package main // 🤖 Email-anchor tests (email.go): a fake SES — an httptest server capturing // SendEmail bodies — plays the mail provider, so the full request→redeem path // runs with no AWS and no network. import ( "encoding/json" "io" "net/http" "net/http/httptest" "net/url" "regexp" "strings" "testing" "time" ) var codeRe = regexp.MustCompile(`\b(\d{6})\b`) // sentMail is one captured fake-SES send: recipient, the first 6-digit code // in the body (the typed code — it precedes the magic link's copy), and the // whole body for contract assertions. type sentMail struct{ to, code, body string } // emailServer is newTestServer with the email anchor on, wired to a fake SES // that pushes each captured send onto a channel. func emailServer(t *testing.T) (*httptest.Server, *Server, chan sentMail) { t.Helper() ts, s := newTestServer(t) sent := make(chan sentMail, 8) fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) var req struct { Destination struct { ToAddresses []string `json:"ToAddresses"` } `json:"Destination"` Content struct { Simple struct { Body struct { Text struct { Data string `json:"Data"` } `json:"Text"` } `json:"Body"` } `json:"Simple"` } `json:"Content"` } if err := json.Unmarshal(body, &req); err != nil || len(req.Destination.ToAddresses) != 1 { t.Errorf("fake ses: bad body %s", body) } m := codeRe.FindStringSubmatch(req.Content.Simple.Body.Text.Data) if m == nil { t.Errorf("fake ses: no code in %q", req.Content.Simple.Body.Text.Data) m = []string{"", ""} } sent <- sentMail{to: req.Destination.ToAddresses[0], code: m[1], body: req.Content.Simple.Body.Text.Data} writeJSON(w, http.StatusOK, map[string]string{"MessageId": "fake"}) })) t.Cleanup(fake.Close) s.mail = &sesMailer{region: "eu-west-1", from: "sign-in@test", accessKey: "k", secretKey: "s", endpoint: fake.URL} s.emailPepper = []byte("test-email-pepper") s.emailCodes = newEmailCodes() return ts, s, sent } func requestMail(t *testing.T, ts *httptest.Server, sent chan sentMail, addr string) sentMail { t.Helper() b, _ := json.Marshal(map[string]string{"email": addr}) resp := do(t, "POST", ts.URL+"/api/email/request", "", nil, b) if resp.StatusCode != http.StatusOK { t.Fatalf("request: got %d", resp.StatusCode) } select { case m := <-sent: if m.to != addr { t.Fatalf("mail went to %q, want %q", m.to, addr) } return m case <-time.After(2 * time.Second): t.Fatalf("no mail arrived for %s", addr) return sentMail{} } } func requestCode(t *testing.T, ts *httptest.Server, sent chan sentMail, addr string) string { t.Helper() return requestMail(t, ts, sent, addr).code } func verifyCode(t *testing.T, ts *httptest.Server, headers map[string]string, body map[string]any) *http.Response { t.Helper() b, _ := json.Marshal(body) return do(t, "POST", ts.URL+"/api/email/verify", "", headers, b) } func TestEmailDisabledIs404(t *testing.T) { ts, _ := newTestServer(t) resp := do(t, "POST", ts.URL+"/api/email/request", "", nil, []byte(`{"email":"a@b.c"}`)) if resp.StatusCode != http.StatusNotFound { t.Fatalf("disabled: got %d, want 404", resp.StatusCode) } } func TestEmailSignInCreatesThenResolvesAccount(t *testing.T) { ts, srv, sent := emailServer(t) code := requestCode(t, ts, sent, "paul@example.com") resp := verifyCode(t, ts, nil, map[string]any{"email": "paul@example.com", "code": code}) if resp.StatusCode != http.StatusOK { t.Fatalf("verify: got %d", resp.StatusCode) } var first struct { Token string `json:"token"` Proof string `json:"proof"` Created bool `json:"created"` } if err := json.NewDecoder(resp.Body).Decode(&first); err != nil { t.Fatalf("decode: %v", err) } if first.Token == "" || first.Proof == "" || !first.Created { t.Fatalf("first: %+v", first) } put := do(t, "PUT", ts.URL+"/api/blob", first.Token, map[string]string{"If-Match": "0"}, []byte("sealed")) if put.StatusCode != http.StatusOK { t.Fatalf("blob put: got %d", put.StatusCode) } // The code is single-use. resp = verifyCode(t, ts, nil, map[string]any{"email": "paul@example.com", "code": code}) if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("reuse: got %d, want 401", resp.StatusCode) } // Address case-folds to the same account; a second code reaches the same // vault. (The 30s resend gap applies per-index — reach into the code // store and age the entry, the seed-state-directly convention.) idx := srv.emailIndex("PAUL@example.com") srv.emailCodes.mu.Lock() if cur, ok := srv.emailCodes.codes[idx]; ok { cur.sent = time.Now().Add(-time.Minute) } srv.emailCodes.mu.Unlock() code2 := requestCode(t, ts, sent, "PAUL@example.com") resp = verifyCode(t, ts, nil, map[string]any{"email": "PAUL@example.com", "code": code2}) var second struct { Token string `json:"token"` Created bool `json:"created"` } if err := json.NewDecoder(resp.Body).Decode(&second); err != nil { t.Fatalf("decode second: %v", err) } if second.Created { t.Fatalf("case-folded address created a fresh account") } get := do(t, "GET", ts.URL+"/api/blob", second.Token, nil, nil) if b, _ := io.ReadAll(get.Body); string(b) != "sealed" { t.Fatalf("second token reads a different vault: %q", b) } } func TestEmailWrongCodeAndAttemptCap(t *testing.T) { ts, s, sent := emailServer(t) code := requestCode(t, ts, sent, "cap@example.com") for i := 0; i < emailCodeAttempts; i++ { resp := verifyCode(t, ts, nil, map[string]any{"email": "cap@example.com", "code": "000000"}) if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("wrong code: got %d", resp.StatusCode) } } // The cap has burned the code: even the right one no longer works. resp := verifyCode(t, ts, nil, map[string]any{"email": "cap@example.com", "code": code}) if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("post-cap right code: got %d, want 401", resp.StatusCode) } _ = s } func TestEmailCodeExpiry(t *testing.T) { ts, s, sent := emailServer(t) code := requestCode(t, ts, sent, "old@example.com") idx := s.emailIndex("old@example.com") s.emailCodes.mu.Lock() s.emailCodes.codes[idx].expires = time.Now().Add(-time.Second) s.emailCodes.mu.Unlock() resp := verifyCode(t, ts, nil, map[string]any{"email": "old@example.com", "code": code}) if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expired code: got %d, want 401", resp.StatusCode) } } func TestEmailResendGapIsSilent(t *testing.T) { ts, _, sent := emailServer(t) _ = requestCode(t, ts, sent, "gap@example.com") // An immediate second request: same 200, no second mail. b, _ := json.Marshal(map[string]string{"email": "gap@example.com"}) resp := do(t, "POST", ts.URL+"/api/email/request", "", nil, b) if resp.StatusCode != http.StatusOK { t.Fatalf("resend: got %d, want the same 200", resp.StatusCode) } select { case m := <-sent: t.Fatalf("resend inside the gap sent mail: %v", m) case <-time.After(300 * time.Millisecond): } } func TestEmailLinkToExistingAccount(t *testing.T) { ts, s, sent := emailServer(t) uid, token := seedUser(t, s) proof := s.keys.mintCeremonyProof(uid) code := requestCode(t, ts, sent, "link@example.com") resp := verifyCode(t, ts, nil, map[string]any{"email": "link@example.com", "code": code, "link": true}) if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("link without proof: got %d, want 401", resp.StatusCode) } // The proofless attempt must NOT have burned the code — the proof gate // runs before the redeem, so the same code now links fine. resp = verifyCode(t, ts, map[string]string{"X-Ceremony-Proof": proof}, map[string]any{"email": "link@example.com", "code": code, "link": true}) if resp.StatusCode != http.StatusOK { t.Fatalf("link with proof: got %d", resp.StatusCode) } put := do(t, "PUT", ts.URL+"/api/blob", token, map[string]string{"If-Match": "0"}, []byte("seeded")) if put.StatusCode != http.StatusOK { t.Fatalf("seed blob: got %d", put.StatusCode) } s.emailCodes.mu.Lock() delete(s.emailCodes.codes, s.emailIndex("link@example.com")) s.emailCodes.mu.Unlock() code = requestCode(t, ts, sent, "link@example.com") resp = verifyCode(t, ts, nil, map[string]any{"email": "link@example.com", "code": code}) var signin struct { Token string `json:"token"` Created bool `json:"created"` } if err := json.NewDecoder(resp.Body).Decode(&signin); err != nil { t.Fatalf("decode: %v", err) } if signin.Created { t.Fatalf("post-link sign-in created a fresh account") } get := do(t, "GET", ts.URL+"/api/blob", signin.Token, nil, nil) if b, _ := io.ReadAll(get.Body); string(b) != "seeded" { t.Fatalf("linked sign-in reads a different vault: %q", b) } } // TestEmailMailCarriesMagicLink pins the mail side of the magic-link contract // (#1091): under the code, the same address+code as a tappable link whose // values ride the URL #fragment (never sent to any server — the page reads // the hash and runs the ordinary /api/email/verify). The JS half can't run // here, so the format assertion is the spec: origin + "/#email=" + the // address percent-escaped exactly as decodeURIComponent inverts (plus- // addressing round-trips) + "&code=" + the 6 digits. func TestEmailMailCarriesMagicLink(t *testing.T) { ts, _, sent := emailServer(t) const addr = "paul+tag@example.com" m := requestMail(t, ts, sent, addr) link := regexp.MustCompile(`(?m)^Or tap: (\S+)$`).FindStringSubmatch(m.body) if link == nil { t.Fatalf("no link line in %q", m.body) } // The test server's origin is http://localhost:1748 (newTestServer). want := "http://localhost:1748/#email=paul%2Btag%40example.com&code=" + m.code if link[1] != want { t.Fatalf("link = %q, want %q", link[1], want) } // The code stays first — the link rides under it, never replaces it. if !strings.HasPrefix(m.body, "Your sign-in code is "+m.code) { t.Fatalf("code is not the mail's opening line: %q", m.body) } // And the link's values redeem through the ordinary verify: decode the // fragment the way the page does (percent-unescape, "+" left alone — // PathUnescape matches decodeURIComponent here) and post it. frag := regexp.MustCompile(`^http://localhost:1748/#email=([^&]*)&code=(\d{6})$`).FindStringSubmatch(link[1]) if frag == nil { t.Fatalf("link doesn't match the boot fragment shape: %q", link[1]) } decoded, err := url.PathUnescape(frag[1]) if err != nil || decoded != addr { t.Fatalf("fragment address decodes to %q (%v), want %q", decoded, err, addr) } resp := verifyCode(t, ts, nil, map[string]any{"email": decoded, "code": frag[2]}) if resp.StatusCode != http.StatusOK { t.Fatalf("verify via link values: got %d", resp.StatusCode) } }