package memoriatest import ( "io" "net/http" "net/http/httptest" "strings" "testing" ) // B5 (polish audit, 2026-09-23): the sign-in page said nothing after any // sign-in attempt. It switched on ?outcome=, a word nothing writes; // rastrillo's auth handlers and memoria's own beginGuard redirect with // ?sent=1 and ?err=rate|address|expired|keymail|1. So "Send me a link" // came back to the same blank form, and a rate limit, an unparseable // address and a dead link were all silent. // // Every case here drives the REAL flow and follows the Location it was // given — never a hand-built query. A hand-built query is written from // the same model as the switch that reads it, which is exactly how the // old ?outcome= test-free code agreed with itself for months. Each case // also asserts the Location's query, so if rastrillo renames a code the // failure names the new spelling rather than a missing sentence. // // Verified red 2026-09-23 against the old localSignin (switch on // "outcome"): every subtest failed with the blank form. // followToSignin performs the redirect a browser would and returns the // page body. It refuses anything that is not a redirect to /signin, so a // changed status cannot pass as "the page rendered without the flash". func followToSignin(t *testing.T, h http.Handler, rr *httptest.ResponseRecorder, wantQuery string) string { t.Helper() if rr.Code != http.StatusSeeOther && rr.Code != http.StatusFound { t.Fatalf("status = %d, want a redirect to the sign-in page; body %q", rr.Code, rr.Body.String()) } loc := rr.Header().Get("Location") if !strings.HasPrefix(loc, "/signin?") { t.Fatalf("Location = %q, want /signin?…", loc) } if got := strings.TrimPrefix(loc, "/signin?"); got != wantQuery { t.Fatalf("Location query = %q, want %q — rastrillo's vocabulary moved; "+ "signinOutcome must read the new word", got, wantQuery) } req := httptest.NewRequest(http.MethodGet, loc, nil) page := httptest.NewRecorder() h.ServeHTTP(page, req) if page.Code != http.StatusOK { t.Fatalf("GET %s = %d", loc, page.Code) } b, _ := io.ReadAll(page.Body) return string(b) } func assertFlash(t *testing.T, body, tone, sentence string) { t.Helper() if !strings.Contains(body, `rst-tone="`+tone+`"`) { t.Errorf("no %s-toned callout on the page the redirect landed on", tone) } if !strings.Contains(body, sentence) { t.Errorf("page does not say %q", sentence) } } func TestSigninPageSaysWhatHappened(t *testing.T) { t.Run("a sent link says check your email", func(t *testing.T) { // Both writers of ?sent=1: rastrillo after mailing an owner's // link, and beginGuard answering a stranger with the owner's // answer by design. // // Since the sign-in door (2026-09-29) a sent link is the door's // own check-your-email notice (estilo-signin-sent), which REPLACES // the form rather than toning a flash above it — so the state // asserted is the notice, with no form under it. var bodies []string for _, address := range []string{dialTestOwner, "stranger@attacker.example"} { h, _ := signinApp(t) rr := beginSignin(t, h, address) body := followToSignin(t, h, rr, "sent=1") if !strings.Contains(body, `

If that address can sign in here, we’ve sent it a link. It works once and expires in 15 minutes.

`) { t.Errorf("the sent page is not the door's check-your-email notice:\n%s", body) } if !strings.Contains(body, "

Check your email

") { t.Error("the sent notice does not say check your email") } if !strings.Contains(body, "Check your email · Memoria") { t.Error("the sent page's tab title is not \"Check your email · Memoria\"") } if !strings.Contains(body, `Use a different address`) { t.Errorf("the sent notice does not link back to the form as \"Use a different address\":\n%s", body) } if strings.Contains(body, `action="/auth/begin"`) { t.Error("the sign-in form is still on the page after a link was sent") } bodies = append(bodies, body) } // Anti-oracle: an owner and a stranger land on the same page, // byte for byte. if bodies[0] != bodies[1] { t.Error("the sent page differs between an owner and a stranger; the page is an oracle for who the owners are") } }) t.Run("an address rastrillo cannot parse says so", func(t *testing.T) { // Admitted by the owners list, refused by signin's parser: a // dotless domain. Only an owner reaches rastrillo at all. const owner = "paul@localhost" h, _ := signinAppWithOwners(t, []string{owner}) rr := beginSignin(t, h, owner) body := followToSignin(t, h, rr, "err=address") // In the field's own error slot, not a banner (spec §9.2). if !strings.Contains(body, "That doesn’t look like an email address.") || strings.Contains(body, `rst-tone="negative"`) { t.Errorf("the address error is not in the field's slot:\n%s", body) } }) t.Run("a rate-limited address says wait", func(t *testing.T) { // signin's per-address budget, spent on an owner whose links // are being sent (the stub mailer takes them). h, _ := signinApp(t) var rr *httptest.ResponseRecorder for i := 0; i < 20; i++ { rr = beginSignin(t, h, dialTestOwner) if strings.Contains(rr.Header().Get("Location"), "err=rate") { break } } body := followToSignin(t, h, rr, "err=rate") assertFlash(t, body, "negative", "Too many attempts. Wait a few minutes, then try again.") }) t.Run("a dead magic link says it expired", func(t *testing.T) { // The GET is the confirm door and spends nothing, so a dead // token is only found dead at the POST its button makes. h, _ := signinApp(t) rr := newClient(t, h).openLink("/auth/verify?token=not-a-real-token") body := followToSignin(t, h, rr, "err=expired") assertFlash(t, body, "negative", "expired or was already used") }) t.Run("a keymail return with no pending cookie says it expired", func(t *testing.T) { h, _ := signinApp(t) req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=x&state=y", nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) body := followToSignin(t, h, rr, "err=expired") assertFlash(t, body, "negative", "expired or was already used") }) t.Run("a plain visit says nothing", func(t *testing.T) { h, _ := signinApp(t) rr := httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/signin", nil)) if strings.Contains(rr.Body.String(), "rst-callout") { t.Error("a sign-in page nobody submitted carries a callout") } }) }