| 1 | package console |
| 2 | |
| 3 | import ( |
| 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 | |
| 19 | type 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 | |
| 28 | func (f *fakeConsoleMail) Ready(context.Context) bool { return f.ready } |
| 29 | |
| 30 | func (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. |
| 41 | func (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. |
| 48 | func 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 | |
| 61 | func 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. |
| 144 | func 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. |
| 159 | func 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. |
| 187 | func 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. |
| 233 | func 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. |
| 246 | func 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. |
| 270 | func 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. |
| 290 | func 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. |
| 335 | func 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. |
| 358 | func 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. |
| 382 | func 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. |
| 405 | func 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 | |
| 468 | func 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 | |