package notify_test import ( "context" "crypto/hmac" "crypto/sha256" "encoding/json" "io" "net/http" "net/http/httptest" "sync" "testing" "github.com/alexei/tinyforge/internal/notify" ) // TestSignedRoundTrip is the canonical "the receiver can verify what we // sent" check. Sender signs the body with a secret; the test server reads // the raw body and the X-Hub-Signature-256 header and verifies via // VerifySignature. A regression here means receivers built against our // docs would silently reject real notifications. func TestSignedRoundTrip(t *testing.T) { const secret = "super-secret-test-key-not-used-in-prod" var receivedBody []byte var receivedSig string var receivedEvent string var receivedDelivery string var receivedTier string var receivedTimestamp string done := make(chan struct{}) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer close(done) body, _ := io.ReadAll(r.Body) receivedBody = body receivedSig = r.Header.Get(notify.HeaderSignature) receivedEvent = r.Header.Get(notify.HeaderEvent) receivedDelivery = r.Header.Get(notify.HeaderDelivery) receivedTier = r.Header.Get(notify.HeaderTier) receivedTimestamp = r.Header.Get(notify.HeaderTimestamp) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"ok":true}`)) })) defer srv.Close() n := notify.New() n.SendSigned(srv.URL, secret, notify.TierStage, notify.Event{ Type: "deploy_success", Project: "demo", Stage: "prod", }) n.Drain() <-done if !notify.VerifySignature(secret, receivedBody, receivedSig) { t.Fatalf("receiver could not verify signature: header=%q body=%q", receivedSig, receivedBody) } if receivedEvent != "deploy_success" { t.Errorf("event header = %q, want deploy_success", receivedEvent) } if receivedDelivery == "" { t.Errorf("delivery ID header missing") } if receivedTier != string(notify.TierStage) { t.Errorf("tier header = %q, want %q", receivedTier, notify.TierStage) } if receivedTimestamp == "" { t.Errorf("timestamp header missing") } // Sanity: payload roundtrips through JSON unchanged. var got notify.Event if err := json.Unmarshal(receivedBody, &got); err != nil { t.Fatalf("decode body: %v", err) } if got.Project != "demo" || got.Stage != "prod" { t.Errorf("body fields lost in transit: %+v", got) } } // TestUnsignedSendOmitsSignatureHeader covers the back-compat path: a // caller that hasn't yet generated a signing secret should still be able to // dispatch events, just without the signature header. Existing receivers // must not break when a Tinyforge instance upgrades but hasn't enabled // signing yet. func TestUnsignedSendOmitsSignatureHeader(t *testing.T) { var sigHeader string done := make(chan struct{}) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer close(done) sigHeader = r.Header.Get(notify.HeaderSignature) w.WriteHeader(http.StatusOK) })) defer srv.Close() n := notify.New() n.SendSigned(srv.URL, "", notify.TierSettings, notify.Event{Type: "test"}) n.Drain() <-done if sigHeader != "" { t.Errorf("expected no signature header on unsigned send, got %q", sigHeader) } } // TestVerifyRejectsTamperedBody is the negative half of the round-trip: // flipping a single byte in the signed body must fail verification. // Catches accidental MAC truncation / wrong hash family / non-constant-time // compares (the last only weakly, but the round-trip already guards // correctness; this just locks the contract). func TestVerifyRejectsTamperedBody(t *testing.T) { const secret = "abc" body := []byte(`{"type":"deploy_success"}`) sig := "sha256=" + hexEncode(hmacSha256(secret, body)) if !notify.VerifySignature(secret, body, sig) { t.Fatalf("control: legit signature failed to verify") } tampered := append([]byte(nil), body...) tampered[1] = 'X' // flip one byte if notify.VerifySignature(secret, tampered, sig) { t.Errorf("verifier accepted tampered body — signature scheme is broken") } if notify.VerifySignature("wrong-secret", body, sig) { t.Errorf("verifier accepted wrong secret") } if notify.VerifySignature(secret, body, "") { t.Errorf("verifier accepted empty signature header") } if notify.VerifySignature("", body, sig) { t.Errorf("verifier accepted empty secret") } } // TestSendSyncForTestReturnsReceiverStatus is the "send test" UI button // contract: when the receiver returns a non-2xx status, we must surface // both the status code and the body preview rather than swallowing them. // Operators rely on this to debug mis-pointed receivers. func TestSendSyncForTestReturnsReceiverStatus(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) _, _ = w.Write([]byte("invalid signature")) })) defer srv.Close() n := notify.New() res := n.SendSyncForTest(context.Background(), srv.URL, "secret", notify.TierProject, notify.Event{Type: "test"}) if res.StatusCode != http.StatusForbidden { t.Errorf("status_code = %d, want 403", res.StatusCode) } if res.ResponseSnippet != "invalid signature" { t.Errorf("response_snippet = %q, want 'invalid signature'", res.ResponseSnippet) } if !res.SignatureSent { t.Errorf("signature_sent should be true when secret is provided") } if res.Tier != notify.TierProject { t.Errorf("tier = %q, want project", res.Tier) } if res.Error == "" { t.Errorf("Error field should be set on 4xx response") } } // TestSendSyncForTestEmptyURL is the guard for the test endpoint when no // URL is configured at any tier. The handler relies on Error being non-empty // to render the "no URL configured" message, so this contract must hold. func TestSendSyncForTestEmptyURL(t *testing.T) { n := notify.New() res := n.SendSyncForTest(context.Background(), "", "secret", notify.TierSettings, notify.Event{Type: "test"}) if res.Error == "" { t.Errorf("Error field should be set when URL is empty") } if res.StatusCode != 0 { t.Errorf("StatusCode should remain 0 when no request was made, got %d", res.StatusCode) } } // TestConcurrentSendsAllArrive guards the WaitGroup contract on Drain — a // regression where Drain returns before in-flight goroutines complete would // drop notifications during graceful shutdown. func TestConcurrentSendsAllArrive(t *testing.T) { const fanout = 20 var ( mu sync.Mutex received int ) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() received++ mu.Unlock() w.WriteHeader(http.StatusOK) })) defer srv.Close() n := notify.New() for i := 0; i < fanout; i++ { n.SendSigned(srv.URL, "key", notify.TierSettings, notify.Event{Type: "test"}) } n.Drain() mu.Lock() defer mu.Unlock() if received != fanout { t.Errorf("received %d sends, want %d", received, fanout) } } // --- helpers ---------------------------------------------------------- // hmacSha256 + hexEncode duplicate the production sign() body so the // negative tests don't depend on the un-exported helper. If the exported // VerifySignature contract changes, this is the canary. func hmacSha256(secret string, body []byte) []byte { h := hmac.New(sha256.New, []byte(secret)) h.Write(body) return h.Sum(nil) } func hexEncode(b []byte) string { const hexdigits = "0123456789abcdef" out := make([]byte, len(b)*2) for i, x := range b { out[i*2] = hexdigits[x>>4] out[i*2+1] = hexdigits[x&0x0f] } return string(out) }