package slidestest import ( "net/http" "net/http/httptest" "net/url" "strings" "testing" slides "amadan.net/oficina/slides/internal/slides" ) // mailedLink asks for a link for address and returns the one emailed. func mailedLink(t *testing.T, a *testApp, address string) string { t.Helper() before := len(a.mailer.messages()) if rec := post(t, a, "/signin", url.Values{"address": {address}}); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" { t.Fatalf("POST /signin = %d %q, want 303 /signin?sent=1", rec.Code, rec.Header().Get("Location")) } msgs := a.mailer.messages() if len(msgs) != before+1 { t.Fatalf("POST /signin for %q mailed %d messages, want 1", address, len(msgs)-before) } link := magicLinkPattern.FindString(msgs[len(msgs)-1].Body) if link == "" { t.Fatalf("no magic link in the mailed body:\n%s", msgs[len(msgs)-1].Body) } return link } // sessionCookie reports whether rec set a live session cookie. func sessionCookie(rec *httptest.ResponseRecorder) bool { for _, c := range rec.Result().Cookies() { if strings.Contains(c.Name, "session") && c.Value != "" && c.MaxAge >= 0 { return true } } return false } // TestEmailedLinkOpensTheConfirmDoor walks the magic-link sign-in end to // end: begin, the mail, GET of the emailed link (the confirm door, which // spends nothing), the door's POST, and a session that opens the deck // list. // // GET /auth/verify used to sign in on sight, so a mail-security gateway // that fetches every link in an inbound message (Safe Links, Proofpoint) // spent it before the person clicked, and they were told it had expired. // rastrillo now confirms on GET and redeems on POST (#64); this pins that // the confirm step is Slides' own door with one Sign in button, not // rastrillo's plain English fallback, and that the POST is mounted. func TestEmailedLinkOpensTheConfirmDoor(t *testing.T) { a := newApp(t) link := mailedLink(t, a, allowedAddress) u, err := url.Parse(link) if err != nil { t.Fatal(err) } // Opening it, twice, as a scanner and then the person would. for _, who := range []string{"scanner", "person"} { rec := get(t, a, u.RequestURI()) if rec.Code != http.StatusOK || len(rec.Result().Cookies()) != 0 { t.Fatalf("%s's GET = %d with %d cookies, want 200 and nothing minted", who, rec.Code, len(rec.Result().Cookies())) } body := rec.Body.String() for _, want := range []string{ "estilo-door", "estilo-signin-form", "Slides", "Select Sign in to finish signing in.", `action="/auth/verify"`, `name="token" value="` + u.Query().Get("token") + `"`, } { if !strings.Contains(body, want) { t.Errorf("%s: confirm door lacks %q:\n%s", who, want, body) } } if n := strings.Count(body, "Sign in`) { t.Errorf("%s: confirm door has %d buttons, want exactly one Sign in:\n%s", who, n, body) } if strings.Contains(body, `name="address"`) { t.Errorf("%s: confirm door asks for an address again", who) } if strings.Contains(body, "slides.signin.") { t.Errorf("%s: a catalog key leaked onto the confirm door", who) } if strings.Contains(body, "Sign in to ") { t.Errorf("%s: rastrillo's fallback confirm page, not the door", who) } // rauth sets these before RenderConfirm; render must not undo them. for header, want := range map[string]string{ "Cache-Control": "no-store", "Referrer-Policy": "strict-origin", "X-Frame-Options": "DENY", } { if got := rec.Header().Get(header); got != want { t.Errorf("%s: %s = %q, want %q", who, header, got, want) } } } // A cross-site post is refused and spends nothing: the proper POST // below still signs in with the same token. action, token := openLink(t, a, link) cross := httptest.NewRequest(http.MethodPost, action, strings.NewReader(url.Values{"token": {token}}.Encode())) cross.Header.Set("Content-Type", "application/x-www-form-urlencoded") cross.Header.Set("Sec-Fetch-Site", "cross-site") refused := httptest.NewRecorder() a.ServeHTTP(refused, cross) if refused.Code != http.StatusForbidden || len(refused.Result().Cookies()) != 0 { t.Fatalf("cross-site POST = %d with %d cookies, want 403 and nothing minted", refused.Code, len(refused.Result().Cookies())) } // Sign in: the POST spends the link and mints the session. signed := postLink(t, a, action, token) if signed.Code != http.StatusSeeOther || signed.Header().Get("Location") != "/auth/landed" || !sessionCookie(signed) { t.Fatalf("POST %s = %d %q, want 303 /auth/landed with a session", action, signed.Code, signed.Header().Get("Location")) } cookies := signed.Result().Cookies() if list := get(t, a, "/", cookies...); list.Code != http.StatusOK { t.Fatalf("GET / with the new session = %d, want 200", list.Code) } // Spent: the door still opens (it looks nothing up), but its button // now says the link expired. action, token = openLink(t, a, link) again := postLink(t, a, action, token) if sessionCookie(again) || again.Header().Get("Location") != "/signin?err=expired" { t.Fatalf("second POST = %d %q, want /signin?err=expired and no session", again.Code, again.Header().Get("Location")) } } // TestALinkRefusedAtRedemptionIsTheDoor: an address on the allowlist // when its link was mailed but off it when the link is redeemed — the // list changed and the instance restarted in between — gets the sign-in // door with the refusal on it, in rauth's 403 (Config.Refused, refused), // and no session. func TestALinkRefusedAtRedemptionIsTheDoor(t *testing.T) { const leaver = "leaver@example.test" cfg, mailer := newConfig(t) cfg.AllowedAddresses = []string{allowedAddress, leaver} d := openTestDB(t, cfg) before, b, err := slides.New(d, cfg) if err != nil { t.Fatal(err) } t.Cleanup(func() { b.Close() }) mailer.flush = b.FlushMail link := mailedLink(t, &testApp{Handler: localised(t, before), mailer: mailer, db: d}, leaver) // The same database, restarted without the leaver on the list. cfg.AllowedAddresses = []string{allowedAddress} afterMux, after, err := slides.New(d, cfg) if err != nil { t.Fatal(err) } t.Cleanup(func() { after.Close() }) h := localised(t, afterMux) rec := followLink(t, h, link) if rec.Code != http.StatusForbidden || rec.Header().Get("Location") != "" { t.Fatalf("refused redemption = %d %q, want 403 and the door itself", rec.Code, rec.Header().Get("Location")) } if sessionCookie(rec) { t.Fatal("a refused sign-in set a session cookie") } body := rec.Body.String() for _, want := range []string{"estilo-door", `name="address"`, `action="/signin"`, "can’t sign in to Slides"} { if !strings.Contains(body, want) { t.Errorf("refusal door lacks %q:\n%s", want, body) } } if strings.Contains(body, "not admitted here") { t.Error("the refusal is rastrillo's bare text") } if got := rec.Header().Get("Cache-Control"); got != "no-store" { t.Errorf("refusal door Cache-Control = %q, want no-store", got) } }