package console import ( "bytes" "context" "fmt" "html" "log" "net/http" "net/url" "os" "regexp" "strings" "sync" "testing" "time" ) type fakeConsoleMail struct { mu sync.Mutex err error sent []string // to|body // ready is what Ready reports. Construct with ready: true wherever the // test expects the console to be able to send. ready bool } func (f *fakeConsoleMail) Ready(context.Context) bool { return f.ready } func (f *fakeConsoleMail) Send(_ context.Context, to, _, body string) error { f.mu.Lock() defer f.mu.Unlock() if f.err != nil { return f.err } f.sent = append(f.sent, to+"|"+body) return nil } // sentCopy reads sent under the lock: Send runs on the server's goroutine. func (f *fakeConsoleMail) sentCopy() []string { f.mu.Lock() defer f.mu.Unlock() return append([]string(nil), f.sent...) } // inviteFlashAfter reads the flash cookie the last redirect set. func inviteFlashAfter(t *testing.T, c *http.Client, base string) (kind, msg string) { t.Helper() u, _ := url.Parse(base) for _, ck := range c.Jar.Cookies(u) { if ck.Name == flashCookie { v, _ := url.QueryUnescape(ck.Value) kind, msg, _ = strings.Cut(v, "|") return kind, msg } } return "", "" } func TestInviteDelivery(t *testing.T) { cases := []struct { name string to string keymail func(fx *consoleFixture) mail *fakeConsoleMail // nil: no email fallback configured wantKind string wantInMsg string // a fragment that identifies the outcome wantFed bool wantMail bool }{ {name: "keymail delivers", to: "sam@keymail.dev", mail: &fakeConsoleMail{ready: true}, wantKind: "ok", wantInMsg: "Keymail", wantFed: true}, {name: "no mailbox, email delivers", to: "sam@gmail.com", mail: &fakeConsoleMail{ready: true}, wantKind: "ok", wantInMsg: "emailed", wantMail: true}, // The address is a Keymail mailbox (the lookup said so); a price above // the cap is not a reason to email it (spec 4.3). {name: "price too high is final", to: "sam@keymail.dev", mail: &fakeConsoleMail{ready: true}, keymail: func(fx *consoleFixture) { fx.fk.powBits = maxFederationPowBits + 1 }, wantKind: "err", wantInMsg: "/invite/"}, // A Keymail outage for the Keymail host's own address sends nothing. {name: "keymail host lookup 5xx is final", to: "sam@127.0.0.1", mail: &fakeConsoleMail{ready: true}, keymail: func(fx *consoleFixture) { fx.fk.lookupStatus["sam@127.0.0.1"] = 500 }, wantKind: "err", wantInMsg: "/invite/"}, {name: "other domain lookup 5xx falls to email", to: "sam@example.com", mail: &fakeConsoleMail{ready: true}, keymail: func(fx *consoleFixture) { fx.fk.lookupStatus["sam@example.com"] = 500 }, wantKind: "ok", wantInMsg: "emailed", wantMail: true}, // A recipient who blocked the console said no; email to the same // address would go round them. Straight to the hand-off. {name: "blocked is final", to: "sam@keymail.dev", mail: &fakeConsoleMail{ready: true}, keymail: func(fx *consoleFixture) { fx.fk.blocked["console@"+OriginHost(fx.ts.URL)] = true }, wantKind: "err", wantInMsg: "/invite/"}, {name: "both fail", to: "sam@gmail.com", mail: &fakeConsoleMail{ready: true, err: ErrConsoleMailNotReady}, wantKind: "err", wantInMsg: "/invite/"}, {name: "no email configured", to: "sam@gmail.com", wantKind: "err", wantInMsg: "/invite/"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { fx := newConsoleFixture(t) if c.keymail != nil { c.keymail(fx) } if c.mail != nil { fx.srv.consoleMail = c.mail } owner := newBrowser(t) signUp(t, fx, owner, "code-owner") csrf := sessionToken(t, fx, owner) st, _, _ := postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {c.to}}) if st != http.StatusSeeOther { t.Fatalf("invite = %d", st) } kind, msg := inviteFlashAfter(t, owner, fx.ts.URL) if kind != c.wantKind || !strings.Contains(msg, c.wantInMsg) { t.Fatalf("flash = %q %q, want %q containing %q", kind, msg, c.wantKind, c.wantInMsg) } pending, _ := fx.st.PendingInvites(OpsAccountID) if len(pending) != 1 { t.Fatalf("invite row missing: %+v", pending) } link := "/invite/" + pending[0].Token if got := strings.Contains(strings.Join(fx.fk.fedBodies(), "\n"), link); got != c.wantFed { t.Errorf("delivered by keymail = %v, want %v", got, c.wantFed) } var sent []string if c.mail != nil { sent = c.mail.sentCopy() } mailed := strings.Contains(strings.Join(sent, "\n"), c.to+"|") && strings.Contains(strings.Join(sent, "\n"), link) if mailed != c.wantMail { t.Errorf("emailed = %v, want %v", mailed, c.wantMail) } if !c.wantMail && len(sent) != 0 { t.Errorf("email sent when it should not have been: %v", sent) } }) } } // The ยง1 second fault: an owner with no stored grant used to get a false // success. Delivery no longer reads the inviter's grant at all. func TestInviteDeliversWithoutTheInvitersGrant(t *testing.T) { fx := newConsoleFixture(t) owner := newBrowser(t) signUp(t, fx, owner, "code-owner") if err := fx.st.SetGrantToken("paul@keymail.dev", "", time.Now()); err != nil { t.Fatal(err) } csrf := sessionToken(t, fx, owner) postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {"sam@keymail.dev"}}) if len(fx.fk.fedBodies()) != 1 { t.Fatalf("fedBodies = %v, want one delivery", fx.fk.fedBodies()) } } // Review Focus 3: a Keymail that never answers must not swallow the email leg. func TestInviteFallsBackWhenKeymailHangs(t *testing.T) { fx := newConsoleFixture(t) mailer := &fakeConsoleMail{ready: true} fx.srv.consoleMail = mailer fx.srv.inviteTimeout = 300 * time.Millisecond fx.srv.inviteKeymailShare = 150 * time.Millisecond fx.srv.sendFederated = func(ctx context.Context, _, _, _ string) (string, error) { <-ctx.Done() return "", fmt.Errorf("%w: %w", ErrKeymailUnreachable, ctx.Err()) } owner := newBrowser(t) signUp(t, fx, owner, "code-owner") csrf := sessionToken(t, fx, owner) start := time.Now() postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {"sam@keymail.dev"}}) if d := time.Since(start); d > 2*time.Second { t.Fatalf("invite took %v", d) } if sent := mailer.sentCopy(); len(sent) != 1 { t.Fatalf("email leg not tried after a hung keymail: %v", sent) } if kind, msg := inviteFlashAfter(t, owner, fx.ts.URL); kind != "ok" || !strings.Contains(msg, "emailed") { t.Fatalf("flash = %q %q, want ok containing %q", kind, msg, "emailed") } } // Pins the approved copy (Paul's exact strings) so a future edit is visible // in a diff rather than discovered by a user. func TestInviteCopyHelpers(t *testing.T) { if got, want := inviteFlashKeymail("sam@keymail.dev"), "Invited sam@keymail.dev. The link is in their Keymail inbox."; got != want { t.Errorf("inviteFlashKeymail = %q, want %q", got, want) } if got, want := inviteFlashEmail("sam@keymail.dev"), "Invited sam@keymail.dev. We emailed them the link."; got != want { t.Errorf("inviteFlashEmail = %q, want %q", got, want) } if got, want := inviteFlashFailed("sam@keymail.dev", "https://console.example/invite/tok"), "Invited sam@keymail.dev, but we couldn't deliver the link. Send it to them yourself: https://console.example/invite/tok"; got != want { t.Errorf("inviteFlashFailed = %q, want %q", got, want) } // DRAFTS under copy review (final-review F2): swap the approved text in // the helpers and here. if got, want := inviteFlashTooManyPending(), "This account already has 20 invites waiting. Revoke some before you send more."; got != want { t.Errorf("inviteFlashTooManyPending = %q, want %q", got, want) } if got, want := inviteFlashTooManyPerHour(), "This account has sent 20 invites in the last hour. Try again later."; got != want { t.Errorf("inviteFlashTooManyPerHour = %q, want %q", got, want) } wantSubject := "You've been invited to an account on the CARLOS platform" wantBody := copyText("email.invite.body", "inviter", "paul@keymail.dev", "account", "ops", "console", "the CARLOS platform", "to", "sam@keymail.dev", "link", "https://console.example/invite/tok") gotSubject, gotBody := inviteMessage("paul@keymail.dev", "ops", "sam@keymail.dev", "https://console.example/invite/tok", "the CARLOS platform") if gotSubject != wantSubject { t.Errorf("inviteMessage subject = %q, want %q", gotSubject, wantSubject) } if gotBody != wantBody { t.Errorf("inviteMessage body = %q, want %q", gotBody, wantBody) } // The approved text itself, so a copy-file edit shows in this diff too. approved := "paul@keymail.dev invited you to the account \"ops\" on the CARLOS platform.\n\n" + "Open this link to accept. You'll sign in as sam@keymail.dev first if you aren't already:\n\n" + "https://console.example/invite/tok\n\n" + "It works once and expires in 7 days. If you weren't expecting this, ignore it. Nothing happens without the link." if gotBody != approved { t.Errorf("inviteMessage body = %q, want the approved text %q", gotBody, approved) } // A named console shows in both the subject and the body. subj, body := inviteMessage("paul@keymail.dev", "ops", "sam@keymail.dev", "https://console.example/invite/tok", "Acme Cloud") if subj != "You've been invited to an account on Acme Cloud" || !strings.Contains(body, "\" on Acme Cloud.\n") { t.Errorf("named console: subject %q body %q", subj, body) } } // inviteAttempts swaps both delivery legs for counters, so a refusal can // prove nothing was even tried. func inviteAttempts(fx *consoleFixture) (fed *int, mailer *fakeConsoleMail) { n := 0 fx.srv.sendFederated = func(context.Context, string, string, string) (string, error) { n++ return "id", nil } mailer = &fakeConsoleMail{ready: true} fx.srv.consoleMail = mailer return &n, mailer } // Final review Important #2: an account cannot pile up more than // maxPendingInvites unaccepted, unexpired invites. func TestInviteRefusedAtThePendingCap(t *testing.T) { fx := newConsoleFixture(t) owner := newBrowser(t) signUp(t, fx, owner, "code-owner") for i := 0; i < maxPendingInvites; i++ { if _, err := fx.st.CreateInvite(OpsAccountID, fmt.Sprintf("p%d@keymail.dev", i), "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } } fed, mailer := inviteAttempts(fx) csrf := sessionToken(t, fx, owner) postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {"sam@keymail.dev"}}) if kind, msg := inviteFlashAfter(t, owner, fx.ts.URL); kind != "err" || msg != inviteFlashTooManyPending() { t.Fatalf("flash = %q %q, want err %q", kind, msg, inviteFlashTooManyPending()) } if pending, _ := fx.st.PendingInvites(OpsAccountID); len(pending) != maxPendingInvites { t.Fatalf("pending = %d, want %d (no new row)", len(pending), maxPendingInvites) } if *fed != 0 || len(mailer.sentCopy()) != 0 { t.Fatalf("delivery attempted: keymail %d, email %v", *fed, mailer.sentCopy()) } } // An expired invite no longer counts against the cap. func TestInvitePendingCapIgnoresExpired(t *testing.T) { fx := newConsoleFixture(t) owner := newBrowser(t) signUp(t, fx, owner, "code-owner") old := time.Now().Add(-InviteTTL - time.Hour) for i := 0; i < maxPendingInvites; i++ { if _, err := fx.st.CreateInvite(OpsAccountID, fmt.Sprintf("p%d@keymail.dev", i), "paul@keymail.dev", old); err != nil { t.Fatal(err) } } inviteAttempts(fx) csrf := sessionToken(t, fx, owner) postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {"sam@keymail.dev"}}) if kind, msg := inviteFlashAfter(t, owner, fx.ts.URL); kind != "ok" { t.Fatalf("flash = %q %q, want ok", kind, msg) } } // At most maxInvitesPerHour invite rows per account per hour, whatever // became of them; the window slides. func TestInviteHourlyLimitSlides(t *testing.T) { fx := newConsoleFixture(t) owner := newBrowser(t) signUp(t, fx, owner, "code-owner") now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) fx.srv.inviteRate.now = func() time.Time { return now } fed, mailer := inviteAttempts(fx) csrf := sessionToken(t, fx, owner) invite := func(to string) (string, string) { postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {to}}) return inviteFlashAfter(t, owner, fx.ts.URL) } for i := 0; i < maxInvitesPerHour; i++ { if kind, msg := invite(fmt.Sprintf("p%d@keymail.dev", i)); kind != "ok" { t.Fatalf("invite %d: flash = %q %q, want ok", i, kind, msg) } // Revoked straight away, so the pending cap is never what refuses. pending, _ := fx.st.PendingInvites(OpsAccountID) for _, p := range pending { if err := fx.st.RevokeInvite(p.Token); err != nil { t.Fatal(err) } } now = now.Add(time.Minute) } *fed = 0 if kind, msg := invite("late@keymail.dev"); kind != "err" || msg != inviteFlashTooManyPerHour() { t.Fatalf("flash = %q %q, want err %q", kind, msg, inviteFlashTooManyPerHour()) } if pending, _ := fx.st.PendingInvites(OpsAccountID); len(pending) != 0 { t.Fatalf("a refused invite created a row: %+v", pending) } if *fed != 0 || len(mailer.sentCopy()) != 0 { t.Fatalf("delivery attempted: keymail %d, email %v", *fed, mailer.sentCopy()) } // The first invite went out at 12:00; an hour and a second later it has // left the window and one more is allowed. now = time.Date(2026, 10, 2, 13, 0, 1, 0, time.UTC) if kind, msg := invite("later@keymail.dev"); kind != "ok" { t.Fatalf("after the window slid: flash = %q %q, want ok", kind, msg) } } // The window counts rows created, not deliveries: an invite nobody could // deliver still used a slot. func TestInviteWindowCountsUndeliveredInvites(t *testing.T) { var w inviteWindow now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) w.now = func() time.Time { return now } for i := 0; i < maxInvitesPerHour; i++ { if !w.take(7) { t.Fatalf("take %d refused", i) } } if w.take(7) { t.Fatal("take past the limit allowed") } if !w.take(8) { t.Fatal("another account shares the window") } now = now.Add(time.Hour + time.Nanosecond) if !w.take(7) { t.Fatal("window did not slide") } } // ValidAddress lets through strings an SES To header reads as more than one // recipient, or as a display name. The invite takes one bare address. func TestInviteRefusesAnythingButOneBareAddress(t *testing.T) { for _, to := range []string{"a@x.com,b@y.com", "x"} { t.Run(to, func(t *testing.T) { fx := newConsoleFixture(t) owner := newBrowser(t) signUp(t, fx, owner, "code-owner") fed, mailer := inviteAttempts(fx) csrf := sessionToken(t, fx, owner) postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {to}}) if kind, msg := inviteFlashAfter(t, owner, fx.ts.URL); kind != "err" || msg != copyText("team.bad_address") { t.Fatalf("flash = %q %q, want the address refusal", kind, msg) } if pending, _ := fx.st.PendingInvites(OpsAccountID); len(pending) != 0 { t.Fatalf("invite row created for %q: %+v", to, pending) } if *fed != 0 || len(mailer.sentCopy()) != 0 { t.Fatalf("delivery attempted for %q", to) } }) } } // No Keymail mailbox is the ordinary road to email, not a failure: it logs // as such. func TestInviteLogsNoMailboxQuietly(t *testing.T) { fx := newConsoleFixture(t) fx.srv.consoleMail = &fakeConsoleMail{ready: true} owner := newBrowser(t) signUp(t, fx, owner, "code-owner") csrf := sessionToken(t, fx, owner) var buf bytes.Buffer log.SetOutput(&buf) t.Cleanup(func() { log.SetOutput(os.Stderr) }) postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {"sam@gmail.com"}}) log.SetOutput(os.Stderr) got := buf.String() if !strings.Contains(got, "console: delivery to sam@gmail.com: no keymail mailbox, trying email") { t.Fatalf("log = %q, want the quiet no-mailbox line", got) } if strings.Contains(got, "by keymail:") { t.Fatalf("log = %q, still logs a no-mailbox lookup as a keymail failure", got) } } // The whole invite path for someone with only an email address: the owner // invites, the link arrives by email, the signed-out invite page asks them to // sign in, they sign in with a code, land back on the invite, and accept. func TestInviteEmailInviteeAcceptsEndToEnd(t *testing.T) { fx, m := newEmailFixture(t, true) owner := newBrowser(t) signUp(t, fx, owner, "code-owner") lookupAnswers(fx, "new@example.com", http.StatusNotFound) csrf := sessionToken(t, fx, owner) if st, _, _ := postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {"new@example.com"}}); st != http.StatusSeeOther { t.Fatalf("invite = %d", st) } if kind, msg := inviteFlashAfter(t, owner, fx.ts.URL); kind != "ok" || !strings.Contains(msg, "emailed") { t.Fatalf("flash = %q %q, want ok containing %q", kind, msg, "emailed") } pending, _ := fx.st.PendingInvites(OpsAccountID) if len(pending) != 1 { t.Fatalf("pending invites = %+v", pending) } next := "/invite/" + pending[0].Token sent := m.sentCopy() if len(sent) != 1 { t.Fatalf("console mailer got %d messages, want the invitation: %q", len(sent), sent) } to, body, _ := strings.Cut(sent[0], "|") if to != "new@example.com" || !strings.Contains(body, fx.ts.URL+next) || !strings.Contains(body, "You'll sign in as new@example.com first") { t.Fatalf("invitation email to %q:\n%s", to, body) } // The link, signed out: the home page with the banner and the prefill. guest := newBrowser(t) status, _, page := get(t, guest, fx.ts.URL+next) for _, want := range []string{esc(copyText("home.invite_prompt", "address", "new@example.com")), `value="new@example.com"`} { if status != http.StatusOK || !strings.Contains(page, want) { t.Fatalf("signed-out invite page = %d, missing %q:\n%s", status, want, page) } } // The form carries the invite back as its hidden next; submit what the // page rendered, not what the test knows. nm := regexp.MustCompile(`name="next" value="([^"]*)"`).FindStringSubmatch(page) if nm == nil { t.Fatalf("signed-out invite page has no hidden next:\n%s", page) } formNext := html.UnescapeString(nm[1]) if formNext != next { t.Fatalf("the form's next = %q, want %q", formNext, next) } // Sign in by code; the invite is where it lands. code, _ := askSignin(t, fx, m, guest, "new@example.com", formNext) status, loc, _ := postCode(t, fx, guest, code) if status != http.StatusSeeOther || loc != next { t.Fatalf("code sign-in = %d to %q, want 303 to %q", status, loc, next) } raw := emailSession(t, fx, guest, "new@example.com") status, _, page = get(t, guest, fx.ts.URL+next) if status != http.StatusOK || !strings.Contains(page, "Join ") { t.Fatalf("invite page after sign-in = %d:\n%s", status, page) } if status, _, _ := postForm(t, guest, fx.ts.URL+next, url.Values{"csrf": {raw}}); status != http.StatusSeeOther { t.Fatalf("accept = %d, want 303", status) } if role, err := fx.st.Role(OpsAccountID, "new@example.com"); err != nil || role != RoleMember { t.Fatalf("role = %q, %v, want member", role, err) } } func TestTeamPageInviteCopy(t *testing.T) { fx := newConsoleFixture(t) owner := newBrowser(t) signUp(t, fx, owner, "code-owner") status, _, body := get(t, owner, fx.u("/team")) if status != http.StatusOK { t.Fatalf("team = %d", status) } for _, want := range []string{`placeholder="` + esc(copyText("team.invite_placeholder")) + `"`, esc(copyText("team.invite_note"))} { if !strings.Contains(body, want) { t.Fatalf("team page missing %q:\n%s", want, body) } } if strings.Contains(body, "the platform never emails anyone") { t.Fatal("team page still says the platform never emails anyone") } }