Files
zeekayandGitHub 8466cf5a79 notify: phase 1 — preferences, channel router, one-click unsubscribe, STOP keyword (#7)
Substrate for the notification-preferences-and-channel-routing paper.
WhatsApp + push are Phase 2/3 — Channel constants for `wa` and `push`
are declared so the wire shape is forward-compatible, but the router
never returns them in Phase 1.

Substrates landed in this PR:

1. Data model — 3 new collections wired through schema/schema.go:
   notification_preferences (user-scoped channel + marketing prefs),
   notification_consent_log (append-only audit, FINRA 5y retention),
   notification_unsubscribe_tokens (consumable HMAC tokens, PK = token).

2. Channel router — internal/router/ is a pure (prefs, category, now)
   → []channel function: no IO, no DB reads. Table-driven test walks
   every cell in §3 of the paper (11 categories × 5 channels = 55
   cells); the matrix copy in the test is the canonical guard against
   future drift.

3. CRUD API — routes/preferences.go: GET/PUT /v1/notify/preferences
   (user reads + writes own; auto-creates default row on first GET;
   PUT writes prior+new state to consent log), GET /audit, plus
   admin freeze/unfreeze and audit under /v1/notify/admin/... gated by
   NOTIFY_ADMIN_KEY Bearer.

4. One-click email unsubscribe — internal/unsubscribe/ HMAC-SHA256
   token (user_id|category|expires_at_unix) base64url-encoded;
   routes/unsubscribe.go renders a no-JS confirmation page, flips
   the bit on POST, mirrors with /resubscribe/{token}. Token is the
   auth — the HMAC means only the email recipient can reach the
   endpoint. Default 30-day TTL.

5. List-Unsubscribe header builder — internal/marketing/ produces
   the RFC 8058 + RFC 2369 header pair callers inject into outbound
   marketing emails. Decoupled from the existing send path so the
   SES SendEmail → SendRawEmail switch can land independently.

6. STOP keyword webhook — routes/sms_inbound.go single endpoint
   accepts both Plivo and Twilio (same logical payload; we read
   From/Text and From/Body), matches STOP|UNSUBSCRIBE|QUIT|CANCEL|END
   case-insensitive on the trimmed body, resolves by primary_phone
   (E.164-normalised: strip whitespace/dashes/parens, prepend '+'),
   sets marketing_globally_muted=true, zeros every marketing
   subscription, writes opt_out consent_log row, replies with a
   Twilio/Plivo-shaped XML confirmation envelope.

Defaults: every new user starts with every marketing category OFF;
transactional categories always deliver via email per regulation;
quiet hours 21:00–08:00 America/New_York (TCPA-compliant baseline).

Boot fail-closed: NOTIFY_UNSUBSCRIBE_SECRET is required in
production; empty crashes notifyd. NOTIFY_TEST_MODE=1 degrades the
unsubscribe routes to 503 while the rest of the surface keeps
working.

The existing /v1/notify/send surface and ProviderChain/Resolver/
PlivoResolver are not modified — every new piece composes on top.

LLM.md updated with the Phase 1 invariants + new route table rows.
2026-06-03 20:08:33 -07:00

179 lines
5.2 KiB
Go

package unsubscribe
import (
"errors"
"strings"
"testing"
"time"
)
// TestNewSignerEmptySecretRejected guards the fail-closed boot policy:
// notifyd MUST refuse to construct the signer without NOTIFY_UNSUBSCRIBE_SECRET.
func TestNewSignerEmptySecretRejected(t *testing.T) {
t.Parallel()
if _, err := NewSigner(""); err == nil {
t.Fatalf("empty secret must be rejected")
}
if _, err := NewSigner(" "); err == nil {
t.Fatalf("whitespace-only secret must be rejected")
}
}
// TestSignVerifyRoundTrip is the canonical happy path: a freshly signed
// token verifies back to the same payload.
func TestSignVerifyRoundTrip(t *testing.T) {
t.Parallel()
s, err := NewSigner("test-secret-bytes")
if err != nil {
t.Fatalf("NewSigner: %v", err)
}
now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
exp := now.Add(Default30Days)
tok, err := s.Sign("user-abc", "promotional", exp)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if tok == "" {
t.Fatalf("Sign returned empty token")
}
got, err := s.Verify(tok, now)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if got.UserID != "user-abc" || got.Category != "promotional" {
t.Fatalf("Verify returned %+v, want user_id=user-abc category=promotional", got)
}
if !got.ExpiresAt.Equal(exp.Truncate(time.Second)) {
t.Fatalf("Verify ExpiresAt=%v, want %v", got.ExpiresAt, exp.Truncate(time.Second))
}
}
// TestSignWithTTL is the convenience wrapper.
func TestSignWithTTL(t *testing.T) {
t.Parallel()
s, err := NewSigner("k")
if err != nil {
t.Fatalf("NewSigner: %v", err)
}
now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
tok, err := s.SignWithTTL("u1", "", now, time.Hour)
if err != nil {
t.Fatalf("SignWithTTL: %v", err)
}
p, err := s.Verify(tok, now)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if p.UserID != "u1" || p.Category != "" {
t.Fatalf("unexpected payload: %+v", p)
}
}
// TestSignRejectsBadInput catches the input-validation rules.
func TestSignRejectsBadInput(t *testing.T) {
t.Parallel()
s, _ := NewSigner("k")
now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
if _, err := s.Sign("", "promotional", now.Add(time.Hour)); err == nil {
t.Fatalf("empty user_id must be rejected")
}
if _, err := s.Sign("u|x", "promotional", now.Add(time.Hour)); err == nil {
t.Fatalf("user_id containing fieldSep must be rejected")
}
if _, err := s.Sign("u1", "pro|mo", now.Add(time.Hour)); err == nil {
t.Fatalf("category containing fieldSep must be rejected")
}
if _, err := s.Sign("u1", "promo", time.Time{}); err == nil {
t.Fatalf("zero expires_at must be rejected")
}
}
// TestVerifyRejectsBadInput covers the parser failure modes.
func TestVerifyRejectsBadInput(t *testing.T) {
t.Parallel()
s, _ := NewSigner("k")
now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
cases := []string{
"", // empty
"not-base64-!@#$", // bad b64
"AAAA", // too short (< macLen)
strings.Repeat("A", 200), // long but no valid HMAC over a parseable body
}
for _, in := range cases {
_, err := s.Verify(in, now)
if err == nil {
t.Fatalf("Verify(%q) accepted, want error", in)
}
}
}
// TestVerifyRejectsWrongSecret is the security guarantee: a token signed
// under secret A cannot be verified under secret B.
func TestVerifyRejectsWrongSecret(t *testing.T) {
t.Parallel()
a, _ := NewSigner("secret-a")
b, _ := NewSigner("secret-b")
now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
tok, err := a.Sign("u1", "promotional", now.Add(time.Hour))
if err != nil {
t.Fatalf("Sign: %v", err)
}
if _, err := b.Verify(tok, now); !errors.Is(err, ErrInvalid) {
t.Fatalf("wrong secret should yield ErrInvalid, got %v", err)
}
}
// TestVerifyDetectsTampering flips a bit in the payload and asserts the
// MAC catches it.
func TestVerifyDetectsTampering(t *testing.T) {
t.Parallel()
s, _ := NewSigner("k")
now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
tok, err := s.Sign("u1", "promotional", now.Add(time.Hour))
if err != nil {
t.Fatalf("Sign: %v", err)
}
// Flip the last byte (the timestamp portion of the payload).
bad := tok[:len(tok)-1]
if tok[len(tok)-1] != 'A' {
bad += "A"
} else {
bad += "B"
}
if _, err := s.Verify(bad, now); !errors.Is(err, ErrInvalid) {
t.Fatalf("tampered token should yield ErrInvalid, got %v", err)
}
}
// TestVerifyExpired covers the expiry path: a valid HMAC but
// expires_at in the past returns ErrExpired (distinct from ErrInvalid
// so the route can render a friendlier page).
func TestVerifyExpired(t *testing.T) {
t.Parallel()
s, _ := NewSigner("k")
now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
tok, err := s.Sign("u1", "promotional", now.Add(-time.Hour))
if err != nil {
t.Fatalf("Sign: %v", err)
}
if _, err := s.Verify(tok, now); !errors.Is(err, ErrExpired) {
t.Fatalf("expired token should yield ErrExpired, got %v", err)
}
}
// TestSignNilReceiver guards the defensive nil check.
func TestSignNilReceiver(t *testing.T) {
t.Parallel()
var s *Signer
now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
if _, err := s.Sign("u1", "p", now.Add(time.Hour)); err == nil {
t.Fatalf("nil signer should reject Sign")
}
if _, err := s.Verify("anything", now); !errors.Is(err, ErrInvalid) {
t.Fatalf("nil signer should reject Verify with ErrInvalid, got %v", err)
}
}