eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package 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
7import (
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
19var 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.
24type 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.
28func 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
66func 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
85func 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
90func 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
96func 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
104func 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
161func 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
178func 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
191func 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
207func 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.
257func 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