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.
188 lines
5.7 KiB
Go
188 lines
5.7 KiB
Go
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)
|
|
}
|