//go:build browser package memoriatest import ( "bytes" "context" "crypto/sha256" "encoding/base64" "encoding/json" "io" "log/slog" "mime/multipart" "net/http" "path/filepath" "regexp" "strings" "testing" "time" "amadan.net/rastrillo/rastrillo" "amadan.net/rastrillo/rastrillo/db" "amadan.net/rastrillo/rastrillo/harness" "github.com/chromedp/cdproto/page" "github.com/chromedp/cdproto/runtime" "github.com/chromedp/chromedp" memoria "amadan.net/oficina/memoria/internal/memoria" ) // cspWatch records every securitypolicyviolation a page raises, from // before its first byte is parsed, into window.__csp. const cspWatch = `window.__csp = []; document.addEventListener("securitypolicyviolation", e => window.__csp.push(e.violatedDirective + " " + (e.blockedURI || "inline") + " " + (e.sample || "")));` // cspProbe is the presence half. It writes one inline style attribute the // way a page or script would, and reports whether the browser refused it. // A refusal proves the listener is live and the page really runs under a // policy that blocks inline styles. Without it, "no violations" would pass // against a policy that allows inline styles, or a listener that never // attached. const cspProbe = `new Promise(done => { const before = window.__csp.length; const probe = document.createElement("i"); probe.setAttribute("style", "outline: 1px solid"); document.body.appendChild(probe); setTimeout(() => { const refused = window.__csp.length > before; window.__csp.length = before; probe.remove(); done(refused); }, 200); })` // TestPagesRenderUnderTheProductionCSP: every page Memoria serves // standalone, in Chromium, through rastrillo's production stack // (memoria.ServeOptions under rastrillo.Handler, so the real CSP), raises // no Content-Security-Policy violation. That includes the sign-in door, // the confirm door an emailed link opens on, the signed-in pages, and // search.js's live results swapped in by script. rastrillo fb806a2 refuses // inline style attributes by default, and this is the check that nothing // Memoria draws or scripts relies on one. // // The plain-Go TestNoPageCarriesAStyleAttribute reads the served markup in // CI; this one also sees what scripts do after load. The other browser // checks serve the bare router and get no CSP at all, so they could not // catch this. // // MUTATIONS (author-chosen, 2026-10-06; an independent pass is owed per // AGENTS.md): style="color:red" on the confirm form in signin.html fails // here; ServeOptions setting the pre-fb806a2 policy (style-src 'self' // 'unsafe-inline') fails the probe; the native approval route without its // hash (651f370) fails on style-src-elem; search.js assigning // main.style.outline fails the [style] check. Reverted. func TestPagesRenderUnderTheProductionCSP(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) served := func(t *testing.T, test bool) func(origin string) http.Handler { return func(origin string) http.Handler { d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { d.Close() }) build := memoria.App if test { build = memoria.AppForTest } mux, a, err := build(d, origin, testDEK(t), logger) if err != nil { t.Fatalf("App: %v", err) } if !test { a.SetOwners([]string{chromeOwner}) au, err := memoria.NewAuth(d.Writer(), memoria.AuthEnv{ Origin: origin, InstanceKey: "csp-browser-instance-key", Owners: []string{chromeOwner}, Mailer: &capturingMailer{}, }, logger, a) if err != nil { t.Fatalf("NewAuth: %v", err) } a.UseAuth(au) a.MountAuth(mux, origin) } h, done, err := rastrillo.Handler(a.ServeOptions(rastrillo.Options{Logger: logger}, mux)) if err != nil { t.Fatalf("rastrillo.Handler: %v", err) } if done != nil { t.Cleanup(func() { _ = done() }) } return h } } visit := func(t *testing.T, rig *harness.Rig, path, ready string, then ...chromedp.Action) { t.Helper() ctx, cancel := context.WithTimeout(rig.Context(), 20*time.Second) defer cancel() var violations, styled []string var refused bool actions := []chromedp.Action{ chromedp.Navigate(rig.Origin + path), chromedp.WaitVisible(ready, chromedp.ByQuery), } actions = append(actions, then...) actions = append(actions, chromedp.Sleep(150*time.Millisecond), chromedp.Evaluate(`window.__csp`, &violations), // A script assigning element.style.x raises no violation (the // CSSOM is not policed), so look at the DOM itself too (Astra // on #27): no element may carry a style attribute. chromedp.Evaluate(`[...document.querySelectorAll("[style]")].map(e => e.tagName.toLowerCase() + '[style="' + e.getAttribute("style") + '"]')`, &styled), chromedp.Evaluate(cspProbe, &refused, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) }), ) if err := chromedp.Run(ctx, actions...); err != nil { t.Fatalf("%s: %v", path, err) } if len(violations) > 0 { t.Errorf("%s: the production CSP refused: %s", path, strings.Join(violations, "; ")) } if len(styled) > 0 { t.Errorf("%s: elements carry inline styles after load: %s", path, strings.Join(styled, "; ")) } if !refused { t.Errorf("%s: an inline style attribute was NOT refused: this page is not under rastrillo's strict policy, or the watch never attached", path) } } watch := func(t *testing.T, rig *harness.Rig) { t.Helper() if err := chromedp.Run(rig.Context(), chromedp.ActionFunc(func(ctx context.Context) error { _, err := page.AddScriptToEvaluateOnNewDocument(cspWatch).Do(ctx) return err })); err != nil { t.Fatal(err) } } t.Run("signed out", func(t *testing.T) { rig := harness.New(t, served(t, false)) watch(t, rig) visit(t, rig, "/signin", `input[name="address"]`) visit(t, rig, "/signin?err=expired", `[rst-callout]`) visit(t, rig, "/signin?sent=1", `[estilo-door]`) visit(t, rig, "/signed-out", `[estilo-door]`) visit(t, rig, "/auth/verify?token=not-a-real-token", `[estilo-signin-form] button[type="submit"]`) visit(t, rig, "/help/open-source", `main`) }) t.Run("signed in", func(t *testing.T) { rig := harness.New(t, served(t, true)) watch(t, rig) var buf bytes.Buffer mw := multipart.NewWriter(&buf) _ = mw.WriteField("title", "Office handbook") _ = mw.WriteField("body", "The Lisbon office is at Rua Augusta 100.") mw.Close() req, _ := http.NewRequest(http.MethodPost, rig.Origin+"/documents", &buf) req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("Origin", rig.Origin) req.Header.Set("Sec-Fetch-Site", "same-origin") res, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("store a document: %v", err) } res.Body.Close() var at string visit(t, rig, "/", `a[href^="/documents/"]`, chromedp.AttributeValue(`a[href^="/documents/"]`, "href", &at, nil, chromedp.ByQuery)) doc := regexp.MustCompile(`^/documents/\d+$`).FindString(at) if doc == "" { t.Fatalf("the index links no document (got %q)", at) } // search.js: typing swaps the results in by script. visit(t, rig, "/", `form[rst-search] input[type="search"]`, chromedp.SendKeys(`form[rst-search] input[type="search"]`, "Lisbon", chromedp.ByQuery), chromedp.WaitVisible(`[rst-search-results-for="Lisbon"]`, chromedp.ByQuery)) visit(t, rig, "/?q=Lisbon", `main`) visit(t, rig, "/add", `textarea[name="body"]`) visit(t, rig, doc, `main`) visit(t, rig, doc+"/delete", `main`) visit(t, rig, "/connectors", `main`) visit(t, rig, "/connectors/notion", `input[name="token"]`) // The native kit's approval page (Astra on #27, P2): its whole // stylesheet is one inline