package memoriatest import ( "bytes" "html" "io" "log/slog" "mime/multipart" "net/http" "net/http/httptest" "net/url" "regexp" "strings" "testing" "amadan.net/rastrillo/rastrillo" "amadan.net/rastrillo/rastrillo/db" memoria "amadan.net/oficina/memoria/internal/memoria" ) // servedApp is the production app behind rastrillo's production stack: // memoria.App with real sign-in, mounted as main.go mounts it, completed // by memoria.ServeOptions and wrapped by rastrillo.Handler — so the CSP, // the framing headers and the framework's own same-origin checks are the // ones a deployed instance sends. owners is the admission list; mail is // captured. func servedApp(t *testing.T, dbPath string, owners []string) (http.Handler, *capturingMailer) { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) d, err := db.Open(dbPath, logger) if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { d.Close() }) mux, a, err := memoria.App(d, testOrigin, testDEK(t), logger) if err != nil { t.Fatalf("App: %v", err) } mailer := &capturingMailer{} a.SetOwners(owners) au, err := memoria.NewAuth(d.Writer(), memoria.AuthEnv{ Origin: testOrigin, InstanceKey: "admission-gate-instance-key", Owners: owners, Mailer: mailer, }, logger, a) if err != nil { t.Fatalf("NewAuth: %v", err) } a.UseAuth(au) a.MountAuth(mux, testOrigin) h := production(t, a.ServeOptions(rastrillo.Options{Logger: logger}, mux)) offlineResolver(t) return h, mailer } // production wraps opts in rastrillo.Handler, the stack Serve runs. func production(t *testing.T, opts rastrillo.Options) http.Handler { t.Helper() h, done, err := rastrillo.Handler(opts) if err != nil { t.Fatalf("rastrillo.Handler: %v", err) } if done != nil { t.Cleanup(func() { _ = done() }) } return h } // defaultCSP is rastrillo's own policy at the pinned version, read off a // bare handler rather than copied, so a tightening upstream is what the // tests below expect next time without anybody editing them. func defaultCSP(t *testing.T) string { t.Helper() mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {}) rec := httptest.NewRecorder() production(t, rastrillo.Options{Mux: mux}).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, testOrigin+"/", nil)) csp := rec.Header().Get("Content-Security-Policy") if csp == "" { t.Fatal("a bare rastrillo handler sends no CSP; this measures nothing") } return csp } // sessionSet reports whether rec set a live session cookie. func sessionSet(rec *httptest.ResponseRecorder) bool { for _, c := range rec.Result().Cookies() { if strings.Contains(strings.ToLower(c.Name), "session") && c.Value != "" && c.MaxAge >= 0 { return true } } return false } // rastrillo #64, end to end through the production stack: opening an // emailed link spends nothing; the confirm door's Sign in button does. // // begin → mail → the link opened twice (a mail scanner's fetch, then the // person's: both the door, neither a session) → a cross-site POST and a // POST with no browser evidence, both refused WITHOUT spending the link → // the door's own POST signs in and the session opens the index → the same // token again is ?err=expired. func TestEmailedLinkOpensTheConfirmDoor(t *testing.T) { h, mailer := servedApp(t, tempDB(t), []string{chromeOwner}) c := newClient(t, h) if rec := c.do(http.MethodPost, testOrigin+"/auth/begin", url.Values{"address": {chromeOwner}}); rec.Code != http.StatusSeeOther { t.Fatalf("POST /auth/begin = %d", rec.Code) } link := mailer.link() if link == "" { t.Fatalf("no magic link was sent: %q", mailer.text()) } u, err := url.Parse(link) if err != nil { t.Fatal(err) } // Twice: a scanner, then the person. A GET mints nothing. var action, token string for _, who := range []*client{newClient(t, h), c} { rec := who.get(testOrigin + u.RequestURI()) if rec.Code != http.StatusOK { t.Fatalf("GET the link = %d, want the confirm door", rec.Code) } if sessionSet(rec) { t.Fatal("opening the link minted a session; the GET must spend nothing") } action, token = confirmForm(t, rec.Body.String()) } if action != "/auth/verify" || token != u.Query().Get("token") { t.Fatalf("the door posts %q with token %q, want /auth/verify and the link's own token", action, token) } post := func(origin, site string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, testOrigin+action, strings.NewReader(url.Values{"token": {token}}.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if origin != "" { req.Header.Set("Origin", origin) } if site != "" { req.Header.Set("Sec-Fetch-Site", site) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } for name, rec := range map[string]*httptest.ResponseRecorder{ "cross-site": post("https://evil.example", "cross-site"), "no browser proof": post("", ""), } { if rec.Code != http.StatusForbidden || sessionSet(rec) { t.Errorf("%s POST = %d (session %v), want 403 and no session", name, rec.Code, sessionSet(rec)) } } // The link survived both: the door's own POST signs in. rec := c.postSameOrigin(testOrigin+action, url.Values{"token": {token}}) if rec.Code != http.StatusSeeOther || !sessionSet(rec) { t.Fatalf("the door's POST = %d (session %v), want 303 and a session — the refused POSTs above spent the link", rec.Code, sessionSet(rec)) } dest := rec.Header().Get("Location") for i := 0; i < 3 && dest != "/"; i++ { dest = location(t, c.get(testOrigin+dest)) } if dest != "/" { t.Fatalf("sign-in landed on %q, want /", dest) } if got := c.get(testOrigin + "/"); got.Code != http.StatusOK || !strings.Contains(got.Body.String(), "estilo-account") { t.Fatalf("GET / with the new session = %d, or it is not signed in", got.Code) } // Spent: the same token again is ?err=expired, and no session. replay := newClient(t, h).postSameOrigin(testOrigin+action, url.Values{"token": {token}}) if replay.Code != http.StatusSeeOther || replay.Header().Get("Location") != "/signin?err=expired" || sessionSet(replay) { t.Errorf("a replayed token = %d %q, want 303 /signin?err=expired and no session", replay.Code, replay.Header().Get("Location")) } } // The confirm door is drawn by Memoria (renderConfirm), inside its own // layout — and auth's headers survive the render: no-store, the // strict-origin Referrer-Policy (the URL is a live credential, and the // layout's stylesheet and script requests would otherwise carry it as // their Referer), the framing refusals, and BOTH CSP values: rastrillo's // policy, then auth's added frame-ancestors 'none'. // // MUTATIONS (author-chosen, 2026-10-06; an independent pass is owed per // AGENTS.md): renderConfirm setting Referrer-Policy to // strict-origin-when-cross-origin, or Set-ing a CSP of its own, each fail // here. Reverted. func TestTheConfirmDoorKeepsEveryPolicy(t *testing.T) { h, _ := servedApp(t, tempDB(t), []string{chromeOwner}) const evil = `">` rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, testOrigin+"/auth/verify?token="+url.QueryEscape(evil), nil)) if rec.Code != http.StatusOK { t.Fatalf("GET /auth/verify = %d", rec.Code) } hd := rec.Header() for name, want := range map[string]string{ "Cache-Control": "no-store", "Referrer-Policy": "strict-origin", "X-Frame-Options": "DENY", "X-Robots-Tag": "noindex, nofollow", } { if got := hd.Get(name); got != want { t.Errorf("%s = %q, want %q", name, got, want) } } csp := hd.Values("Content-Security-Policy") if want := []string{defaultCSP(t), "frame-ancestors 'none'"}; strings.Join(csp, "\n") != strings.Join(want, "\n") { t.Errorf("CSP values = %q, want %q", csp, want) } body := rec.Body.String() _, token := confirmForm(t, body) if token != evil { t.Errorf("the door carries token %q, want the link's own", token) } if strings.Contains(body, "