oficina / memoria Public

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

Plain git — no account needed to clone.

Download

Download this file

1package memoria
2
3import (
4 "bytes"
5 "context"
6 "html"
7 "io"
8 "log/slog"
9 "net/http"
10 "net/http/httptest"
11 "net/url"
12 "path/filepath"
13 "regexp"
14 "strings"
15 "testing"
16 "time"
17
18 "amadan.net/oficina/home/connect"
19 "amadan.net/oficina/memoria/internal/memoria/seal"
20 "amadan.net/oficina/suite"
21 "amadan.net/rastrillo/rastrillo/assertion"
22 assertioncrypto "amadan.net/rastrillo/rastrillo/crypto"
23 "amadan.net/rastrillo/rastrillo/db"
24)
25
26const homeTestOrigin = "https://memoria.example.test"
27const homeTestIssuer = "https://id.example.test"
28
29type homeTestMail struct{ body string }
30
31func (m *homeTestMail) Send(_ context.Context, _, _, body string) error { m.body = body; return nil }
32
33type homeRouterFixture struct {
34 app *app
35 handler http.Handler
36 key *assertioncrypto.Keypair
37 mail *homeTestMail
38 cookies map[string]*http.Cookie
39}
40
41func newHomeRouterFixture(t *testing.T) *homeRouterFixture {
42 t.Helper()
43 return newHomeRouterFixtureDir(t, "")
44}
45
46// newHomeRouterFixtureDir is the fixture with a hub directory origin
47// (HOME_DIRECTORY_ORIGIN), which is what puts the switcher in the bar.
48func newHomeRouterFixtureDir(t *testing.T, directory string) *homeRouterFixture {
49 t.Helper()
50 key, err := assertioncrypto.Generate()
51 if err != nil {
52 t.Fatal(err)
53 }
54 return newHomeRouterFixtureWith(t, key, Config{Origin: homeTestOrigin, Home: &connect.DecodedCredentials{Issuer: homeTestIssuer, ConsumerID: "memoria-test", Keys: map[string][]byte{"key": key.SignPub()}, DEK: bytes.Repeat([]byte{7}, 32), Directory: directory}})
55}
56
57// newHomeRouterFixtureWith wires Home from cfg, signed by key under the
58// key ID "key".
59func newHomeRouterFixtureWith(t *testing.T, key *assertioncrypto.Keypair, cfg Config) *homeRouterFixture {
60 t.Helper()
61 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
62 d, err := db.Open(filepath.Join(t.TempDir(), "memoria.db"), logger)
63 if err != nil {
64 t.Fatal(err)
65 }
66 t.Cleanup(func() { d.Close() })
67 cipher, err := seal.New(bytes.Repeat([]byte{6}, 32))
68 if err != nil {
69 t.Fatal(err)
70 }
71 mux, a, err := App(d, homeTestOrigin, cipher, logger)
72 if err != nil {
73 t.Fatal(err)
74 }
75 t.Cleanup(func() { a.store.ix.Close() })
76 a.SetOwners([]string{"alice@example.test"})
77 mail := &homeTestMail{}
78 auth, err := NewAuth(d.Writer(), AuthEnv{Origin: homeTestOrigin, InstanceKey: "isolated-test-instance-key", Mailer: mail, HomeEnabled: true}, logger, a)
79 if err != nil {
80 t.Fatal(err)
81 }
82 a.UseAuth(auth)
83 if err = a.WireHome(mux, cfg); err != nil {
84 t.Fatal(err)
85 }
86 a.MountAuth(mux, homeTestOrigin)
87 return &homeRouterFixture{a, mux, key, mail, map[string]*http.Cookie{}}
88}
89func (f *homeRouterFixture) do(method, path, body string) *httptest.ResponseRecorder {
90 r := httptest.NewRequest(method, homeTestOrigin+path, strings.NewReader(body))
91 for _, c := range f.cookies {
92 r.AddCookie(c)
93 }
94 if method == "POST" {
95 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
96 r.Header.Set("Origin", homeTestOrigin)
97 if path == "/auth/assertion" {
98 r.Header.Set("Origin", homeTestIssuer)
99 }
100 }
101 return f.send(r)
102}
103
104// send serves r with the fixture's cookies already on it, and keeps the
105// cookies the answer sets.
106func (f *homeRouterFixture) send(r *http.Request) *httptest.ResponseRecorder {
107 w := httptest.NewRecorder()
108 f.handler.ServeHTTP(w, r)
109 for _, c := range w.Result().Cookies() {
110 if c.MaxAge < 0 {
111 delete(f.cookies, c.Name)
112 } else {
113 f.cookies[c.Name] = c
114 }
115 }
116 return w
117}
118
119// confirmRe is the confirm door's form (templates/signin.html): the one
120// form on the page, posting the token in a hidden field.
121var confirmRe = regexp.MustCompile(`<form rst-form estilo-signin-form method="post" action="([^"]+)">\s*<input type="hidden" name="token" value="([^"]*)">`)
122
123// confirmForm reads the confirm door off a page and returns where its Sign
124// in button posts and the token it carries. It fails the test unless the
125// page is the door with no address field — the shape renderConfirm draws.
126func confirmForm(t *testing.T, page string) (action, token string) {
127 t.Helper()
128 m := confirmRe.FindStringSubmatch(page)
129 if m == nil {
130 t.Fatalf("the link did not open the confirm door:\n%s", page)
131 }
132 if strings.Contains(page, `name="address"`) {
133 t.Error("the confirm door has an address field; it is one Sign in button")
134 }
135 if !strings.Contains(page, ">Sign in</button>") {
136 t.Error("the confirm door has no Sign in button")
137 }
138 return html.UnescapeString(m[1]), html.UnescapeString(m[2])
139}
140
141// openLink follows an emailed sign-in link the way a person does since
142// rastrillo #64: the GET draws the confirm door and spends nothing, and
143// the door's Sign in button POSTs the token back — same-origin, as a
144// browser submitting the door's own form says it is. It returns the
145// POST's answer, which is what the GET used to answer.
146func (f *homeRouterFixture) openLink(t *testing.T, uri string) *httptest.ResponseRecorder {
147 t.Helper()
148 w := f.do("GET", uri, "")
149 if w.Code != http.StatusOK {
150 t.Fatalf("GET %s = %d, want the confirm door", uri, w.Code)
151 }
152 action, token := confirmForm(t, w.Body.String())
153 r := httptest.NewRequest("POST", homeTestOrigin+action, strings.NewReader(url.Values{"token": {token}}.Encode()))
154 for _, c := range f.cookies {
155 r.AddCookie(c)
156 }
157 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
158 r.Header.Set("Origin", homeTestOrigin)
159 r.Header.Set("Sec-Fetch-Site", "same-origin")
160 return f.send(r)
161}
162func homeRedirect(t *testing.T, w *httptest.ResponseRecorder) string {
163 t.Helper()
164 if w.Code != 303 {
165 t.Fatalf("redirect status %d: %s", w.Code, w.Body.String())
166 }
167 return w.Header().Get("Location")
168}
169func (f *homeRouterFixture) local(t *testing.T) { f.localAs(t, "alice@example.test") }
170func (f *homeRouterFixture) localAs(t *testing.T, address string) {
171 t.Helper()
172 homeRedirect(t, f.do("POST", "/auth/begin", "address="+url.QueryEscape(address)+"&force=1"))
173 link := regexp.MustCompile(`https://[^\s<>\"]+/auth/verify\?[^\s<>\"]+`).FindString(f.mail.body)
174 u, err := url.Parse(link)
175 if err != nil || link == "" {
176 t.Fatal("no captured local verification link")
177 }
178 landing := homeRedirect(t, f.openLink(t, u.RequestURI()))
179 if landing != "/auth/local/complete" {
180 t.Fatal("wrong local landing", landing)
181 }
182 homeRedirect(t, f.do("GET", landing, ""))
183}
184func (f *homeRouterFixture) exchange(t *testing.T, start, subject string) *httptest.ResponseRecorder {
185 t.Helper()
186 target := homeRedirect(t, f.do("GET", start, ""))
187 u, err := url.Parse(target)
188 if err != nil || u.Query().Get("consumer") != "memoria-test" {
189 t.Fatal("bad issuer redirect")
190 }
191 nonce := u.Query().Get("nonce")
192 now := time.Now().Unix()
193 jti, err := assertion.RandomID()
194 if err != nil {
195 t.Fatal(err)
196 }
197 token, err := assertion.Sign(f.key, assertion.Claims{Version: 1, Issuer: homeTestIssuer, Audience: homeTestOrigin, Subject: subject, KeyID: "key", ID: jti, Nonce: nonce, IssuedAt: now, ExpiresAt: now + 90, AuthTime: now})
198 if err != nil {
199 t.Fatal(err)
200 }
201 callback := f.do("POST", "/auth/assertion", url.Values{"assertion": {token}}.Encode())
202 finish := homeRedirect(t, callback)
203 return f.do("GET", finish, "")
204}
205func TestHomeRouterLinkKeepsContentAndSiblingAddress(t *testing.T) {
206 f := newHomeRouterFixture(t)
207 custody := &fixedCustody{apps: docsRoster(), give: suite.VISIBLE}
208 f.app.store.custody = custody
209 for _, spec := range []IngestSpec{{Source: "Upload", Key: "own", Title: "Own nebula", Body: "nebula local content"}, {Source: "Docs", Key: "sibling", Title: "Shared nebula", Body: "nebula sibling content", ItemID: "doc-one", SiblingRevision: 3}} {
210 if _, err := f.app.store.Ingest(t.Context(), spec); err != nil {
211 t.Fatal(err)
212 }
213 }
214 f.local(t)
215 localCookie := f.cookies[f.app.home.sessions.CookieName()].Value
216 before := f.do("GET", "/?q=nebula", "")
217 if before.Code != 200 || !strings.Contains(before.Body.String(), "Shared nebula") {
218 t.Fatal("local baseline failed", before.Code)
219 }
220 if w := f.do("GET", "/account/home", ""); w.Code != 200 {
221 t.Fatal("link page", w.Code, w.Body.String())
222 }
223 start := homeRedirect(t, f.do("POST", "/account/home", ""))
224 finish := f.exchange(t, start, "opaque-home-alice")
225 if homeRedirect(t, finish) != "/account/home/confirm" {
226 t.Fatal("wrong proof landing")
227 }
228 if f.cookies[f.app.home.sessions.CookieName()].Value != localCookie {
229 t.Fatal("proof replaced local session")
230 }
231 if w := f.do("GET", "/account/home/confirm", ""); w.Code != 200 || !strings.Contains(w.Body.String(), "alice@example.test") {
232 t.Fatal("confirmation", w.Code, w.Body.String())
233 }
234 homeRedirect(t, f.do("POST", "/account/home/confirm", ""))
235 // Sign out chains to id.* (home/connect 01166fc, shared chrome §9.5): a
236 // 307, so the hop stays a POST, to the issuer's /signout.
237 if w := f.do("POST", "/signout", ""); w.Code != http.StatusTemporaryRedirect || w.Header().Get("Location") != homeTestIssuer+"/signout" {
238 t.Fatal("signout did not chain to id.*", w.Code, w.Header().Get("Location"))
239 }
240 if f.cookies[f.app.home.sessions.CookieName()] != nil {
241 t.Fatal("signout retained app cookie")
242 }
243 start = homeRedirect(t, f.do("GET", "/signin?return_to=%2F%3Fq%3Dnebula", ""))
244 target := homeRedirect(t, f.exchange(t, start, "opaque-home-alice"))
245 after := f.do("GET", target, "")
246 if after.Code != 200 || !strings.Contains(after.Body.String(), "Shared nebula") || !strings.Contains(after.Body.String(), "Own nebula") {
247 t.Fatal("Home lost content", after.Code, after.Body.String())
248 }
249 if custody.asker.Kind != suite.SubjectAddress || custody.asker.Value != "alice@example.test" {
250 t.Fatal("Home changed S2 asker", custody.asker)
251 }
252 var docs, chunks int
253 f.app.db.Writer().QueryRow("SELECT COUNT(*) FROM documents").Scan(&docs)
254 f.app.db.Writer().QueryRow("SELECT COUNT(*) FROM chunks").Scan(&chunks)
255 if docs != 2 || chunks != 2 {
256 t.Fatal("link changed content", docs, chunks)
257 }
258 custody.give = suite.NOT_VISIBLE
259 denied := f.do("GET", "/?q=nebula", "")
260 if denied.Code != 200 || strings.Contains(denied.Body.String(), "nebula sibling content") || !strings.Contains(denied.Body.String(), "Own nebula") {
261 t.Fatal("Home bypassed sibling refusal", denied.Code)
262 }
263 enabled := f.app.home
264 f.app.home = nil
265 if w := f.do("GET", "/", ""); w.Code != 404 {
266 t.Fatal("Home session survived disabled integration", w.Code)
267 }
268 f.app.home = enabled
269 f.app.SetOwners([]string{"bob@example.test"})
270 // Removed: still not admitted, and now told so. The session is ended
271 // and a page request goes to the sign-in page's "removed" sentence,
272 // where it used to meet a 404 on every page until the session lapsed.
273 if w := f.do("GET", "/", ""); w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/signin?err=removed" {
274 t.Fatal("removed owner still admitted, or not told", w.Code, w.Header().Get("Location"))
275 }
276 if f.cookies[f.app.home.sessions.CookieName()] != nil {
277 t.Fatal("a removed owner kept their session")
278 }
279}
280func TestHomeRouterUnknownSubjectCannotAllocateOrRead(t *testing.T) {
281 f := newHomeRouterFixture(t)
282 start := homeRedirect(t, f.do("GET", "/signin", ""))
283 w := f.exchange(t, start, "opaque-unlinked-alice")
284 // "Oficina account", never "Home" (Paul's §13 answer 3; homeCopy).
285 if w.Code != 403 || !strings.Contains(w.Body.String(), "Oficina account not connected") {
286 t.Fatal("unknown subject", w.Code, w.Body.String())
287 }
288 var n int
289 if err := f.app.db.Writer().QueryRow("SELECT COUNT(*) FROM memoria_home_local_identities").Scan(&n); err != nil || n != 0 {
290 t.Fatal("Home allocated identity", n, err)
291 }
292 if f.cookies[f.app.home.sessions.CookieName()] != nil {
293 t.Fatal("Home minted session before linking")
294 }
295}
296
297func TestHomeRouterMutationsKeepCSRFAndCookieGuards(t *testing.T) {
298 f := newHomeRouterFixture(t)
299 f.local(t)
300 for _, path := range []string{"/account/home", "/account/home/confirm", "/auth/begin", "/signout"} {
301 req := httptest.NewRequest("POST", homeTestOrigin+path, strings.NewReader("address=alice%40example.test&force=1"))
302 req.Header.Set("Origin", "https://attacker.example")
303 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
304 for _, c := range f.cookies {
305 req.AddCookie(c)
306 }
307 rec := httptest.NewRecorder()
308 f.handler.ServeHTTP(rec, req)
309 if rec.Code != 403 {
310 t.Fatal("cross-origin mutation", path, rec.Code)
311 }
312 }
313 req := httptest.NewRequest("POST", homeTestOrigin+"/auth/assertion", strings.NewReader("assertion=invalid"))
314 req.Header.Set("Origin", "https://attacker.example")
315 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
316 rec := httptest.NewRecorder()
317 f.handler.ServeHTTP(rec, req)
318 if rec.Code != 400 {
319 t.Fatal("wrong issuer accepted", rec.Code)
320 }
321 req = httptest.NewRequest("GET", homeTestOrigin+"/account/home", nil)
322 c := f.cookies[f.app.home.sessions.CookieName()]
323 req.AddCookie(c)
324 req.AddCookie(c)
325 rec = httptest.NewRecorder()
326 f.handler.ServeHTTP(rec, req)
327 if rec.Code != 400 {
328 t.Fatal("duplicate app cookie accepted", rec.Code)
329 }
330 var n int
331 if err := f.app.db.Writer().QueryRow("SELECT COUNT(*) FROM memoria_home_local_identities").Scan(&n); err != nil || n != 0 {
332 t.Fatal("refused route allocated identity", n, err)
333 }
334}
335
336func TestHomeRouterMissingKeymailFreshnessCannotAllocateLink(t *testing.T) {
337 f := newHomeRouterFixture(t)
338 f.local(t)
339 if _, err := f.app.db.Writer().Exec("UPDATE sessions SET method='keymail',auth_time=''"); err != nil {
340 t.Fatal(err)
341 }
342 w := f.do("GET", "/account/home", "")
343 if homeRedirect(t, w) != "/signin/local" {
344 t.Fatal("missing Keymail freshness did not request local proof")
345 }
346 var n int
347 if err := f.app.db.Writer().QueryRow("SELECT COUNT(*) FROM memoria_home_local_identities").Scan(&n); err != nil || n != 0 {
348 t.Fatal("missing freshness allocated identity", n, err)
349 }
350}
351func TestHomeSigninClearsOnlyDefinitivelyStaleSession(t *testing.T) {
352 f := newHomeRouterFixture(t)
353 f.local(t)
354 cookieName := f.app.home.sessions.CookieName()
355 saved := f.cookies[cookieName].Value
356 if _, err := f.app.db.Writer().Exec("ALTER TABLE sessions RENAME TO unavailable_sessions"); err != nil {
357 t.Fatal(err)
358 }
359 homeRedirect(t, f.do("GET", "/signin", ""))
360 if f.cookies[cookieName] == nil || f.cookies[cookieName].Value != saved {
361 t.Fatal("temporary storage failure cleared valid cookie")
362 }
363 if _, err := f.app.db.Writer().Exec("ALTER TABLE unavailable_sessions RENAME TO sessions"); err != nil {
364 t.Fatal(err)
365 }
366 if _, err := f.app.db.Writer().Exec("DELETE FROM sessions"); err != nil {
367 t.Fatal(err)
368 }
369 start := homeRedirect(t, f.do("GET", "/signin", ""))
370 if f.cookies[cookieName] != nil {
371 t.Fatal("stale cookie not cleared")
372 }
373 target := homeRedirect(t, f.do("GET", start, ""))
374 if !strings.HasPrefix(target, homeTestIssuer+"/") {
375 t.Fatal("stale cookie prevented Home sign-in", target)
376 }
377}
378
379// The footer's egress claim on the pages that exist only when Home is
380// configured. TestTheFooterStatesTheModelSwitch (memoriatest) walks every
381// declared route, but its harness does not configure Home, so these 404
382// there — this is where they are checked. They render through renderHome,
383// the Render callback both the Home routes and its Bridge are given.
384//
385// **Every route explicitly, at whatever status it renders.** The first
386// version accepted "at least one page made a claim" and checked only
387// 200s, so /account/home/confirm — which renders its failure page at 400 —
388// was never looked at, and removing ModelOn from renderHome left the
389// suite green until the next commit added this. A review found both.
390func TestHomePagesStateTheModelSwitch(t *testing.T) {
391 // Each route with the extra cookies that make it render. The four
392 // /auth/ routes are FAILURE pages — a duplicate or malformed cookie, or
393 // missing state — and a review found all four render the shared footer
394 // at 400 while this test did not look at them: making renderHome drop
395 // ModelOn only for /auth/ paths left the whole suite green. The
396 // cookie conditions are the Home library's own refusals
397 // (connect.homeCookie, Routes.LocalGuard, Bridge.Finish).
398 type visit struct {
399 path string
400 extra []*http.Cookie
401 }
402 dup := func(name string) []*http.Cookie {
403 v := strings.Repeat("A", 43)
404 return []*http.Cookie{{Name: name, Value: v}, {Name: name, Value: v}}
405 }
406 visits := []visit{
407 {path: "/account/home"},
408 {path: "/account/home/confirm"},
409 {path: "/auth/start", extra: dup("__Host-oficina-home-init")},
410 {path: "/auth/finish"},
411 {path: "/auth/callback", extra: dup("__Host-rastrillo_pending")},
412 {path: "/auth/verify", extra: dup("__Host-rastrillo_pending")},
413 }
414 // The sign-in pages carry no footer at all (shared chrome §9.2 retires
415 // the claim there): the claim is about searching, said only to somebody
416 // signed in. Checked as an absence, so a footer creeping back onto them
417 // is seen, not skipped.
418 signinPages := []string{"/signed-out", "/signin/local"}
419 for _, on := range []bool{true, false} {
420 f := newHomeRouterFixture(t)
421 if on {
422 f.app.store.extractor = &recorder{}
423 }
424 f.local(t)
425 for _, path := range signinPages {
426 w := f.do("GET", path, "")
427 if w.Code != 200 || strings.Contains(w.Body.String(), "<footer") || strings.Contains(w.Body.String(), "leaves this instance") {
428 t.Errorf("AI on=%v, %s (%d): a sign-in page carries the footer's claim", on, path, w.Code)
429 }
430 }
431 for _, v := range visits {
432 path := v.path
433 r := httptest.NewRequest("GET", homeTestOrigin+path, nil)
434 for _, c := range f.cookies {
435 r.AddCookie(c)
436 }
437 for _, c := range v.extra {
438 r.AddCookie(c)
439 }
440 w := httptest.NewRecorder()
441 f.handler.ServeHTTP(w, r)
442 b := w.Body.String()
443 i := strings.Index(b, "<footer")
444 if i < 0 {
445 t.Errorf("AI on=%v, %s (%d): no footer, so this route is unchecked", on, path, w.Code)
446 continue
447 }
448 foot := strings.Join(strings.Fields(b[i:]), " ")
449 if !strings.Contains(foot, "leaves this instance") {
450 t.Errorf("AI on=%v, %s (%d): the footer makes no egress claim", on, path, w.Code)
451 continue
452 }
453 if on && (strings.Contains(foot, "no document ever") || !strings.Contains(foot, "TensorX")) {
454 t.Errorf("AI on, %s (%d): footer does not disclose TensorX", path, w.Code)
455 }
456 if !on && (!strings.Contains(foot, "no document ever") || strings.Contains(foot, "TensorX")) {
457 t.Errorf("AI off, %s (%d): footer does not make the true claim", path, w.Code)
458 }
459 }
460 }
461}
462
463// Where sign-out lands is the door, like the sign-in page beside it:
464// Memoria's card on the sky, with the suite's signed-out words (spec §9.2)
465// rather than Home's "You can sign in again with Home", and a plain Sign
466// in back to /signin. The same page standalone and with Home.
467func TestSignedOutIsTheDoor(t *testing.T) {
468 f := newHomeRouterFixture(t)
469 w := f.do("GET", "/signed-out", "")
470 if w.Code != http.StatusOK || w.Header().Get("Cache-Control") != "no-store" {
471 t.Fatalf("/signed-out: %d, Cache-Control %q", w.Code, w.Header().Get("Cache-Control"))
472 }
473 b := html.UnescapeString(w.Body.String())
474 for _, want := range []string{
475 "<div estilo-door", "estilo-sky-layer", "<section estilo-signin", "<span>Memoria</span>",
476 "/static/chrome.", "/static/sky.", "<title>Signed out · Memoria</title>",
477 "<h1>You’re signed out</h1>", "You’ve signed out of Memoria on this device.", `href="/signin"`, ">Sign in<",
478 } {
479 if !strings.Contains(b, want) {
480 t.Errorf("/signed-out lacks %q", want)
481 }
482 }
483 // Home is never named to people (Paul, 2026-09-23).
484 if strings.Contains(b, "Home") {
485 t.Errorf("/signed-out names Home:\n%s", b)
486 }
487}
488