| 1 | package callstest |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "log/slog" |
| 9 | "net/http" |
| 10 | "net/url" |
| 11 | "path/filepath" |
| 12 | "regexp" |
| 13 | "strings" |
| 14 | "testing" |
| 15 | "time" |
| 16 | |
| 17 | "amadan.net/rastrillo/rastrillo/db" |
| 18 | |
| 19 | "amadan.net/oficina/calls/internal/calls" |
| 20 | ) |
| 21 | |
| 22 | func TestMemberSignsInAndLandsOnTheList(t *testing.T) { |
| 23 | x := newHarness(t, "paul@example.com") |
| 24 | c := x.browser(t) |
| 25 | if dest := c.signIn("paul@example.com"); dest != "/" { |
| 26 | t.Fatalf("member landed on %s, want /", dest) |
| 27 | } |
| 28 | if rec := c.get("/"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Meetings") { |
| 29 | t.Fatalf("GET / after sign-in: %d", rec.Code) |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | func TestOutsiderWithoutALinkGetsNoMailAndTheSameAnswer(t *testing.T) { |
| 34 | x := newHarness(t, "paul@example.com") |
| 35 | c := x.browser(t) |
| 36 | rec := c.post("/signin", url.Values{"address": {"stranger@example.com"}}) |
| 37 | if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" { |
| 38 | t.Fatalf("refusal differs from success: %d %s", rec.Code, rec.Header().Get("Location")) |
| 39 | } |
| 40 | if x.mail.count() != 0 { |
| 41 | t.Fatal("mail was sent to a non-member with no link") |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | func TestSignedOutIsRedirectedToSignin(t *testing.T) { |
| 46 | x := newHarness(t, "paul@example.com") |
| 47 | rec := x.browser(t).get("/") |
| 48 | if rec.Code != http.StatusSeeOther || !strings.HasPrefix(rec.Header().Get("Location"), "/signin") { |
| 49 | t.Fatalf("GET / signed out: %d %s", rec.Code, rec.Header().Get("Location")) |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | func TestNonMemberSessionSeesWelcomeNotTheList(t *testing.T) { |
| 54 | // Development with no allowlist admits every address at sign-in but |
| 55 | // makes nobody a member; that is the shape the guest tests rely on. |
| 56 | x := newHarness(t) |
| 57 | c := x.browser(t) |
| 58 | if dest := c.signIn("anyone@example.com"); dest != "/welcome" { |
| 59 | t.Fatalf("non-member landed on %s, want /welcome", dest) |
| 60 | } |
| 61 | rec := c.get("/") |
| 62 | if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/welcome" { |
| 63 | t.Fatalf("non-member GET /: %d %s, want 303 /welcome", rec.Code, rec.Header().Get("Location")) |
| 64 | } |
| 65 | if rec := c.get("/welcome"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "anyone@example.com") { |
| 66 | t.Fatal("welcome page does not name the address") |
| 67 | } |
| 68 | if rec := c.post("/meetings/start", nil); rec.Code != http.StatusNotFound { |
| 69 | t.Fatalf("guest can access member action: %d", rec.Code) |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | // Codex round 2, C2-4: mailredact_test.go proves the wrapper is safe by |
| 74 | // calling it directly, which would stay green even if cmd/calls/main.go |
| 75 | // stopped wiring it up. This is the production-path check instead: an |
| 76 | // app built with calls.App — the same entry point main.go calls, over a |
| 77 | // mailer that fails and a real *slog.Logger — answers a real |
| 78 | // POST /signin, and nothing that logger captured names the address. |
| 79 | // (Internal 3 / review C1-2's resolve-wraps-everything design is what |
| 80 | // this is actually testing: no call site can opt out of the wrap.) |
| 81 | func TestSigninOverAFailingMailerLeavesNoAddressInTheLog(t *testing.T) { |
| 82 | const address = "paul@example.com" |
| 83 | var buf bytes.Buffer |
| 84 | logger := slog.New(slog.NewTextHandler(&buf, nil)) |
| 85 | d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) |
| 86 | if err != nil { |
| 87 | t.Fatal(err) |
| 88 | } |
| 89 | t.Cleanup(func() { d.Close() }) |
| 90 | |
| 91 | failing := &failingMailer{err: fmt.Errorf("rastrillo/mail: send to %s: %w", address, errFakeSMTPFailure)} |
| 92 | mux, rt, err := calls.App(testCtx(t), d, calls.Config{ |
| 93 | Env: calls.EnvDevelopment, Origin: testOrigin, AllowedAddresses: []string{address}, |
| 94 | Mailer: failing, Logger: logger, |
| 95 | }) |
| 96 | if err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | t.Cleanup(rt.Close) // App's queue outlives its ctx until Close |
| 100 | |
| 101 | rec := post(t, mux, "/signin", url.Values{"address": {address}}) |
| 102 | if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" { |
| 103 | t.Fatalf("POST /signin over a failing mailer: %d %s, want 303 /signin?sent=1 (the same answer as success)", |
| 104 | rec.Code, rec.Header().Get("Location")) |
| 105 | } |
| 106 | |
| 107 | failing.flush() // the send, and its failure, happen after the response |
| 108 | log := buf.String() |
| 109 | if strings.Contains(strings.ToLower(log), strings.ToLower(address)) { |
| 110 | t.Fatalf("the log carries the address after a failing send:\n%s", log) |
| 111 | } |
| 112 | if !strings.Contains(log, "calls: mail delivery failed") { |
| 113 | t.Fatalf("no redacted delivery failure was logged at all — the wrapper may not be wired:\n%s", log) |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | var errFakeSMTPFailure = errors.New("550 mailbox unavailable") |
| 118 | |
| 119 | type failingMailer struct { |
| 120 | err error |
| 121 | waits []func() // the sign-in mail queue in front of it |
| 122 | } |
| 123 | |
| 124 | func (f *failingMailer) Send(ctx context.Context, to, subject, body string) error { return f.err } |
| 125 | |
| 126 | func (f *failingMailer) AttachSigninQueue(wait func()) { f.waits = append(f.waits, wait) } |
| 127 | |
| 128 | func (f *failingMailer) flush() { |
| 129 | for _, w := range f.waits { |
| 130 | w() |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | // Re-review of 9e067e4: a plain member sign-in — no meeting-link cookie, |
| 135 | // so no pending_joins row at all — still leaves rastrillo's own |
| 136 | // auth_links row for the magic link it mailed. nextExpiry (and so |
| 137 | // NextDue) has to see that row, or an app that hibernates right after |
| 138 | // would never be woken to sweep it. |
| 139 | func TestNextDueWakesForAPlainMemberSignin(t *testing.T) { |
| 140 | x := newHarness(t, "paul@example.com") |
| 141 | if rec := x.browser(t).post("/signin", url.Values{"address": {"paul@example.com"}}); rec.Header().Get("Location") != "/signin?sent=1" { |
| 142 | t.Fatalf("sign-in request: %s", rec.Header().Get("Location")) |
| 143 | } |
| 144 | if n := x.authLinks(t); n != 1 { |
| 145 | t.Fatalf("auth_links rows after a plain sign-in: %d, want 1", n) |
| 146 | } |
| 147 | |
| 148 | next := x.ctl.NextDue() |
| 149 | if next.IsZero() { |
| 150 | t.Fatal("NextDue is zero after a plain sign-in left an unclicked magic link outstanding") |
| 151 | } |
| 152 | // signin.Flow.LinkTTL defaults to 15 minutes |
| 153 | // (keymaildev/signin@v0.1.1/flow.go:57,186); the wide window is |
| 154 | // about not coupling this test to that constant, not about |
| 155 | // precision. |
| 156 | if until := next.Sub(x.now); until <= 0 || until > 20*time.Minute { |
| 157 | t.Fatalf("NextDue is %v away, want a positive duration within the magic link's TTL window", until) |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | // The sign-in page is the Oficina door (estilo chrome): one card on the |
| 162 | // suite's sky with Calls' mark and name at its head. The form still posts |
| 163 | // the field "address" to /signin, and a member's POST still answers |
| 164 | // 303 /signin?sent=1 with a link in the mail, the sent page drawn as the |
| 165 | // door too. |
| 166 | func TestSigninPageIsTheOficinaDoor(t *testing.T) { |
| 167 | x := newHarness(t, "paul@example.com") |
| 168 | c := x.browser(t) |
| 169 | rec := c.get("/signin") |
| 170 | if rec.Code != http.StatusOK { |
| 171 | t.Fatalf("GET /signin: %d", rec.Code) |
| 172 | } |
| 173 | body := rec.Body.String() |
| 174 | for _, want := range []string{ |
| 175 | "<div estilo-door", "<span>Calls</span>", `<img estilo-sky-layer="placeholder"`, |
| 176 | `<img estilo-sky-layer="grain"`, `action="/signin"`, `name="address"`, |
| 177 | } { |
| 178 | if !strings.Contains(body, want) { |
| 179 | t.Errorf("sign-in page is missing %q:\n%s", want, body) |
| 180 | } |
| 181 | } |
| 182 | // The sky's layers are served signed-out, from under estilo's prefix. |
| 183 | grain := regexp.MustCompile(`src="(/static/estilo/sky/v1/[0-9a-f]{16}/grain\.svg)"`).FindStringSubmatch(body) |
| 184 | if grain == nil { |
| 185 | t.Fatal("sign-in page links no sky grain layer") |
| 186 | } |
| 187 | if rec := c.get(grain[1]); rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "image/svg+xml" { |
| 188 | t.Fatalf("GET %s: %d %q", grain[1], rec.Code, rec.Header().Get("Content-Type")) |
| 189 | } |
| 190 | |
| 191 | rec = c.post("/signin", url.Values{"address": {"paul@example.com"}}) |
| 192 | if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" { |
| 193 | t.Fatalf("POST /signin: %d %s", rec.Code, rec.Header().Get("Location")) |
| 194 | } |
| 195 | if x.mail.count() != 1 { |
| 196 | t.Fatalf("member sign-in sent %d mails, want 1", x.mail.count()) |
| 197 | } |
| 198 | sent := c.get("/signin?sent=1").Body.String() |
| 199 | if !strings.Contains(sent, "<div estilo-door") || !strings.Contains(sent, "Check your email") || strings.Contains(sent, `name="address"`) { |
| 200 | t.Errorf("sent page is not the door's check-your-email notice:\n%s", sent) |
| 201 | } |
| 202 | |
| 203 | // A malformed address is the field's own error; other outcomes are |
| 204 | // the page's. |
| 205 | if page := c.get("/signin?err=address").Body.String(); !strings.Contains(page, `aria-invalid="true"`) || !strings.Contains(page, "That doesn’t look like an email address.") { |
| 206 | t.Errorf("err=address is not a field error:\n%s", page) |
| 207 | } |
| 208 | if page := c.get("/signin?err=rate").Body.String(); !strings.Contains(page, `rst-tone="negative" role="alert"`) || !strings.Contains(page, "Too many attempts. Wait a few minutes, then try again.") { |
| 209 | t.Errorf("err=rate is not a page error:\n%s", page) |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | // The door's words are estilo's, the same in every Oficina app: the |
| 214 | // submit reads "Sign in" (never "Email me a link", Paul 2026-10-01), one |
| 215 | // help line says what happens, the sent state says the one body and links |
| 216 | // back with "Use a different address", and each tab title is |
| 217 | // "<Page> · Calls". |
| 218 | func TestSigninDoorSpeaksTheSuitesCopy(t *testing.T) { |
| 219 | x := newHarness(t, "paul@example.com") |
| 220 | c := x.browser(t) |
| 221 | form := c.get("/signin").Body.String() |
| 222 | for _, want := range []string{ |
| 223 | "<title>Sign in · Calls</title>", "<h1>Sign in</h1>", ">Email address<", |
| 224 | "We’ll email you a link that signs you in. It works once and expires in 15 minutes.", |
| 225 | `<button rst-btn="primary" type="submit">Sign in</button>`, |
| 226 | } { |
| 227 | if !strings.Contains(form, want) { |
| 228 | t.Errorf("sign-in form is missing %q:\n%s", want, form) |
| 229 | } |
| 230 | } |
| 231 | if strings.Contains(form, "Email me a link") { |
| 232 | t.Errorf("sign-in form still says \"Email me a link\":\n%s", form) |
| 233 | } |
| 234 | sent := c.get("/signin?sent=1").Body.String() |
| 235 | for _, want := range []string{ |
| 236 | "<title>Check your email · Calls</title>", "<h1>Check your email</h1>", |
| 237 | "If that address can sign in here, we’ve sent it a link. It works once and expires in 15 minutes.", |
| 238 | `<a href="/signin">Use a different address</a>`, |
| 239 | } { |
| 240 | if !strings.Contains(sent, want) { |
| 241 | t.Errorf("sent page is missing %q:\n%s", want, sent) |
| 242 | } |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | // The sign-in email's subject names the app: "Sign in to Calls". |
| 247 | func TestSigninEmailSubjectNamesCalls(t *testing.T) { |
| 248 | x := newHarness(t, "paul@example.com") |
| 249 | x.browser(t).post("/signin", url.Values{"address": {"paul@example.com"}}) |
| 250 | if x.mail.count() != 1 { |
| 251 | t.Fatalf("sent %d mails, want 1", x.mail.count()) |
| 252 | } |
| 253 | if got := x.mail.lastSubject(t); got != "Sign in to Calls" { |
| 254 | t.Errorf("sign-in email subject %q, want %q", got, "Sign in to Calls") |
| 255 | } |
| 256 | } |
| 257 | |