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