| 1 | package main |
| 2 | |
| 3 | // đŸ¤– Email-anchor tests (email.go): a fake SES — an httptest server capturing |
| 4 | // SendEmail bodies — plays the mail provider, so the full request→redeem path |
| 5 | // runs with no AWS and no network. |
| 6 | |
| 7 | import ( |
| 8 | "encoding/json" |
| 9 | "io" |
| 10 | "net/http" |
| 11 | "net/http/httptest" |
| 12 | "net/url" |
| 13 | "regexp" |
| 14 | "strings" |
| 15 | "testing" |
| 16 | "time" |
| 17 | ) |
| 18 | |
| 19 | var codeRe = regexp.MustCompile(`\b(\d{6})\b`) |
| 20 | |
| 21 | // sentMail is one captured fake-SES send: recipient, the first 6-digit code |
| 22 | // in the body (the typed code — it precedes the magic link's copy), and the |
| 23 | // whole body for contract assertions. |
| 24 | type sentMail struct{ to, code, body string } |
| 25 | |
| 26 | // emailServer is newTestServer with the email anchor on, wired to a fake SES |
| 27 | // that pushes each captured send onto a channel. |
| 28 | func emailServer(t *testing.T) (*httptest.Server, *Server, chan sentMail) { |
| 29 | t.Helper() |
| 30 | ts, s := newTestServer(t) |
| 31 | sent := make(chan sentMail, 8) |
| 32 | fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 33 | body, _ := io.ReadAll(r.Body) |
| 34 | var req struct { |
| 35 | Destination struct { |
| 36 | ToAddresses []string `json:"ToAddresses"` |
| 37 | } `json:"Destination"` |
| 38 | Content struct { |
| 39 | Simple struct { |
| 40 | Body struct { |
| 41 | Text struct { |
| 42 | Data string `json:"Data"` |
| 43 | } `json:"Text"` |
| 44 | } `json:"Body"` |
| 45 | } `json:"Simple"` |
| 46 | } `json:"Content"` |
| 47 | } |
| 48 | if err := json.Unmarshal(body, &req); err != nil || len(req.Destination.ToAddresses) != 1 { |
| 49 | t.Errorf("fake ses: bad body %s", body) |
| 50 | } |
| 51 | m := codeRe.FindStringSubmatch(req.Content.Simple.Body.Text.Data) |
| 52 | if m == nil { |
| 53 | t.Errorf("fake ses: no code in %q", req.Content.Simple.Body.Text.Data) |
| 54 | m = []string{"", ""} |
| 55 | } |
| 56 | sent <- sentMail{to: req.Destination.ToAddresses[0], code: m[1], body: req.Content.Simple.Body.Text.Data} |
| 57 | writeJSON(w, http.StatusOK, map[string]string{"MessageId": "fake"}) |
| 58 | })) |
| 59 | t.Cleanup(fake.Close) |
| 60 | s.mail = &sesMailer{region: "eu-west-1", from: "sign-in@test", accessKey: "k", secretKey: "s", endpoint: fake.URL} |
| 61 | s.emailPepper = []byte("test-email-pepper") |
| 62 | s.emailCodes = newEmailCodes() |
| 63 | return ts, s, sent |
| 64 | } |
| 65 | |
| 66 | func requestMail(t *testing.T, ts *httptest.Server, sent chan sentMail, addr string) sentMail { |
| 67 | t.Helper() |
| 68 | b, _ := json.Marshal(map[string]string{"email": addr}) |
| 69 | resp := do(t, "POST", ts.URL+"/api/email/request", "", nil, b) |
| 70 | if resp.StatusCode != http.StatusOK { |
| 71 | t.Fatalf("request: got %d", resp.StatusCode) |
| 72 | } |
| 73 | select { |
| 74 | case m := <-sent: |
| 75 | if m.to != addr { |
| 76 | t.Fatalf("mail went to %q, want %q", m.to, addr) |
| 77 | } |
| 78 | return m |
| 79 | case <-time.After(2 * time.Second): |
| 80 | t.Fatalf("no mail arrived for %s", addr) |
| 81 | return sentMail{} |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | func requestCode(t *testing.T, ts *httptest.Server, sent chan sentMail, addr string) string { |
| 86 | t.Helper() |
| 87 | return requestMail(t, ts, sent, addr).code |
| 88 | } |
| 89 | |
| 90 | func verifyCode(t *testing.T, ts *httptest.Server, headers map[string]string, body map[string]any) *http.Response { |
| 91 | t.Helper() |
| 92 | b, _ := json.Marshal(body) |
| 93 | return do(t, "POST", ts.URL+"/api/email/verify", "", headers, b) |
| 94 | } |
| 95 | |
| 96 | func TestEmailDisabledIs404(t *testing.T) { |
| 97 | ts, _ := newTestServer(t) |
| 98 | resp := do(t, "POST", ts.URL+"/api/email/request", "", nil, []byte(`{"email":"a@b.c"}`)) |
| 99 | if resp.StatusCode != http.StatusNotFound { |
| 100 | t.Fatalf("disabled: got %d, want 404", resp.StatusCode) |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | func TestEmailSignInCreatesThenResolvesAccount(t *testing.T) { |
| 105 | ts, srv, sent := emailServer(t) |
| 106 | |
| 107 | code := requestCode(t, ts, sent, "paul@example.com") |
| 108 | resp := verifyCode(t, ts, nil, map[string]any{"email": "paul@example.com", "code": code}) |
| 109 | if resp.StatusCode != http.StatusOK { |
| 110 | t.Fatalf("verify: got %d", resp.StatusCode) |
| 111 | } |
| 112 | var first struct { |
| 113 | Token string `json:"token"` |
| 114 | Proof string `json:"proof"` |
| 115 | Created bool `json:"created"` |
| 116 | } |
| 117 | if err := json.NewDecoder(resp.Body).Decode(&first); err != nil { |
| 118 | t.Fatalf("decode: %v", err) |
| 119 | } |
| 120 | if first.Token == "" || first.Proof == "" || !first.Created { |
| 121 | t.Fatalf("first: %+v", first) |
| 122 | } |
| 123 | put := do(t, "PUT", ts.URL+"/api/blob", first.Token, map[string]string{"If-Match": "0"}, []byte("sealed")) |
| 124 | if put.StatusCode != http.StatusOK { |
| 125 | t.Fatalf("blob put: got %d", put.StatusCode) |
| 126 | } |
| 127 | |
| 128 | // The code is single-use. |
| 129 | resp = verifyCode(t, ts, nil, map[string]any{"email": "paul@example.com", "code": code}) |
| 130 | if resp.StatusCode != http.StatusUnauthorized { |
| 131 | t.Fatalf("reuse: got %d, want 401", resp.StatusCode) |
| 132 | } |
| 133 | |
| 134 | // Address case-folds to the same account; a second code reaches the same |
| 135 | // vault. (The 30s resend gap applies per-index — reach into the code |
| 136 | // store and age the entry, the seed-state-directly convention.) |
| 137 | idx := srv.emailIndex("PAUL@example.com") |
| 138 | srv.emailCodes.mu.Lock() |
| 139 | if cur, ok := srv.emailCodes.codes[idx]; ok { |
| 140 | cur.sent = time.Now().Add(-time.Minute) |
| 141 | } |
| 142 | srv.emailCodes.mu.Unlock() |
| 143 | code2 := requestCode(t, ts, sent, "PAUL@example.com") |
| 144 | resp = verifyCode(t, ts, nil, map[string]any{"email": "PAUL@example.com", "code": code2}) |
| 145 | var second struct { |
| 146 | Token string `json:"token"` |
| 147 | Created bool `json:"created"` |
| 148 | } |
| 149 | if err := json.NewDecoder(resp.Body).Decode(&second); err != nil { |
| 150 | t.Fatalf("decode second: %v", err) |
| 151 | } |
| 152 | if second.Created { |
| 153 | t.Fatalf("case-folded address created a fresh account") |
| 154 | } |
| 155 | get := do(t, "GET", ts.URL+"/api/blob", second.Token, nil, nil) |
| 156 | if b, _ := io.ReadAll(get.Body); string(b) != "sealed" { |
| 157 | t.Fatalf("second token reads a different vault: %q", b) |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | func TestEmailWrongCodeAndAttemptCap(t *testing.T) { |
| 162 | ts, s, sent := emailServer(t) |
| 163 | code := requestCode(t, ts, sent, "cap@example.com") |
| 164 | for i := 0; i < emailCodeAttempts; i++ { |
| 165 | resp := verifyCode(t, ts, nil, map[string]any{"email": "cap@example.com", "code": "000000"}) |
| 166 | if resp.StatusCode != http.StatusUnauthorized { |
| 167 | t.Fatalf("wrong code: got %d", resp.StatusCode) |
| 168 | } |
| 169 | } |
| 170 | // The cap has burned the code: even the right one no longer works. |
| 171 | resp := verifyCode(t, ts, nil, map[string]any{"email": "cap@example.com", "code": code}) |
| 172 | if resp.StatusCode != http.StatusUnauthorized { |
| 173 | t.Fatalf("post-cap right code: got %d, want 401", resp.StatusCode) |
| 174 | } |
| 175 | _ = s |
| 176 | } |
| 177 | |
| 178 | func TestEmailCodeExpiry(t *testing.T) { |
| 179 | ts, s, sent := emailServer(t) |
| 180 | code := requestCode(t, ts, sent, "old@example.com") |
| 181 | idx := s.emailIndex("old@example.com") |
| 182 | s.emailCodes.mu.Lock() |
| 183 | s.emailCodes.codes[idx].expires = time.Now().Add(-time.Second) |
| 184 | s.emailCodes.mu.Unlock() |
| 185 | resp := verifyCode(t, ts, nil, map[string]any{"email": "old@example.com", "code": code}) |
| 186 | if resp.StatusCode != http.StatusUnauthorized { |
| 187 | t.Fatalf("expired code: got %d, want 401", resp.StatusCode) |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | func TestEmailResendGapIsSilent(t *testing.T) { |
| 192 | ts, _, sent := emailServer(t) |
| 193 | _ = requestCode(t, ts, sent, "gap@example.com") |
| 194 | // An immediate second request: same 200, no second mail. |
| 195 | b, _ := json.Marshal(map[string]string{"email": "gap@example.com"}) |
| 196 | resp := do(t, "POST", ts.URL+"/api/email/request", "", nil, b) |
| 197 | if resp.StatusCode != http.StatusOK { |
| 198 | t.Fatalf("resend: got %d, want the same 200", resp.StatusCode) |
| 199 | } |
| 200 | select { |
| 201 | case m := <-sent: |
| 202 | t.Fatalf("resend inside the gap sent mail: %v", m) |
| 203 | case <-time.After(300 * time.Millisecond): |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | func TestEmailLinkToExistingAccount(t *testing.T) { |
| 208 | ts, s, sent := emailServer(t) |
| 209 | uid, token := seedUser(t, s) |
| 210 | proof := s.keys.mintCeremonyProof(uid) |
| 211 | |
| 212 | code := requestCode(t, ts, sent, "link@example.com") |
| 213 | resp := verifyCode(t, ts, nil, map[string]any{"email": "link@example.com", "code": code, "link": true}) |
| 214 | if resp.StatusCode != http.StatusUnauthorized { |
| 215 | t.Fatalf("link without proof: got %d, want 401", resp.StatusCode) |
| 216 | } |
| 217 | // The proofless attempt must NOT have burned the code — the proof gate |
| 218 | // runs before the redeem, so the same code now links fine. |
| 219 | resp = verifyCode(t, ts, map[string]string{"X-Ceremony-Proof": proof}, |
| 220 | map[string]any{"email": "link@example.com", "code": code, "link": true}) |
| 221 | if resp.StatusCode != http.StatusOK { |
| 222 | t.Fatalf("link with proof: got %d", resp.StatusCode) |
| 223 | } |
| 224 | |
| 225 | put := do(t, "PUT", ts.URL+"/api/blob", token, map[string]string{"If-Match": "0"}, []byte("seeded")) |
| 226 | if put.StatusCode != http.StatusOK { |
| 227 | t.Fatalf("seed blob: got %d", put.StatusCode) |
| 228 | } |
| 229 | s.emailCodes.mu.Lock() |
| 230 | delete(s.emailCodes.codes, s.emailIndex("link@example.com")) |
| 231 | s.emailCodes.mu.Unlock() |
| 232 | code = requestCode(t, ts, sent, "link@example.com") |
| 233 | resp = verifyCode(t, ts, nil, map[string]any{"email": "link@example.com", "code": code}) |
| 234 | var signin struct { |
| 235 | Token string `json:"token"` |
| 236 | Created bool `json:"created"` |
| 237 | } |
| 238 | if err := json.NewDecoder(resp.Body).Decode(&signin); err != nil { |
| 239 | t.Fatalf("decode: %v", err) |
| 240 | } |
| 241 | if signin.Created { |
| 242 | t.Fatalf("post-link sign-in created a fresh account") |
| 243 | } |
| 244 | get := do(t, "GET", ts.URL+"/api/blob", signin.Token, nil, nil) |
| 245 | if b, _ := io.ReadAll(get.Body); string(b) != "seeded" { |
| 246 | t.Fatalf("linked sign-in reads a different vault: %q", b) |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | // TestEmailMailCarriesMagicLink pins the mail side of the magic-link contract |
| 251 | // (#1091): under the code, the same address+code as a tappable link whose |
| 252 | // values ride the URL #fragment (never sent to any server — the page reads |
| 253 | // the hash and runs the ordinary /api/email/verify). The JS half can't run |
| 254 | // here, so the format assertion is the spec: origin + "/#email=" + the |
| 255 | // address percent-escaped exactly as decodeURIComponent inverts (plus- |
| 256 | // addressing round-trips) + "&code=" + the 6 digits. |
| 257 | func TestEmailMailCarriesMagicLink(t *testing.T) { |
| 258 | ts, _, sent := emailServer(t) |
| 259 | const addr = "paul+tag@example.com" |
| 260 | m := requestMail(t, ts, sent, addr) |
| 261 | |
| 262 | link := regexp.MustCompile(`(?m)^Or tap: (\S+)$`).FindStringSubmatch(m.body) |
| 263 | if link == nil { |
| 264 | t.Fatalf("no link line in %q", m.body) |
| 265 | } |
| 266 | // The test server's origin is http://localhost:1748 (newTestServer). |
| 267 | want := "http://localhost:1748/#email=paul%2Btag%40example.com&code=" + m.code |
| 268 | if link[1] != want { |
| 269 | t.Fatalf("link = %q, want %q", link[1], want) |
| 270 | } |
| 271 | // The code stays first — the link rides under it, never replaces it. |
| 272 | if !strings.HasPrefix(m.body, "Your sign-in code is "+m.code) { |
| 273 | t.Fatalf("code is not the mail's opening line: %q", m.body) |
| 274 | } |
| 275 | |
| 276 | // And the link's values redeem through the ordinary verify: decode the |
| 277 | // fragment the way the page does (percent-unescape, "+" left alone — |
| 278 | // PathUnescape matches decodeURIComponent here) and post it. |
| 279 | frag := regexp.MustCompile(`^http://localhost:1748/#email=([^&]*)&code=(\d{6})$`).FindStringSubmatch(link[1]) |
| 280 | if frag == nil { |
| 281 | t.Fatalf("link doesn't match the boot fragment shape: %q", link[1]) |
| 282 | } |
| 283 | decoded, err := url.PathUnescape(frag[1]) |
| 284 | if err != nil || decoded != addr { |
| 285 | t.Fatalf("fragment address decodes to %q (%v), want %q", decoded, err, addr) |
| 286 | } |
| 287 | resp := verifyCode(t, ts, nil, map[string]any{"email": decoded, "code": frag[2]}) |
| 288 | if resp.StatusCode != http.StatusOK { |
| 289 | t.Fatalf("verify via link values: got %d", resp.StatusCode) |
| 290 | } |
| 291 | } |
| 292 | |