Files
oikos/internal/notifier/notifier_test.go
dtoro c96c795126 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.
2026-07-17 22:54:44 +02:00

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)
}