test: add unit tests for 6 previously-untested packages (R7)
Added pure unit tests for all packages that had 0% coverage. Where pure logic was entangled with DB calls, extracted testable helpers first. internal/domain (0% -> 100%): - TestIsNil, TestCanTransition (all 30 state transitions), TestSentinelErrors, TestSignalTransitionsComplete internal/learning (0% -> 26.2%): - Refactored processGroup to extract 4 pure helpers: countOutcomes, computeConfidence, shouldValidate, shouldQuarantine - TestWilsonLowerBound (monotonicity, edge cases, sample-size cap) - TestCountOutcomes, TestComputeConfidence, TestShouldValidate, TestShouldQuarantine (table-driven) - Remaining gap: extractPatterns/processGroup DB calls need make test-db internal/policy (39% -> 50%): - Extracted determineRoute from ClassifySignal (pure route logic) - TestDetermineRoute (7 cases covering global/entity kill-switches, approval) - Remaining gap: ClassifySignal/computeBlastRadius need DB mock internal/knowledge (0% -> 14.2%): - TestContentHash, TestStr, TestStrSlice, TestMapVal, TestToPGArray - Documented latent bug: toPGArray doesn't escape " or \\ in tags - Remaining gap: ingest* functions need make test-db internal/actuator (0% -> 14.7%): - TestSSHErrorClassString, TestClassifySSHError (11 cases incl. net.Error mock) - TestParseProcedure, TestSetDefaultSSHTimeout - Circuit breaker full state-machine test (open/close/reset/per-target) - Remaining gap: ExecuteProcedure/ProvisionLXC need SSH+DB fixtures internal/scheduler (0% -> 7.3%): - TestParsePingLatency (Linux/macOS formats), TestAllowlistedScript - TestEvaluateSeverity (threshold logic, crit:0 skip, signalKind fallback) - Remaining gap: checkHTTP/checkTCP need httptest; runCheckPass needs DB internal/notifier (0% -> 6.4%): - TestHashToken, TestGenerateApprovalToken (HMAC re-derivation) - Remaining gap: checkReaction/sendMatrixAlert need httptest; DB funcs need make test-db All tests pass with -race. domain hits its 60% gate at 100%. The remaining packages need integration tests (make test-db) and/or httptest-based tests to reach their coverage gates — tracked as follow-up.
This commit is contained in:
187
internal/notifier/notifier_test.go
Normal file
187
internal/notifier/notifier_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var hex64Re = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
|
||||
func TestHashToken(t *testing.T) {
|
||||
// sha256("") == e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
|
||||
emptyHash := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
|
||||
t.Run("determinism same input same output", func(t *testing.T) {
|
||||
a := hashToken("approval-token-123")
|
||||
b := hashToken("approval-token-123")
|
||||
if a != b {
|
||||
t.Fatalf("hashToken not deterministic: %q vs %q", a, b)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty string known sha256", func(t *testing.T) {
|
||||
got := hashToken("")
|
||||
if got != emptyHash {
|
||||
t.Fatalf("hashToken(\"\") = %q, want %q", got, emptyHash)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different inputs different outputs", func(t *testing.T) {
|
||||
a := hashToken("one")
|
||||
b := hashToken("two")
|
||||
if a == b {
|
||||
t.Fatalf("hashToken collided for different inputs: %q", a)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("output is valid 64-char hex", func(t *testing.T) {
|
||||
for _, in := range []string{"", "abc", "some-longer-token-value-xyz"} {
|
||||
got := hashToken(in)
|
||||
if !hex64Re.MatchString(got) {
|
||||
t.Fatalf("hashToken(%q) = %q, not 64-char lowercase hex", in, got)
|
||||
}
|
||||
// also must decode cleanly to 32 bytes
|
||||
b, err := hex.DecodeString(got)
|
||||
if err != nil {
|
||||
t.Fatalf("hashToken(%q) decode error: %v", in, err)
|
||||
}
|
||||
if len(b) != 32 {
|
||||
t.Fatalf("hashToken(%q) decoded len = %d, want 32", in, len(b))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateApprovalToken(t *testing.T) {
|
||||
id := uuid.New()
|
||||
|
||||
t.Run("output is 64-char hex", func(t *testing.T) {
|
||||
tok := generateApprovalToken(id, "super-secret")
|
||||
if !hex64Re.MatchString(tok) {
|
||||
t.Fatalf("generateApprovalToken = %q, not 64-char lowercase hex", tok)
|
||||
}
|
||||
b, err := hex.DecodeString(tok)
|
||||
if err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
if len(b) != 32 {
|
||||
t.Fatalf("decoded len = %d, want 32 (sha256)", len(b))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty secret falls back to dev secret no panic", func(t *testing.T) {
|
||||
tok := generateApprovalToken(id, "")
|
||||
if tok == "" {
|
||||
t.Fatal("empty secret produced empty token")
|
||||
}
|
||||
if !hex64Re.MatchString(tok) {
|
||||
t.Fatalf("empty-secret token %q not 64-char hex", tok)
|
||||
}
|
||||
})
|
||||
|
||||
// Non-determinism: time.Now().UnixNano() is embedded in the HMAC message,
|
||||
// so two calls with identical inputs produce different tokens (unless the
|
||||
// clock has nanosecond-identical reads, which we do not assert against).
|
||||
t.Run("same inputs twice produce different tokens (time-based)", func(t *testing.T) {
|
||||
a := generateApprovalToken(id, "stable-secret")
|
||||
b := generateApprovalToken(id, "stable-secret")
|
||||
if a == b {
|
||||
// Not a hard failure (clock granularity), but document expectation.
|
||||
t.Logf("note: two immediate calls returned identical token %q — clock resolution collapsed", a)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different secrets produce different tokens", func(t *testing.T) {
|
||||
a := generateApprovalToken(id, "secret-a")
|
||||
b := generateApprovalToken(id, "secret-b")
|
||||
if a == b {
|
||||
t.Fatalf("different secrets produced same token %q", a)
|
||||
}
|
||||
})
|
||||
|
||||
// HMAC correctness: re-derive the token with the same secret + approvalID
|
||||
// using a freshly captured timestamp window is impossible because we don't
|
||||
// observe the embedded timestamp. Instead, verify the token is a valid
|
||||
// HMAC-SHA256 by brute-forcing a small time window around now: reconstruct
|
||||
// mac(secret, approvalID || ts) for ts in [now-N, now] and confirm one
|
||||
// matches. This proves the token genuinely is an HMAC over (approvalID, ts)
|
||||
// with the supplied secret.
|
||||
t.Run("token is HMAC-SHA256 over approvalID+timestamp with secret", func(t *testing.T) {
|
||||
secret := "hmac-verify-secret"
|
||||
before := nowNanos()
|
||||
tok := generateApprovalToken(id, secret)
|
||||
after := nowNanos()
|
||||
|
||||
// The token's embedded ts is captured inside generateApprovalToken,
|
||||
// which is called after `before` was sampled — so ts ∈ [before, after].
|
||||
// Add a tiny ±band to absorb scheduler jitter on loaded runners.
|
||||
lo := before - 10_000
|
||||
hi := after + 10_000
|
||||
matched := false
|
||||
for ts := lo; ts <= hi; ts++ {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(id.String()))
|
||||
mac.Write([]byte(formatInt(ts)))
|
||||
cand := hex.EncodeToString(mac.Sum(nil))
|
||||
if hmac.Equal([]byte(cand), []byte(tok)) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
t.Fatalf("token %q did not match any HMAC in window [%d,%d]; not a valid HMAC-SHA256 over approvalID+ts", tok, lo, hi)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty secret HMAC uses dev fallback secret", func(t *testing.T) {
|
||||
before := nowNanos()
|
||||
tok := generateApprovalToken(id, "")
|
||||
after := nowNanos()
|
||||
dev := "dev-secret-do-not-use-in-prod"
|
||||
lo := before - 10_000
|
||||
hi := after + 10_000
|
||||
matched := false
|
||||
for ts := lo; ts <= hi; ts++ {
|
||||
mac := hmac.New(sha256.New, []byte(dev))
|
||||
mac.Write([]byte(id.String()))
|
||||
mac.Write([]byte(formatInt(ts)))
|
||||
cand := hex.EncodeToString(mac.Sum(nil))
|
||||
if hmac.Equal([]byte(cand), []byte(tok)) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
t.Fatalf("empty-secret token %q did not match dev-fallback HMAC", tok)
|
||||
}
|
||||
})
|
||||
|
||||
// sanity: token should not leak the secret in plaintext
|
||||
t.Run("token does not contain secret substring", func(t *testing.T) {
|
||||
secret := "leakcheck-secret-xyz"
|
||||
tok := generateApprovalToken(id, secret)
|
||||
if strings.Contains(tok, secret) {
|
||||
t.Fatalf("token %q contains secret substring %q", tok, secret)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// nowNanos returns the current nanosecond count, matching the time source
|
||||
// used by generateApprovalToken (time.Now().UnixNano()).
|
||||
func nowNanos() int64 {
|
||||
return time.Now().UnixNano()
|
||||
}
|
||||
|
||||
// formatInt mirrors fmt.Sprintf("%d", ...) used by the production code so the
|
||||
// re-derivation in tests is byte-identical.
|
||||
func formatInt(n int64) string {
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
Reference in New Issue
Block a user