carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "bytes"
5 "context"
6 "fmt"
7 "html"
8 "log"
9 "net/http"
10 "net/url"
11 "os"
12 "regexp"
13 "strings"
14 "sync"
15 "testing"
16 "time"
17)
18
19type fakeConsoleMail struct {
20 mu sync.Mutex
21 err error
22 sent []string // to|body
23 // ready is what Ready reports. Construct with ready: true wherever the
24 // test expects the console to be able to send.
25 ready bool
26}
27
28func (f *fakeConsoleMail) Ready(context.Context) bool { return f.ready }
29
30func (f *fakeConsoleMail) Send(_ context.Context, to, _, body string) error {
31 f.mu.Lock()
32 defer f.mu.Unlock()
33 if f.err != nil {
34 return f.err
35 }
36 f.sent = append(f.sent, to+"|"+body)
37 return nil
38}
39
40// sentCopy reads sent under the lock: Send runs on the server's goroutine.
41func (f *fakeConsoleMail) sentCopy() []string {
42 f.mu.Lock()
43 defer f.mu.Unlock()
44 return append([]string(nil), f.sent...)
45}
46
47// inviteFlashAfter reads the flash cookie the last redirect set.
48func inviteFlashAfter(t *testing.T, c *http.Client, base string) (kind, msg string) {
49 t.Helper()
50 u, _ := url.Parse(base)
51 for _, ck := range c.Jar.Cookies(u) {
52 if ck.Name == flashCookie {
53 v, _ := url.QueryUnescape(ck.Value)
54 kind, msg, _ = strings.Cut(v, "|")
55 return kind, msg
56 }
57 }
58 return "", ""
59}
60
61func TestInviteDelivery(t *testing.T) {
62 cases := []struct {
63 name string
64 to string
65 keymail func(fx *consoleFixture)
66 mail *fakeConsoleMail // nil: no email fallback configured
67 wantKind string
68 wantInMsg string // a fragment that identifies the outcome
69 wantFed bool
70 wantMail bool
71 }{
72 {name: "keymail delivers", to: "sam@keymail.dev", mail: &fakeConsoleMail{ready: true},
73 wantKind: "ok", wantInMsg: "Keymail", wantFed: true},
74 {name: "no mailbox, email delivers", to: "sam@gmail.com", mail: &fakeConsoleMail{ready: true},
75 wantKind: "ok", wantInMsg: "emailed", wantMail: true},
76 // The address is a Keymail mailbox (the lookup said so); a price above
77 // the cap is not a reason to email it (spec 4.3).
78 {name: "price too high is final", to: "sam@keymail.dev", mail: &fakeConsoleMail{ready: true},
79 keymail: func(fx *consoleFixture) { fx.fk.powBits = maxFederationPowBits + 1 },
80 wantKind: "err", wantInMsg: "/invite/"},
81 // A Keymail outage for the Keymail host's own address sends nothing.
82 {name: "keymail host lookup 5xx is final", to: "sam@127.0.0.1", mail: &fakeConsoleMail{ready: true},
83 keymail: func(fx *consoleFixture) { fx.fk.lookupStatus["sam@127.0.0.1"] = 500 },
84 wantKind: "err", wantInMsg: "/invite/"},
85 {name: "other domain lookup 5xx falls to email", to: "sam@example.com", mail: &fakeConsoleMail{ready: true},
86 keymail: func(fx *consoleFixture) { fx.fk.lookupStatus["sam@example.com"] = 500 },
87 wantKind: "ok", wantInMsg: "emailed", wantMail: true},
88 // A recipient who blocked the console said no; email to the same
89 // address would go round them. Straight to the hand-off.
90 {name: "blocked is final", to: "sam@keymail.dev", mail: &fakeConsoleMail{ready: true},
91 keymail: func(fx *consoleFixture) { fx.fk.blocked["console@"+OriginHost(fx.ts.URL)] = true },
92 wantKind: "err", wantInMsg: "/invite/"},
93 {name: "both fail", to: "sam@gmail.com", mail: &fakeConsoleMail{ready: true, err: ErrConsoleMailNotReady},
94 wantKind: "err", wantInMsg: "/invite/"},
95 {name: "no email configured", to: "sam@gmail.com",
96 wantKind: "err", wantInMsg: "/invite/"},
97 }
98 for _, c := range cases {
99 t.Run(c.name, func(t *testing.T) {
100 fx := newConsoleFixture(t)
101 if c.keymail != nil {
102 c.keymail(fx)
103 }
104 if c.mail != nil {
105 fx.srv.consoleMail = c.mail
106 }
107 owner := newBrowser(t)
108 signUp(t, fx, owner, "code-owner")
109 csrf := sessionToken(t, fx, owner)
110 st, _, _ := postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {c.to}})
111 if st != http.StatusSeeOther {
112 t.Fatalf("invite = %d", st)
113 }
114 kind, msg := inviteFlashAfter(t, owner, fx.ts.URL)
115 if kind != c.wantKind || !strings.Contains(msg, c.wantInMsg) {
116 t.Fatalf("flash = %q %q, want %q containing %q", kind, msg, c.wantKind, c.wantInMsg)
117 }
118 pending, _ := fx.st.PendingInvites(OpsAccountID)
119 if len(pending) != 1 {
120 t.Fatalf("invite row missing: %+v", pending)
121 }
122 link := "/invite/" + pending[0].Token
123 if got := strings.Contains(strings.Join(fx.fk.fedBodies(), "\n"), link); got != c.wantFed {
124 t.Errorf("delivered by keymail = %v, want %v", got, c.wantFed)
125 }
126 var sent []string
127 if c.mail != nil {
128 sent = c.mail.sentCopy()
129 }
130 mailed := strings.Contains(strings.Join(sent, "\n"), c.to+"|") &&
131 strings.Contains(strings.Join(sent, "\n"), link)
132 if mailed != c.wantMail {
133 t.Errorf("emailed = %v, want %v", mailed, c.wantMail)
134 }
135 if !c.wantMail && len(sent) != 0 {
136 t.Errorf("email sent when it should not have been: %v", sent)
137 }
138 })
139 }
140}
141
142// The §1 second fault: an owner with no stored grant used to get a false
143// success. Delivery no longer reads the inviter's grant at all.
144func TestInviteDeliversWithoutTheInvitersGrant(t *testing.T) {
145 fx := newConsoleFixture(t)
146 owner := newBrowser(t)
147 signUp(t, fx, owner, "code-owner")
148 if err := fx.st.SetGrantToken("paul@keymail.dev", "", time.Now()); err != nil {
149 t.Fatal(err)
150 }
151 csrf := sessionToken(t, fx, owner)
152 postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {"sam@keymail.dev"}})
153 if len(fx.fk.fedBodies()) != 1 {
154 t.Fatalf("fedBodies = %v, want one delivery", fx.fk.fedBodies())
155 }
156}
157
158// Review Focus 3: a Keymail that never answers must not swallow the email leg.
159func TestInviteFallsBackWhenKeymailHangs(t *testing.T) {
160 fx := newConsoleFixture(t)
161 mailer := &fakeConsoleMail{ready: true}
162 fx.srv.consoleMail = mailer
163 fx.srv.inviteTimeout = 300 * time.Millisecond
164 fx.srv.inviteKeymailShare = 150 * time.Millisecond
165 fx.srv.sendFederated = func(ctx context.Context, _, _, _ string) (string, error) {
166 <-ctx.Done()
167 return "", fmt.Errorf("%w: %w", ErrKeymailUnreachable, ctx.Err())
168 }
169 owner := newBrowser(t)
170 signUp(t, fx, owner, "code-owner")
171 csrf := sessionToken(t, fx, owner)
172 start := time.Now()
173 postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {"sam@keymail.dev"}})
174 if d := time.Since(start); d > 2*time.Second {
175 t.Fatalf("invite took %v", d)
176 }
177 if sent := mailer.sentCopy(); len(sent) != 1 {
178 t.Fatalf("email leg not tried after a hung keymail: %v", sent)
179 }
180 if kind, msg := inviteFlashAfter(t, owner, fx.ts.URL); kind != "ok" || !strings.Contains(msg, "emailed") {
181 t.Fatalf("flash = %q %q, want ok containing %q", kind, msg, "emailed")
182 }
183}
184
185// Pins the approved copy (Paul's exact strings) so a future edit is visible
186// in a diff rather than discovered by a user.
187func TestInviteCopyHelpers(t *testing.T) {
188 if got, want := inviteFlashKeymail("sam@keymail.dev"), "Invited sam@keymail.dev. The link is in their Keymail inbox."; got != want {
189 t.Errorf("inviteFlashKeymail = %q, want %q", got, want)
190 }
191 if got, want := inviteFlashEmail("sam@keymail.dev"), "Invited sam@keymail.dev. We emailed them the link."; got != want {
192 t.Errorf("inviteFlashEmail = %q, want %q", got, want)
193 }
194 if got, want := inviteFlashFailed("sam@keymail.dev", "https://console.example/invite/tok"),
195 "Invited sam@keymail.dev, but we couldn't deliver the link. Send it to them yourself: https://console.example/invite/tok"; got != want {
196 t.Errorf("inviteFlashFailed = %q, want %q", got, want)
197 }
198 // DRAFTS under copy review (final-review F2): swap the approved text in
199 // the helpers and here.
200 if got, want := inviteFlashTooManyPending(), "This account already has 20 invites waiting. Revoke some before you send more."; got != want {
201 t.Errorf("inviteFlashTooManyPending = %q, want %q", got, want)
202 }
203 if got, want := inviteFlashTooManyPerHour(), "This account has sent 20 invites in the last hour. Try again later."; got != want {
204 t.Errorf("inviteFlashTooManyPerHour = %q, want %q", got, want)
205 }
206 wantSubject := "You've been invited to an account on the CARLOS platform"
207 wantBody := copyText("email.invite.body", "inviter", "paul@keymail.dev", "account", "ops",
208 "console", "the CARLOS platform", "to", "sam@keymail.dev", "link", "https://console.example/invite/tok")
209 gotSubject, gotBody := inviteMessage("paul@keymail.dev", "ops", "sam@keymail.dev", "https://console.example/invite/tok", "the CARLOS platform")
210 if gotSubject != wantSubject {
211 t.Errorf("inviteMessage subject = %q, want %q", gotSubject, wantSubject)
212 }
213 if gotBody != wantBody {
214 t.Errorf("inviteMessage body = %q, want %q", gotBody, wantBody)
215 }
216 // The approved text itself, so a copy-file edit shows in this diff too.
217 approved := "paul@keymail.dev invited you to the account \"ops\" on the CARLOS platform.\n\n" +
218 "Open this link to accept. You'll sign in as sam@keymail.dev first if you aren't already:\n\n" +
219 "https://console.example/invite/tok\n\n" +
220 "It works once and expires in 7 days. If you weren't expecting this, ignore it. Nothing happens without the link."
221 if gotBody != approved {
222 t.Errorf("inviteMessage body = %q, want the approved text %q", gotBody, approved)
223 }
224 // A named console shows in both the subject and the body.
225 subj, body := inviteMessage("paul@keymail.dev", "ops", "sam@keymail.dev", "https://console.example/invite/tok", "Acme Cloud")
226 if subj != "You've been invited to an account on Acme Cloud" || !strings.Contains(body, "\" on Acme Cloud.\n") {
227 t.Errorf("named console: subject %q body %q", subj, body)
228 }
229}
230
231// inviteAttempts swaps both delivery legs for counters, so a refusal can
232// prove nothing was even tried.
233func inviteAttempts(fx *consoleFixture) (fed *int, mailer *fakeConsoleMail) {
234 n := 0
235 fx.srv.sendFederated = func(context.Context, string, string, string) (string, error) {
236 n++
237 return "id", nil
238 }
239 mailer = &fakeConsoleMail{ready: true}
240 fx.srv.consoleMail = mailer
241 return &n, mailer
242}
243
244// Final review Important #2: an account cannot pile up more than
245// maxPendingInvites unaccepted, unexpired invites.
246func TestInviteRefusedAtThePendingCap(t *testing.T) {
247 fx := newConsoleFixture(t)
248 owner := newBrowser(t)
249 signUp(t, fx, owner, "code-owner")
250 for i := 0; i < maxPendingInvites; i++ {
251 if _, err := fx.st.CreateInvite(OpsAccountID, fmt.Sprintf("p%d@keymail.dev", i), "paul@keymail.dev", time.Now()); err != nil {
252 t.Fatal(err)
253 }
254 }
255 fed, mailer := inviteAttempts(fx)
256 csrf := sessionToken(t, fx, owner)
257 postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {"sam@keymail.dev"}})
258 if kind, msg := inviteFlashAfter(t, owner, fx.ts.URL); kind != "err" || msg != inviteFlashTooManyPending() {
259 t.Fatalf("flash = %q %q, want err %q", kind, msg, inviteFlashTooManyPending())
260 }
261 if pending, _ := fx.st.PendingInvites(OpsAccountID); len(pending) != maxPendingInvites {
262 t.Fatalf("pending = %d, want %d (no new row)", len(pending), maxPendingInvites)
263 }
264 if *fed != 0 || len(mailer.sentCopy()) != 0 {
265 t.Fatalf("delivery attempted: keymail %d, email %v", *fed, mailer.sentCopy())
266 }
267}
268
269// An expired invite no longer counts against the cap.
270func TestInvitePendingCapIgnoresExpired(t *testing.T) {
271 fx := newConsoleFixture(t)
272 owner := newBrowser(t)
273 signUp(t, fx, owner, "code-owner")
274 old := time.Now().Add(-InviteTTL - time.Hour)
275 for i := 0; i < maxPendingInvites; i++ {
276 if _, err := fx.st.CreateInvite(OpsAccountID, fmt.Sprintf("p%d@keymail.dev", i), "paul@keymail.dev", old); err != nil {
277 t.Fatal(err)
278 }
279 }
280 inviteAttempts(fx)
281 csrf := sessionToken(t, fx, owner)
282 postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {"sam@keymail.dev"}})
283 if kind, msg := inviteFlashAfter(t, owner, fx.ts.URL); kind != "ok" {
284 t.Fatalf("flash = %q %q, want ok", kind, msg)
285 }
286}
287
288// At most maxInvitesPerHour invite rows per account per hour, whatever
289// became of them; the window slides.
290func TestInviteHourlyLimitSlides(t *testing.T) {
291 fx := newConsoleFixture(t)
292 owner := newBrowser(t)
293 signUp(t, fx, owner, "code-owner")
294 now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
295 fx.srv.inviteRate.now = func() time.Time { return now }
296 fed, mailer := inviteAttempts(fx)
297 csrf := sessionToken(t, fx, owner)
298 invite := func(to string) (string, string) {
299 postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {to}})
300 return inviteFlashAfter(t, owner, fx.ts.URL)
301 }
302 for i := 0; i < maxInvitesPerHour; i++ {
303 if kind, msg := invite(fmt.Sprintf("p%d@keymail.dev", i)); kind != "ok" {
304 t.Fatalf("invite %d: flash = %q %q, want ok", i, kind, msg)
305 }
306 // Revoked straight away, so the pending cap is never what refuses.
307 pending, _ := fx.st.PendingInvites(OpsAccountID)
308 for _, p := range pending {
309 if err := fx.st.RevokeInvite(p.Token); err != nil {
310 t.Fatal(err)
311 }
312 }
313 now = now.Add(time.Minute)
314 }
315 *fed = 0
316 if kind, msg := invite("late@keymail.dev"); kind != "err" || msg != inviteFlashTooManyPerHour() {
317 t.Fatalf("flash = %q %q, want err %q", kind, msg, inviteFlashTooManyPerHour())
318 }
319 if pending, _ := fx.st.PendingInvites(OpsAccountID); len(pending) != 0 {
320 t.Fatalf("a refused invite created a row: %+v", pending)
321 }
322 if *fed != 0 || len(mailer.sentCopy()) != 0 {
323 t.Fatalf("delivery attempted: keymail %d, email %v", *fed, mailer.sentCopy())
324 }
325 // The first invite went out at 12:00; an hour and a second later it has
326 // left the window and one more is allowed.
327 now = time.Date(2026, 10, 2, 13, 0, 1, 0, time.UTC)
328 if kind, msg := invite("later@keymail.dev"); kind != "ok" {
329 t.Fatalf("after the window slid: flash = %q %q, want ok", kind, msg)
330 }
331}
332
333// The window counts rows created, not deliveries: an invite nobody could
334// deliver still used a slot.
335func TestInviteWindowCountsUndeliveredInvites(t *testing.T) {
336 var w inviteWindow
337 now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
338 w.now = func() time.Time { return now }
339 for i := 0; i < maxInvitesPerHour; i++ {
340 if !w.take(7) {
341 t.Fatalf("take %d refused", i)
342 }
343 }
344 if w.take(7) {
345 t.Fatal("take past the limit allowed")
346 }
347 if !w.take(8) {
348 t.Fatal("another account shares the window")
349 }
350 now = now.Add(time.Hour + time.Nanosecond)
351 if !w.take(7) {
352 t.Fatal("window did not slide")
353 }
354}
355
356// ValidAddress lets through strings an SES To header reads as more than one
357// recipient, or as a display name. The invite takes one bare address.
358func TestInviteRefusesAnythingButOneBareAddress(t *testing.T) {
359 for _, to := range []string{"a@x.com,b@y.com", "x<v@y.com>"} {
360 t.Run(to, func(t *testing.T) {
361 fx := newConsoleFixture(t)
362 owner := newBrowser(t)
363 signUp(t, fx, owner, "code-owner")
364 fed, mailer := inviteAttempts(fx)
365 csrf := sessionToken(t, fx, owner)
366 postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {to}})
367 if kind, msg := inviteFlashAfter(t, owner, fx.ts.URL); kind != "err" || msg != copyText("team.bad_address") {
368 t.Fatalf("flash = %q %q, want the address refusal", kind, msg)
369 }
370 if pending, _ := fx.st.PendingInvites(OpsAccountID); len(pending) != 0 {
371 t.Fatalf("invite row created for %q: %+v", to, pending)
372 }
373 if *fed != 0 || len(mailer.sentCopy()) != 0 {
374 t.Fatalf("delivery attempted for %q", to)
375 }
376 })
377 }
378}
379
380// No Keymail mailbox is the ordinary road to email, not a failure: it logs
381// as such.
382func TestInviteLogsNoMailboxQuietly(t *testing.T) {
383 fx := newConsoleFixture(t)
384 fx.srv.consoleMail = &fakeConsoleMail{ready: true}
385 owner := newBrowser(t)
386 signUp(t, fx, owner, "code-owner")
387 csrf := sessionToken(t, fx, owner)
388 var buf bytes.Buffer
389 log.SetOutput(&buf)
390 t.Cleanup(func() { log.SetOutput(os.Stderr) })
391 postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {"sam@gmail.com"}})
392 log.SetOutput(os.Stderr)
393 got := buf.String()
394 if !strings.Contains(got, "console: delivery to sam@gmail.com: no keymail mailbox, trying email") {
395 t.Fatalf("log = %q, want the quiet no-mailbox line", got)
396 }
397 if strings.Contains(got, "by keymail:") {
398 t.Fatalf("log = %q, still logs a no-mailbox lookup as a keymail failure", got)
399 }
400}
401
402// The whole invite path for someone with only an email address: the owner
403// invites, the link arrives by email, the signed-out invite page asks them to
404// sign in, they sign in with a code, land back on the invite, and accept.
405func TestInviteEmailInviteeAcceptsEndToEnd(t *testing.T) {
406 fx, m := newEmailFixture(t, true)
407 owner := newBrowser(t)
408 signUp(t, fx, owner, "code-owner")
409 lookupAnswers(fx, "new@example.com", http.StatusNotFound)
410 csrf := sessionToken(t, fx, owner)
411 if st, _, _ := postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {"new@example.com"}}); st != http.StatusSeeOther {
412 t.Fatalf("invite = %d", st)
413 }
414 if kind, msg := inviteFlashAfter(t, owner, fx.ts.URL); kind != "ok" || !strings.Contains(msg, "emailed") {
415 t.Fatalf("flash = %q %q, want ok containing %q", kind, msg, "emailed")
416 }
417 pending, _ := fx.st.PendingInvites(OpsAccountID)
418 if len(pending) != 1 {
419 t.Fatalf("pending invites = %+v", pending)
420 }
421 next := "/invite/" + pending[0].Token
422 sent := m.sentCopy()
423 if len(sent) != 1 {
424 t.Fatalf("console mailer got %d messages, want the invitation: %q", len(sent), sent)
425 }
426 to, body, _ := strings.Cut(sent[0], "|")
427 if to != "new@example.com" || !strings.Contains(body, fx.ts.URL+next) || !strings.Contains(body, "You'll sign in as new@example.com first") {
428 t.Fatalf("invitation email to %q:\n%s", to, body)
429 }
430
431 // The link, signed out: the home page with the banner and the prefill.
432 guest := newBrowser(t)
433 status, _, page := get(t, guest, fx.ts.URL+next)
434 for _, want := range []string{esc(copyText("home.invite_prompt", "address", "new@example.com")), `value="new@example.com"`} {
435 if status != http.StatusOK || !strings.Contains(page, want) {
436 t.Fatalf("signed-out invite page = %d, missing %q:\n%s", status, want, page)
437 }
438 }
439 // The form carries the invite back as its hidden next; submit what the
440 // page rendered, not what the test knows.
441 nm := regexp.MustCompile(`name="next" value="([^"]*)"`).FindStringSubmatch(page)
442 if nm == nil {
443 t.Fatalf("signed-out invite page has no hidden next:\n%s", page)
444 }
445 formNext := html.UnescapeString(nm[1])
446 if formNext != next {
447 t.Fatalf("the form's next = %q, want %q", formNext, next)
448 }
449 // Sign in by code; the invite is where it lands.
450 code, _ := askSignin(t, fx, m, guest, "new@example.com", formNext)
451 status, loc, _ := postCode(t, fx, guest, code)
452 if status != http.StatusSeeOther || loc != next {
453 t.Fatalf("code sign-in = %d to %q, want 303 to %q", status, loc, next)
454 }
455 raw := emailSession(t, fx, guest, "new@example.com")
456 status, _, page = get(t, guest, fx.ts.URL+next)
457 if status != http.StatusOK || !strings.Contains(page, "Join ") {
458 t.Fatalf("invite page after sign-in = %d:\n%s", status, page)
459 }
460 if status, _, _ := postForm(t, guest, fx.ts.URL+next, url.Values{"csrf": {raw}}); status != http.StatusSeeOther {
461 t.Fatalf("accept = %d, want 303", status)
462 }
463 if role, err := fx.st.Role(OpsAccountID, "new@example.com"); err != nil || role != RoleMember {
464 t.Fatalf("role = %q, %v, want member", role, err)
465 }
466}
467
468func TestTeamPageInviteCopy(t *testing.T) {
469 fx := newConsoleFixture(t)
470 owner := newBrowser(t)
471 signUp(t, fx, owner, "code-owner")
472 status, _, body := get(t, owner, fx.u("/team"))
473 if status != http.StatusOK {
474 t.Fatalf("team = %d", status)
475 }
476 for _, want := range []string{`placeholder="` + esc(copyText("team.invite_placeholder")) + `"`, esc(copyText("team.invite_note"))} {
477 if !strings.Contains(body, want) {
478 t.Fatalf("team page missing %q:\n%s", want, body)
479 }
480 }
481 if strings.Contains(body, "the platform never emails anyone") {
482 t.Fatal("team page still says the platform never emails anyone")
483 }
484}
485