mirror of
https://github.com/hanzoai/notify.git
synced 2026-10-30 15:04:32 +00:00
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.
179 lines
5.2 KiB
Go
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)
|
|
}
|
|
}
|