| 1 | //go:build browser |
| 2 | |
| 3 | package memoriatest |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "context" |
| 8 | "crypto/sha256" |
| 9 | "encoding/base64" |
| 10 | "encoding/json" |
| 11 | "io" |
| 12 | "log/slog" |
| 13 | "mime/multipart" |
| 14 | "net/http" |
| 15 | "path/filepath" |
| 16 | "regexp" |
| 17 | "strings" |
| 18 | "testing" |
| 19 | "time" |
| 20 | |
| 21 | "amadan.net/rastrillo/rastrillo" |
| 22 | "amadan.net/rastrillo/rastrillo/db" |
| 23 | "amadan.net/rastrillo/rastrillo/harness" |
| 24 | "github.com/chromedp/cdproto/page" |
| 25 | "github.com/chromedp/cdproto/runtime" |
| 26 | "github.com/chromedp/chromedp" |
| 27 | |
| 28 | memoria "amadan.net/oficina/memoria/internal/memoria" |
| 29 | ) |
| 30 | |
| 31 | // cspWatch records every securitypolicyviolation a page raises, from |
| 32 | // before its first byte is parsed, into window.__csp. |
| 33 | const cspWatch = `window.__csp = []; |
| 34 | document.addEventListener("securitypolicyviolation", e => |
| 35 | window.__csp.push(e.violatedDirective + " " + (e.blockedURI || "inline") + " " + (e.sample || "")));` |
| 36 | |
| 37 | // cspProbe is the presence half. It writes one inline style attribute the |
| 38 | // way a page or script would, and reports whether the browser refused it. |
| 39 | // A refusal proves the listener is live and the page really runs under a |
| 40 | // policy that blocks inline styles. Without it, "no violations" would pass |
| 41 | // against a policy that allows inline styles, or a listener that never |
| 42 | // attached. |
| 43 | const cspProbe = `new Promise(done => { |
| 44 | const before = window.__csp.length; |
| 45 | const probe = document.createElement("i"); |
| 46 | probe.setAttribute("style", "outline: 1px solid"); |
| 47 | document.body.appendChild(probe); |
| 48 | setTimeout(() => { |
| 49 | const refused = window.__csp.length > before; |
| 50 | window.__csp.length = before; |
| 51 | probe.remove(); |
| 52 | done(refused); |
| 53 | }, 200); |
| 54 | })` |
| 55 | |
| 56 | // TestPagesRenderUnderTheProductionCSP: every page Memoria serves |
| 57 | // standalone, in Chromium, through rastrillo's production stack |
| 58 | // (memoria.ServeOptions under rastrillo.Handler, so the real CSP), raises |
| 59 | // no Content-Security-Policy violation. That includes the sign-in door, |
| 60 | // the confirm door an emailed link opens on, the signed-in pages, and |
| 61 | // search.js's live results swapped in by script. rastrillo fb806a2 refuses |
| 62 | // inline style attributes by default, and this is the check that nothing |
| 63 | // Memoria draws or scripts relies on one. |
| 64 | // |
| 65 | // The plain-Go TestNoPageCarriesAStyleAttribute reads the served markup in |
| 66 | // CI; this one also sees what scripts do after load. The other browser |
| 67 | // checks serve the bare router and get no CSP at all, so they could not |
| 68 | // catch this. |
| 69 | // |
| 70 | // MUTATIONS (author-chosen, 2026-10-06; an independent pass is owed per |
| 71 | // AGENTS.md): style="color:red" on the confirm form in signin.html fails |
| 72 | // here; ServeOptions setting the pre-fb806a2 policy (style-src 'self' |
| 73 | // 'unsafe-inline') fails the probe; the native approval route without its |
| 74 | // hash (651f370) fails on style-src-elem; search.js assigning |
| 75 | // main.style.outline fails the [style] check. Reverted. |
| 76 | func TestPagesRenderUnderTheProductionCSP(t *testing.T) { |
| 77 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 78 | served := func(t *testing.T, test bool) func(origin string) http.Handler { |
| 79 | return func(origin string) http.Handler { |
| 80 | d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) |
| 81 | if err != nil { |
| 82 | t.Fatalf("db.Open: %v", err) |
| 83 | } |
| 84 | t.Cleanup(func() { d.Close() }) |
| 85 | build := memoria.App |
| 86 | if test { |
| 87 | build = memoria.AppForTest |
| 88 | } |
| 89 | mux, a, err := build(d, origin, testDEK(t), logger) |
| 90 | if err != nil { |
| 91 | t.Fatalf("App: %v", err) |
| 92 | } |
| 93 | if !test { |
| 94 | a.SetOwners([]string{chromeOwner}) |
| 95 | au, err := memoria.NewAuth(d.Writer(), memoria.AuthEnv{ |
| 96 | Origin: origin, InstanceKey: "csp-browser-instance-key", |
| 97 | Owners: []string{chromeOwner}, Mailer: &capturingMailer{}, |
| 98 | }, logger, a) |
| 99 | if err != nil { |
| 100 | t.Fatalf("NewAuth: %v", err) |
| 101 | } |
| 102 | a.UseAuth(au) |
| 103 | a.MountAuth(mux, origin) |
| 104 | } |
| 105 | h, done, err := rastrillo.Handler(a.ServeOptions(rastrillo.Options{Logger: logger}, mux)) |
| 106 | if err != nil { |
| 107 | t.Fatalf("rastrillo.Handler: %v", err) |
| 108 | } |
| 109 | if done != nil { |
| 110 | t.Cleanup(func() { _ = done() }) |
| 111 | } |
| 112 | return h |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | visit := func(t *testing.T, rig *harness.Rig, path, ready string, then ...chromedp.Action) { |
| 117 | t.Helper() |
| 118 | ctx, cancel := context.WithTimeout(rig.Context(), 20*time.Second) |
| 119 | defer cancel() |
| 120 | var violations, styled []string |
| 121 | var refused bool |
| 122 | actions := []chromedp.Action{ |
| 123 | chromedp.Navigate(rig.Origin + path), |
| 124 | chromedp.WaitVisible(ready, chromedp.ByQuery), |
| 125 | } |
| 126 | actions = append(actions, then...) |
| 127 | actions = append(actions, |
| 128 | chromedp.Sleep(150*time.Millisecond), |
| 129 | chromedp.Evaluate(`window.__csp`, &violations), |
| 130 | // A script assigning element.style.x raises no violation (the |
| 131 | // CSSOM is not policed), so look at the DOM itself too (Astra |
| 132 | // on #27): no element may carry a style attribute. |
| 133 | chromedp.Evaluate(`[...document.querySelectorAll("[style]")].map(e => e.tagName.toLowerCase() + '[style="' + e.getAttribute("style") + '"]')`, &styled), |
| 134 | chromedp.Evaluate(cspProbe, &refused, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) }), |
| 135 | ) |
| 136 | if err := chromedp.Run(ctx, actions...); err != nil { |
| 137 | t.Fatalf("%s: %v", path, err) |
| 138 | } |
| 139 | if len(violations) > 0 { |
| 140 | t.Errorf("%s: the production CSP refused: %s", path, strings.Join(violations, "; ")) |
| 141 | } |
| 142 | if len(styled) > 0 { |
| 143 | t.Errorf("%s: elements carry inline styles after load: %s", path, strings.Join(styled, "; ")) |
| 144 | } |
| 145 | if !refused { |
| 146 | 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) |
| 147 | } |
| 148 | } |
| 149 | watch := func(t *testing.T, rig *harness.Rig) { |
| 150 | t.Helper() |
| 151 | if err := chromedp.Run(rig.Context(), chromedp.ActionFunc(func(ctx context.Context) error { |
| 152 | _, err := page.AddScriptToEvaluateOnNewDocument(cspWatch).Do(ctx) |
| 153 | return err |
| 154 | })); err != nil { |
| 155 | t.Fatal(err) |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | t.Run("signed out", func(t *testing.T) { |
| 160 | rig := harness.New(t, served(t, false)) |
| 161 | watch(t, rig) |
| 162 | visit(t, rig, "/signin", `input[name="address"]`) |
| 163 | visit(t, rig, "/signin?err=expired", `[rst-callout]`) |
| 164 | visit(t, rig, "/signin?sent=1", `[estilo-door]`) |
| 165 | visit(t, rig, "/signed-out", `[estilo-door]`) |
| 166 | visit(t, rig, "/auth/verify?token=not-a-real-token", `[estilo-signin-form] button[type="submit"]`) |
| 167 | visit(t, rig, "/help/open-source", `main`) |
| 168 | }) |
| 169 | |
| 170 | t.Run("signed in", func(t *testing.T) { |
| 171 | rig := harness.New(t, served(t, true)) |
| 172 | watch(t, rig) |
| 173 | var buf bytes.Buffer |
| 174 | mw := multipart.NewWriter(&buf) |
| 175 | _ = mw.WriteField("title", "Office handbook") |
| 176 | _ = mw.WriteField("body", "The Lisbon office is at Rua Augusta 100.") |
| 177 | mw.Close() |
| 178 | req, _ := http.NewRequest(http.MethodPost, rig.Origin+"/documents", &buf) |
| 179 | req.Header.Set("Content-Type", mw.FormDataContentType()) |
| 180 | req.Header.Set("Origin", rig.Origin) |
| 181 | req.Header.Set("Sec-Fetch-Site", "same-origin") |
| 182 | res, err := http.DefaultClient.Do(req) |
| 183 | if err != nil { |
| 184 | t.Fatalf("store a document: %v", err) |
| 185 | } |
| 186 | res.Body.Close() |
| 187 | |
| 188 | var at string |
| 189 | visit(t, rig, "/", `a[href^="/documents/"]`, |
| 190 | chromedp.AttributeValue(`a[href^="/documents/"]`, "href", &at, nil, chromedp.ByQuery)) |
| 191 | doc := regexp.MustCompile(`^/documents/\d+$`).FindString(at) |
| 192 | if doc == "" { |
| 193 | t.Fatalf("the index links no document (got %q)", at) |
| 194 | } |
| 195 | // search.js: typing swaps the results in by script. |
| 196 | visit(t, rig, "/", `form[rst-search] input[type="search"]`, |
| 197 | chromedp.SendKeys(`form[rst-search] input[type="search"]`, "Lisbon", chromedp.ByQuery), |
| 198 | chromedp.WaitVisible(`[rst-search-results-for="Lisbon"]`, chromedp.ByQuery)) |
| 199 | visit(t, rig, "/?q=Lisbon", `main`) |
| 200 | visit(t, rig, "/add", `textarea[name="body"]`) |
| 201 | visit(t, rig, doc, `main`) |
| 202 | visit(t, rig, doc+"/delete", `main`) |
| 203 | visit(t, rig, "/connectors", `main`) |
| 204 | visit(t, rig, "/connectors/notion", `input[name="token"]`) |
| 205 | |
| 206 | // The native kit's approval page (Astra on #27, P2): its whole |
| 207 | // stylesheet is one inline <style>, admitted by hash on that route |
| 208 | // alone (memoria native.go). On 651f370 this was refused |
| 209 | // (style-src-elem), and the card and button went unstyled — so |
| 210 | // also check the stylesheet applied: the button is the kit's |
| 211 | // purple, not the browser's grey. |
| 212 | sum := sha256.Sum256([]byte(strings.Repeat("v", 43))) |
| 213 | nreq, _ := http.NewRequest(http.MethodPost, rig.Origin+"/native/request", |
| 214 | strings.NewReader(`{"challenge":"`+base64.RawURLEncoding.EncodeToString(sum[:])+`"}`)) |
| 215 | nreq.Header.Set("Content-Type", "application/json") |
| 216 | nreq.Header.Set("Origin", rig.Origin) |
| 217 | nres, err := http.DefaultClient.Do(nreq) |
| 218 | if err != nil { |
| 219 | t.Fatalf("POST /native/request: %v", err) |
| 220 | } |
| 221 | var native struct{ ID string } |
| 222 | _ = json.NewDecoder(nres.Body).Decode(&native) |
| 223 | nres.Body.Close() |
| 224 | if native.ID == "" { |
| 225 | t.Fatalf("POST /native/request = %d with no id", nres.StatusCode) |
| 226 | } |
| 227 | var bg string |
| 228 | visit(t, rig, "/native/authorize?id="+native.ID, `main form button`, |
| 229 | chromedp.Evaluate(`getComputedStyle(document.querySelector("main form button")).backgroundColor`, &bg)) |
| 230 | if bg != "rgb(112, 82, 162)" { |
| 231 | t.Errorf("/native/authorize: the approval button's background is %q, want the kit's #7052a2 — its stylesheet did not apply", bg) |
| 232 | } |
| 233 | }) |
| 234 | } |
| 235 | |