oficina / memoria Public

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

Plain git — no account needed to clone.

Download

Download this file

1package memoriatest
2
3import (
4 "bytes"
5 "html"
6 "io"
7 "log/slog"
8 "mime/multipart"
9 "net/http"
10 "net/http/httptest"
11 "net/url"
12 "regexp"
13 "strings"
14 "testing"
15
16 "amadan.net/rastrillo/rastrillo"
17 "amadan.net/rastrillo/rastrillo/db"
18
19 memoria "amadan.net/oficina/memoria/internal/memoria"
20)
21
22// servedApp is the production app behind rastrillo's production stack:
23// memoria.App with real sign-in, mounted as main.go mounts it, completed
24// by memoria.ServeOptions and wrapped by rastrillo.Handler — so the CSP,
25// the framing headers and the framework's own same-origin checks are the
26// ones a deployed instance sends. owners is the admission list; mail is
27// captured.
28func servedApp(t *testing.T, dbPath string, owners []string) (http.Handler, *capturingMailer) {
29 t.Helper()
30 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
31 d, err := db.Open(dbPath, logger)
32 if err != nil {
33 t.Fatalf("db.Open: %v", err)
34 }
35 t.Cleanup(func() { d.Close() })
36 mux, a, err := memoria.App(d, testOrigin, testDEK(t), logger)
37 if err != nil {
38 t.Fatalf("App: %v", err)
39 }
40 mailer := &capturingMailer{}
41 a.SetOwners(owners)
42 au, err := memoria.NewAuth(d.Writer(), memoria.AuthEnv{
43 Origin: testOrigin, InstanceKey: "admission-gate-instance-key",
44 Owners: owners, Mailer: mailer,
45 }, logger, a)
46 if err != nil {
47 t.Fatalf("NewAuth: %v", err)
48 }
49 a.UseAuth(au)
50 a.MountAuth(mux, testOrigin)
51 h := production(t, a.ServeOptions(rastrillo.Options{Logger: logger}, mux))
52 offlineResolver(t)
53 return h, mailer
54}
55
56// production wraps opts in rastrillo.Handler, the stack Serve runs.
57func production(t *testing.T, opts rastrillo.Options) http.Handler {
58 t.Helper()
59 h, done, err := rastrillo.Handler(opts)
60 if err != nil {
61 t.Fatalf("rastrillo.Handler: %v", err)
62 }
63 if done != nil {
64 t.Cleanup(func() { _ = done() })
65 }
66 return h
67}
68
69// defaultCSP is rastrillo's own policy at the pinned version, read off a
70// bare handler rather than copied, so a tightening upstream is what the
71// tests below expect next time without anybody editing them.
72func defaultCSP(t *testing.T) string {
73 t.Helper()
74 mux := http.NewServeMux()
75 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {})
76 rec := httptest.NewRecorder()
77 production(t, rastrillo.Options{Mux: mux}).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, testOrigin+"/", nil))
78 csp := rec.Header().Get("Content-Security-Policy")
79 if csp == "" {
80 t.Fatal("a bare rastrillo handler sends no CSP; this measures nothing")
81 }
82 return csp
83}
84
85// sessionSet reports whether rec set a live session cookie.
86func sessionSet(rec *httptest.ResponseRecorder) bool {
87 for _, c := range rec.Result().Cookies() {
88 if strings.Contains(strings.ToLower(c.Name), "session") && c.Value != "" && c.MaxAge >= 0 {
89 return true
90 }
91 }
92 return false
93}
94
95// rastrillo #64, end to end through the production stack: opening an
96// emailed link spends nothing; the confirm door's Sign in button does.
97//
98// begin → mail → the link opened twice (a mail scanner's fetch, then the
99// person's: both the door, neither a session) → a cross-site POST and a
100// POST with no browser evidence, both refused WITHOUT spending the link →
101// the door's own POST signs in and the session opens the index → the same
102// token again is ?err=expired.
103func TestEmailedLinkOpensTheConfirmDoor(t *testing.T) {
104 h, mailer := servedApp(t, tempDB(t), []string{chromeOwner})
105 c := newClient(t, h)
106 if rec := c.do(http.MethodPost, testOrigin+"/auth/begin", url.Values{"address": {chromeOwner}}); rec.Code != http.StatusSeeOther {
107 t.Fatalf("POST /auth/begin = %d", rec.Code)
108 }
109 link := mailer.link()
110 if link == "" {
111 t.Fatalf("no magic link was sent: %q", mailer.text())
112 }
113 u, err := url.Parse(link)
114 if err != nil {
115 t.Fatal(err)
116 }
117
118 // Twice: a scanner, then the person. A GET mints nothing.
119 var action, token string
120 for _, who := range []*client{newClient(t, h), c} {
121 rec := who.get(testOrigin + u.RequestURI())
122 if rec.Code != http.StatusOK {
123 t.Fatalf("GET the link = %d, want the confirm door", rec.Code)
124 }
125 if sessionSet(rec) {
126 t.Fatal("opening the link minted a session; the GET must spend nothing")
127 }
128 action, token = confirmForm(t, rec.Body.String())
129 }
130 if action != "/auth/verify" || token != u.Query().Get("token") {
131 t.Fatalf("the door posts %q with token %q, want /auth/verify and the link's own token", action, token)
132 }
133
134 post := func(origin, site string) *httptest.ResponseRecorder {
135 req := httptest.NewRequest(http.MethodPost, testOrigin+action, strings.NewReader(url.Values{"token": {token}}.Encode()))
136 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
137 if origin != "" {
138 req.Header.Set("Origin", origin)
139 }
140 if site != "" {
141 req.Header.Set("Sec-Fetch-Site", site)
142 }
143 rec := httptest.NewRecorder()
144 h.ServeHTTP(rec, req)
145 return rec
146 }
147 for name, rec := range map[string]*httptest.ResponseRecorder{
148 "cross-site": post("https://evil.example", "cross-site"),
149 "no browser proof": post("", ""),
150 } {
151 if rec.Code != http.StatusForbidden || sessionSet(rec) {
152 t.Errorf("%s POST = %d (session %v), want 403 and no session", name, rec.Code, sessionSet(rec))
153 }
154 }
155
156 // The link survived both: the door's own POST signs in.
157 rec := c.postSameOrigin(testOrigin+action, url.Values{"token": {token}})
158 if rec.Code != http.StatusSeeOther || !sessionSet(rec) {
159 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))
160 }
161 dest := rec.Header().Get("Location")
162 for i := 0; i < 3 && dest != "/"; i++ {
163 dest = location(t, c.get(testOrigin+dest))
164 }
165 if dest != "/" {
166 t.Fatalf("sign-in landed on %q, want /", dest)
167 }
168 if got := c.get(testOrigin + "/"); got.Code != http.StatusOK || !strings.Contains(got.Body.String(), "estilo-account") {
169 t.Fatalf("GET / with the new session = %d, or it is not signed in", got.Code)
170 }
171
172 // Spent: the same token again is ?err=expired, and no session.
173 replay := newClient(t, h).postSameOrigin(testOrigin+action, url.Values{"token": {token}})
174 if replay.Code != http.StatusSeeOther || replay.Header().Get("Location") != "/signin?err=expired" || sessionSet(replay) {
175 t.Errorf("a replayed token = %d %q, want 303 /signin?err=expired and no session", replay.Code, replay.Header().Get("Location"))
176 }
177}
178
179// The confirm door is drawn by Memoria (renderConfirm), inside its own
180// layout — and auth's headers survive the render: no-store, the
181// strict-origin Referrer-Policy (the URL is a live credential, and the
182// layout's stylesheet and script requests would otherwise carry it as
183// their Referer), the framing refusals, and BOTH CSP values: rastrillo's
184// policy, then auth's added frame-ancestors 'none'.
185//
186// MUTATIONS (author-chosen, 2026-10-06; an independent pass is owed per
187// AGENTS.md): renderConfirm setting Referrer-Policy to
188// strict-origin-when-cross-origin, or Set-ing a CSP of its own, each fail
189// here. Reverted.
190func TestTheConfirmDoorKeepsEveryPolicy(t *testing.T) {
191 h, _ := servedApp(t, tempDB(t), []string{chromeOwner})
192 const evil = `"><script>alert(1)</script>`
193 rec := httptest.NewRecorder()
194 h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, testOrigin+"/auth/verify?token="+url.QueryEscape(evil), nil))
195 if rec.Code != http.StatusOK {
196 t.Fatalf("GET /auth/verify = %d", rec.Code)
197 }
198 hd := rec.Header()
199 for name, want := range map[string]string{
200 "Cache-Control": "no-store",
201 "Referrer-Policy": "strict-origin",
202 "X-Frame-Options": "DENY",
203 "X-Robots-Tag": "noindex, nofollow",
204 } {
205 if got := hd.Get(name); got != want {
206 t.Errorf("%s = %q, want %q", name, got, want)
207 }
208 }
209 csp := hd.Values("Content-Security-Policy")
210 if want := []string{defaultCSP(t), "frame-ancestors 'none'"}; strings.Join(csp, "\n") != strings.Join(want, "\n") {
211 t.Errorf("CSP values = %q, want %q", csp, want)
212 }
213 body := rec.Body.String()
214 _, token := confirmForm(t, body)
215 if token != evil {
216 t.Errorf("the door carries token %q, want the link's own", token)
217 }
218 if strings.Contains(body, "<script>alert(1)") {
219 t.Error("the token reached the page unescaped")
220 }
221 for _, want := range []string{"<header estilo-bar", "estilo-door", "Select Sign in to finish signing in."} {
222 if !strings.Contains(body, want) {
223 t.Errorf("the confirm door is not Memoria's door: no %q", want)
224 }
225 }
226 if strings.Contains(body, "<footer") {
227 t.Error("the confirm door carries the footer; sign-in pages do not")
228 }
229}
230
231// Standalone, Memoria serves rastrillo's own CSP, unchanged: no page or
232// script writes an inline style (memoria.ServeOptions), so there is
233// nothing to loosen style-src for. Read through ServeOptions and
234// rastrillo.Handler, the stack main runs.
235//
236// MUTATIONS (author-chosen, 2026-10-06; an independent pass is owed):
237// ServeOptions setting opts.CSP to the pre-fb806a2 policy (style-src
238// 'self' 'unsafe-inline') fails here. Reverted.
239func TestStandaloneServesRastrillosDefaultCSP(t *testing.T) {
240 h, _ := servedApp(t, tempDB(t), []string{chromeOwner})
241 want := defaultCSP(t)
242 if strings.Contains(want, "'unsafe-inline'") {
243 t.Fatalf("rastrillo's default allows inline styles again (%q); this test is about the stricter one", want)
244 }
245 for _, path := range []string{"/signin", "/signin?err=expired", "/signed-out", "/auth/verify?token=x", "/help/open-source", "/no-such-page"} {
246 rec := httptest.NewRecorder()
247 h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, testOrigin+path, nil))
248 if got := rec.Header().Values("Content-Security-Policy"); len(got) == 0 || got[0] != want {
249 t.Errorf("%s: CSP %q, want rastrillo's default %q", path, got, want)
250 }
251 }
252}
253
254// styleAttr is an inline style attribute in markup: the thing the
255// default CSP refuses.
256var styleAttr = regexp.MustCompile(`(?i)<[a-z][^>]*\sstyle\s*=`)
257
258// No page Memoria serves standalone carries an inline style attribute,
259// signed out or in — the rendered markup, estilo's and rastrillo's
260// partials included, not a grep of our own templates. This is what lets
261// TestStandaloneServesRastrillosDefaultCSP's policy stand; the browser
262// check (TestPagesRenderUnderTheProductionCSP) watches the scripts too.
263//
264// MUTATIONS (author-chosen, 2026-10-06; an independent pass is owed):
265// style="color:red" on layout.html's <main>, on the index's document-count
266// heading, and on signin.html's confirm form each fail here. Reverted.
267func TestNoPageCarriesAStyleAttribute(t *testing.T) {
268 check := func(h http.Handler, path string) {
269 t.Helper()
270 rec := httptest.NewRecorder()
271 h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, testOrigin+path, nil))
272 // The page itself, not a redirect away from it: a 303 has no
273 // markup to find a style in, and would pass by measuring nothing.
274 want := http.StatusOK
275 if path == "/no-such-page" {
276 want = http.StatusNotFound
277 }
278 if rec.Code != want {
279 t.Fatalf("GET %s = %d, want %d", path, rec.Code, want)
280 }
281 if m := styleAttr.FindString(rec.Body.String()); m != "" {
282 t.Errorf("%s (%d) carries an inline style, which rastrillo's CSP refuses: %s", path, rec.Code, html.UnescapeString(m))
283 }
284 }
285
286 out, _ := servedApp(t, tempDB(t), []string{chromeOwner})
287 for _, path := range []string{"/signin", "/signin?err=expired", "/signin?sent=1", "/signin?err=address", "/signin?reauth=1", "/signed-out", "/auth/verify?token=x", "/help/open-source"} {
288 check(out, path)
289 }
290
291 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
292 d, err := db.Open(tempDB(t), logger)
293 if err != nil {
294 t.Fatal(err)
295 }
296 t.Cleanup(func() { d.Close() })
297 mux, a, err := memoria.AppForTest(d, testOrigin, testDEK(t), logger)
298 if err != nil {
299 t.Fatal(err)
300 }
301 in := production(t, a.ServeOptions(rastrillo.Options{Logger: logger}, mux))
302 var buf bytes.Buffer
303 mw := multipart.NewWriter(&buf)
304 _ = mw.WriteField("title", "Office handbook")
305 _ = mw.WriteField("body", "The Lisbon office is at Rua Augusta 100.")
306 mw.Close()
307 req := httptest.NewRequest(http.MethodPost, testOrigin+"/documents", &buf)
308 req.Header.Set("Content-Type", mw.FormDataContentType())
309 req.Header.Set("Origin", testOrigin)
310 req.Header.Set("Sec-Fetch-Site", "same-origin")
311 rec := httptest.NewRecorder()
312 in.ServeHTTP(rec, req)
313 if rec.Code != http.StatusSeeOther {
314 t.Fatalf("store a document = %d", rec.Code)
315 }
316 index := httptest.NewRecorder()
317 in.ServeHTTP(index, httptest.NewRequest(http.MethodGet, testOrigin+"/", nil))
318 doc := regexp.MustCompile(`href="(/documents/\d+)"`).FindStringSubmatch(index.Body.String())
319 if doc == nil {
320 t.Fatal("the index links no document; the document pages below would be measured as 404s")
321 }
322 for _, path := range []string{"/", "/?q=Lisbon", "/add", "/add?stored=1", doc[1], doc[1] + "/delete", "/search?q=Lisbon", "/connectors", "/connectors/notion", "/help/open-source", "/no-such-page"} {
323 check(in, path)
324 }
325}
326