oficina / memoria Public

Clone
git clone https://amadan.net/oficina/memoria

Plain git — no account needed to clone.

Download

Download this file

1//go:build browser
2
3package memoriatest
4
5import (
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.
33const cspWatch = `window.__csp = [];
34document.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.
43const 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.
76func 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