package memoriatest import ( "encoding/base64" "errors" "fmt" "go/ast" "go/parser" "go/token" "net/http" "os" "path/filepath" "regexp" "strings" "testing" memoria "amadan.net/oficina/memoria/internal/memoria" ) // canary is text that must never appear in bytes at rest. It is a phrase // no library or schema would produce, so a hit is our leak and not a // coincidence. const canary = "ZEBRAFINCH-QUARTERLY-REVENUE-8834" // Gate 1. The one rule: the database file never holds a readable // passage. // // This reads the raw bytes rather than querying, because a query would // go through the code that decrypts and would pass no matter what is on // disk. The WAL is read too: SQLite writes there first, and a gate that // checks only the main file passes for as long as a checkpoint has not // happened — which in a short test is always. // // MUTATION-TESTED 2026-09-03: replacing s.sealer.Seal with a plain // []byte(...) cast in Store.Ingest makes this fail on both the chunk and // the title, naming the file it found the canary in. Reverted. func TestPlaintextNeverReachesTheDatabase(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "app.db") h, _ := newAppAt(t, path) body := "# " + canary + "\n\nJune revenue was 1,350,000 and " + canary + " again in the body.\n" rec := store(t, h, canary+" as a title", body) // The vacuity guard, and it has to be this specific. An earlier // version checked `rec.Code != 303` and then searched for the string // "result" — but the handler redirects with 303 on FAILURE too // (/?err=empty, /?stored=0&refused=1), and "result" is a substring // of "0 results". With // Ingest mutated to always error, that version PASSED against an // empty database: the gate below was scanning a file with nothing in // it. Assert the success location and real result markup. if rec.Code != 303 { t.Fatalf("store: status %d, want 303", rec.Code) } if loc := rec.Header().Get("Location"); loc != "/add?stored=1" { t.Fatalf("store redirected to %q, want /add?stored=1 — the document was not stored, "+ "so the scan below would pass vacuously", loc) } if got := get(t, h, "/?q=zebrafinch").Body.String(); !strings.Contains(got, "") { t.Fatal("no highlighted result: the document was not indexed, and the scan below proves nothing") } entries, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } checked := 0 for _, e := range entries { if e.IsDir() { continue } raw, err := os.ReadFile(filepath.Join(dir, e.Name())) if err != nil { t.Fatal(err) } checked++ if bytesContainsFold(raw, canary) { t.Errorf("%s holds the canary in plaintext: the one rule is broken", e.Name()) } } if checked == 0 { t.Fatal("no files to check; the gate proved nothing") } } // Gate 1's other half: no plaintext reaches a TEMP file either. // // ParseMultipartForm's argument is a MEMORY threshold, and Go spends it // cumulatively across parts — it holds file parts in memory until the // running total passes the threshold and writes every part after that to // a temp file, in the clear, with no error raised anywhere. One 2 MiB // file could not reach that with the old single-file cap. Twenty-five // can, trivially, which is why this gate exists now and did not before. // // It works because httptest calls the handler directly: net/http's own // server calls MultipartForm.RemoveAll() when the request finishes, so // in production a spilled file is deleted — after having been written. // Here nothing deletes it, so a spill is still on disk to be found. // // MUTATION-TESTED 2026-09-04: ParseMultipartForm's argument changed back // to maxUpload — the per-file cap, which is what it was before // multi-file — makes this fail naming the spilled file (the suffix is // random, e.g. "multipart-578803712 in the temp directory holds the // canary in plaintext: Go spilled a part to disk") and again with // "1 files were left in the temp directory". Reverted. func TestAMultiFileDropNeverSpillsPlaintextToATempFile(t *testing.T) { // The app first, so its database does not land in the directory // being scanned: TMPDIR is what os.CreateTemp and t.TempDir both // read, and the point here is to watch one specific directory. h := newApp(t) spill := t.TempDir() t.Setenv("TMPDIR", spill) // Twelve parts of half a MiB. Under any per-file threshold this // passes the budget several times over, because the budget is spent // across parts and not per part. body := strings.Repeat(canary+" and more revenue text to pad this passage out.\n\n", 8000) files := make([]file, 0, 12) for i := 0; i < 12; i++ { files = append(files, file{fmt.Sprintf("spill-%02d.txt", i), body}) } rec := submit(t, h, "", "", files...) // The vacuity guard. A submission that was refused would leave the // directory empty and this gate would pass having watched nothing. if loc := rec.Header().Get("Location"); loc != "/add?stored=12" { t.Fatalf("the drop answered %q rather than storing all 12; the scan below would "+ "prove nothing", loc) } found := 0 err := filepath.Walk(spill, func(p string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return err } found++ raw, err := os.ReadFile(p) if err != nil { return err } if bytesContainsFold(raw, canary) { t.Errorf("%s in the temp directory holds the canary in plaintext: Go spilled a "+ "part to disk. ParseMultipartForm's argument is a memory threshold, not a "+ "limit, and it is spent across parts — it must be at least the whole body "+ "cap or a large drop writes somebody's documents to /tmp in the clear.", filepath.Base(p)) } return nil }) if err != nil { t.Fatal(err) } if found > 0 { t.Errorf("%d files were left in the temp directory by a drop that should have been "+ "parsed entirely in memory", found) } } // Gate 15's egress half. With the assistant off, nothing leaves the // instance — and in slice 0 there is no assistant at all, so the honest // form of this gate is that no app package can make an outbound request. // // A runtime gate would be better and is not available: httptest cannot // observe a dial that never happens. So this is a source gate, and it is // mutation-tested by adding an http.Get to the store, which makes it // fail. // // MUTATION-TESTED 2026-09-03: adding `http.Get("https://example.com")` // to Store.Ingest makes this fail naming store.go. Reverted. // // AMENDED 2026-09-05 for the Notion connector, which must dial // api.notion.com. The blanket ban survives; one directory is exempt and // pays for the exemption with a runtime host pin. See allowedNetworkDirs. // // AMENDED AGAIN 2026-09-06 for slice 1's sibling client. Same shape, same // price: the package is exempt from the grep and pays for it with a // runtime guard the grep cannot perform. Two entries now, and the comment // on the list says why a third should be harder. // // MUTATION-TESTED 2026-09-05: adding an `*http.Client` field to // internal/memoria/handlers.go still makes this fail, so narrowing the // gate did not blunt it. Reverted. // MUTATION-TESTED 2026-09-06: adding a third directory to // allowedNetworkDirs fails the count assertion; adding an // `http.NewRequest` to handlers.go still fails the blacklist. Reverted. func TestNoOutboundNetworkInAppCode(t *testing.T) { // A blacklist, and it is worth saying so: this is a source // heuristic, not a proof. A review defeated the first version with // `new(http.Client).Post(...)`, which none of six spellings matched. // The list is broader now and the walk covers cmd/ and tools/ too, // but the honest statement of what it buys is "an accidental // outbound call is caught"; a determined one is not. forbidden := []string{ `http\.Get\(`, `http\.Post\(`, `http\.PostForm\(`, `http\.Head\(`, `http\.Client\{`, `new\(http\.Client\)`, `\*http\.Client`, `http\.DefaultClient`, `http\.DefaultTransport`, `http\.NewRequest`, `net\.Dial`, `net\.Lookup`, `smtp\.`, `os/exec`, `exec\.Command`, `url\.Parse\(.*https?://`, } pats := make([]*regexp.Regexp, 0, len(forbidden)) for _, f := range forbidden { pats = append(pats, regexp.MustCompile(f)) } // EXACTLY THREE packages may reach the network, and this is the list. // // It is a list rather than a deleted gate because the connector had // to dial api.notion.com and the cheapest way to make this test pass // would have been to widen it until it named nothing. The exemption // is paid for by a RUNTIME guard that is stronger than the grep it // replaces here: notion.pinnedTransport refuses every host but the // one it was built for, and the client refuses redirects outright, // both asserted by notion.TestClientDialsOnlyNotion. A source grep // cannot tell an http.Client pointed at Notion from one pointed // anywhere else; that test can. // // A SECOND entry was added on 2026-09-06, and it was meant to be hard // to justify. Slice 1 gives memoria a sibling client, and a sibling // call is outbound by definition — decision 1's whole point is that a // team's instances are one trust domain, so this is egress the design // requires rather than egress that crept in. // // It is paid for the same way Notion's is, by a runtime guard the grep // could not perform: suite.NewSiblingClient refuses any host outside // memoria's own label and refuses redirects outright, both // before the transport dials, and sibling.TestClientRefusesHostsOutsideTheTeam // asserts it against the real constructor. A source grep cannot tell // an http.Client bound to one team from a bare one; that test can. // // A third entry should be harder still. Two packages that may dial is // two places to read when §3.2's leak list is next revised. // // **The third entry, 2026-09-27: the model provider.** Harder to // justify than either before it, because what it sends is the team's // passages to a party outside the suite — §3.2's largest leak class, // not a sibling inside the trust domain. It is admitted on three // conditions, and each is a thing a reviewer can check rather than a // sentence to take on trust: // // - Paul ruled the egress in (decision 15 as amended, §8.9) under // decision 4's terms: per-team opt-in, one named provider, a working // floor when it is off. memoria.TestTheModelSwitch holds the opt-in. // - The same runtime guard the other two carry: provider.pinnedTransport // refuses every host and scheme but TensorX's and every redirect, // before dialling, asserted against the real constructor by // provider.TestClientDialsOnlyTensorX with requests counted at the // listener. Mutation-tested 2026-09-27: passing everything through, // dropping the scheme check, and following redirects each go red. // - Nothing leaves when AI is off, asserted as an absence of traffic // WITH its presence half (gate 39). // // **A fourth entry should need a spec section of its own.** Three is // three places to read when §3.2 is next revised, and the third is the // one that sends content outside the suite. allowedNetworkDirs := []string{ filepath.Join("..", "memoria", "notion"), filepath.Join("..", "memoria", "sibling"), filepath.Join("..", "memoria", "provider"), } if len(allowedNetworkDirs) != 3 { t.Fatalf("%d packages may reach the network, want exactly 3: %v", len(allowedNetworkDirs), allowedNetworkDirs) } exempt := func(p string) bool { for _, d := range allowedNetworkDirs { if strings.HasPrefix(filepath.Clean(p), filepath.Clean(d)+string(filepath.Separator)) { return true } } return false } var hits []string scanned := 0 walk := func(root string) error { return filepath.Walk(root, func(p string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() || !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") { return nil } if exempt(p) { return nil } src, err := os.ReadFile(p) if err != nil { return err } scanned++ for _, pat := range pats { if pat.Match(src) { hits = append(hits, p+" matches "+pat.String()) } } return nil }) } for _, root := range []string{ filepath.Join("..", "memoria"), filepath.Join("..", "..", "cmd"), } { if err := walk(root); err != nil { t.Fatal(err) } } // Vacuity guard. An exempt() that matched everything, or a walk root // that moved, would leave this gate scanning nothing and passing — // which is how a gate stops guarding without anyone noticing. if scanned < 5 { t.Fatalf("only %d files scanned; the gate is not looking at the app", scanned) } for _, h := range hits { t.Errorf("app code can reach the network: %s", h) } } // The footer says no DOCUMENT leaves this instance, and that is the claim // this file can support. It deliberately no longer says "nothing leaves", // because that was false: rastrillo's sign-in classifier fetches // https:///.well-known/keymail on POST /auth/begin, // BEFORE the admission gate — so an anonymous visitor makes the instance // dial any domain they type. Found by a code review of this branch. // // This gate pins the copy, so nobody restores the stronger sentence // without also removing the dial. // // **Since §8.9 the sentence is conditional**: it is the AI-OFF branch of // the footer, and with AI on the footer names TensorX instead. This gate // reads the template source, so it pins the wording of both branches; // TestTheFooterStatesTheModelSwitch renders every declared page both ways // and is what checks the right branch is chosen. func TestFooterDoesNotOverclaim(t *testing.T) { raw, err := os.ReadFile(filepath.Join("..", "memoria", "templates", "layout.html")) if err != nil { t.Fatal(err) } src := string(raw) if strings.Contains(src, "nothing leaves this instance") { t.Error("the footer claims nothing leaves this instance; sign-in dials the " + "address's domain before anyone is admitted, so that is false") } if !strings.Contains(src, "no document ever") { t.Error("the footer no longer makes the claim this app can actually support") } } // Gate 4, absent until now: no content in any log line. §11 said slice 0 // ships it. It did not, which is how a mailer that logs the magic link // went unnoticed. // // A FILENAME is content too, and that is the half multi-file upload // makes easy to get wrong: a refusal now names the file it refused, and // the obvious next line to write is that same name into a log. So the // secret is posted as a filename as well — once on a file that stores // and once on a file that is refused, because the refusal path is where // a filename is nearest to hand. // // The asynchronous path is exercised too, and for a sharper reason than // symmetry. It is the path where a filename is nearest to hand — a // request that answers per-file is the natural place to log which file it // answered about — and it is the one path whose RESPONSE body is allowed // to carry a filename, which makes it easy to forget that the log beside // it is not. So the same secret goes up over that path as well, on a file // that stores and a file that is refused. // // The store-failure line is reached DELIBERATELY, and the reason is the // whole of this comment's history. This gate used to claim, in a // MUTATION-TESTED note, that adding `"file", name` to createDocument's // ingest-failure log line made it go red. A code review replayed that // mutation and it stayed GREEN: nothing here made Store.Ingest fail — // the secret-named .png files are refused in readUpload well before that // line, and the .txt files store cleanly — so the line under test never // executed and the claim was false. That is exactly the shape AGENTS.md // opens with, written on the guard for the log line nearest a filename. // The database is now closed under the running app so that the line runs, // and its presence is asserted before its contents are, because a gate // that checks for an absence has to prove first that the thing whose // absence it checks was there to be found. // // MUTATION-TESTED 2026-09-04, each reverted, each observed red: // // (a) `"file", name` added to createDocument's ingest-failure log line // → "a log line carries document content, a filename or the query", // printing the buffer with WOMBAT…-fails.txt in it. This is the // mutation the previous version of this comment claimed and did not // have. // (b) Store.Ingest's post-transaction error return wrapped as // `fmt.Errorf("ingest %q: %w", title, err)` — the plausible "which // document was it?" edit, and the one that puts content in an ERROR // STRING rather than a log call → the same failure, printing // err="ingest \"WOMBAT…-fails\": sql: database is closed". // (c) `a.logger.Info("search", "q", q)` in the index handler // → the same failure, printing q=WOMBAT… for both searches. // (d) the vacuity guard's own subject: removing `d.Close()` → "the // ingest-failure log line never ran", which is the state this gate // was silently in before. func TestNoContentInLogs(t *testing.T) { h, logs, d := newAppWithLog(t) const secret = "WOMBATCONFIDENTIAL9931" store(t, h, secret+" title", "## "+secret+"\n\nA passage about "+secret+" and revenue.") submit(t, h, "", "", file{secret + "-good.txt", "A passage about hosting costs and the migration."}, file{secret + "-refused.png", "\x00\x00 binary payload"}, ) // The same again, asynchronously. One file per request, as the tray // sends them. submitJSON(t, h, file{secret + "-async.txt", "A passage about the migration date."}) submitJSON(t, h, file{secret + "-async.png", "\x00\x00 binary payload"}) get(t, h, "/?q="+secret) get(t, h, "/?q=revenue") get(t, h, "/search?q="+secret) get(t, h, "/search?q=revenue") // Now a file whose store genuinely fails. Last, because it takes the // database away from everything above. d.Close() reply := readReply(t, submitJSON(t, h, file{secret + "-fails.txt", "A passage about the migration date."})) if reply.Stored != 0 || len(reply.Refused) != 1 || !strings.Contains(reply.Refused[0], secret+"-fails.txt") { t.Fatalf("the store did not fail, so the log line this gate is aimed at never ran and "+ "everything below it proves nothing: stored=%d refused=%v", reply.Stored, reply.Refused) } got := logs.String() if !strings.Contains(got, "ingest failed") { t.Fatalf("nothing in the log says a store failed, so the line that names a file was "+ "never written and the check below is vacuous:\n%s", got) } if strings.Contains(got, secret) { t.Errorf("a log line carries document content, a filename or the query:\n%s", got) } for _, bad := range []string{"sign-in link", "auth/verify?"} { if strings.Contains(got, bad) { t.Errorf("a log line carries a credential (%q):\n%s", bad, got) } } } // requireSession fails closed. An app built without auth must refuse // every route rather than serve it. // // MUTATION-TESTED 2026-09-04: restoring the nil-auth pass-through makes // this fail. Reverted. func TestUnauthenticatedAppRefuses(t *testing.T) { h := newProductionApp(t) // /search is on this list because it is the newest way to read the // corpus and the easiest one to mount outside the group by accident: // it is not a page, so nothing about it looks like a door. for _, target := range []string{"/", "/documents/1", "/?q=revenue", "/search?q=revenue"} { rec := get(t, h, target) if rec.Code == http.StatusOK { t.Errorf("GET %s served 200 with no sign-in configured; requireSession fails open", target) } } } // The test seam must stay in tests. Production wiring may not reach // AppForTest, and this is the gate that the previous version of this file // only claimed to have. func TestProductionDoesNotUseTheTestConstructor(t *testing.T) { err := filepath.Walk(filepath.Join("..", "..", "cmd"), func(p string, info os.FileInfo, err error) error { if err != nil || info.IsDir() || !strings.HasSuffix(p, ".go") { return err } raw, err := os.ReadFile(p) if err != nil { return err } // Every seam, not just the constructor. App() hands cmd/memoria // the same *app that carries these, so `app.AsPerson("x")` in // main would compile, make every request an owner, and pass a // gate that greps for AppForTest alone. Found by a code review. for _, seam := range []string{ "AppForTest", "AsPerson", "UseFakeSource", "WaitForSync", // SeedTallyForDrive writes breaker counters into the past. // The breaker's whole claim is that they record what // happened when it happened. "SeedTallyForDrive", // sibling.NewForTest builds a plain http.Client with no team // binding. In production that is §6.4's cross-team refusal // removed — the guard that runs before the transport dials — // and it would compile and serve perfectly well. "NewForTest", // UseFakeModel switches the model layer on without MEMORIA_AI: // in main it bypasses the owner's opt-in, and handed a real // client it sends content the owner never agreed to send. "UseFakeModel", } { if strings.Contains(string(raw), seam) { t.Errorf("%s calls the test seam %s; production must not reach it", p, seam) } } if !strings.Contains(string(raw), "memoria.App(") { return nil } // Every setter the app needs and cannot supply itself. // // UseAuth's omission serves every route unauthenticated, which is // the failure this gate was written for. The other two fail in the // other direction and are here anyway, because a failure that is // closed is not thereby a failure that is noticed: // // LoadSiblings omitted -> a roster is never read, so a // misconfigured one is never refused at boot and the instance // runs with a permanently short page instead. // UseSiblings omitted -> custody stays nil, every sibling // passage withholds, and the notice blames apps that are fine. // // Both present a working instance that quietly serves less than it // holds, and neither shows up in a test that only asks whether the // app starts. // // **Checked as calls, not as text.** A grep for the name passes on // `_ = memoria.LoadSiblings` — a mention, not a call — and a // review's mutation did exactly that and stayed green. The seam // scan above can stay a grep, because there the string appearing // at all is the problem; here the problem is the call NOT // happening, and only the parse can tell those apart. // **This proves the call is SPELLED, not that it is reached**, and // saying so is the point. A review wrapped `app.UseAuth(a)` in a // closure nobody invoked and this stayed green; so did putting // the sibling wiring behind `if len(apps) > 1`. An AST walk over // one file cannot see control flow, and a smarter walk is a race // against however the next person writes it. // // What makes that survivable is that there is now almost nothing // in main to get wrong: the sibling wiring is one call to // `memoria.WireSiblings`, which has its own tests, and this gate // only has to notice the call disappearing. The names below are // the ones whose absence is silent — UseAuth serves every route // unauthenticated, WireSiblings leaves every sibling passage // withheld while blaming apps that are fine, and WireModel leaves // MEMORIA_AI=on doing nothing, with nothing to say so. for _, call := range []string{"UseAuth", "WireSiblings", "WireModel"} { if !callsFunction(t, p, raw, call) { t.Errorf("%s builds the app but never calls %s", p, call) } } return nil }) if err != nil { t.Fatal(err) } } // callsFunction reports whether the file actually CALLS name, as opposed // to merely mentioning it. // // The distinction is the whole point: `memoria.LoadSiblings(os.Getenv)` is // a call, `_ = memoria.LoadSiblings` is a reference that does nothing, and // a grep cannot tell them apart. It matches either a bare `name(...)` or a // selector `x.name(...)`, since the wiring here is a mix of package // functions and methods on the app. func callsFunction(t *testing.T, path string, src []byte, name string) bool { t.Helper() f, err := parser.ParseFile(token.NewFileSet(), path, src, 0) if err != nil { t.Fatalf("parse %s: %v", path, err) } found := false ast.Inspect(f, func(n ast.Node) bool { call, ok := n.(*ast.CallExpr) if !ok { return true } switch fn := call.Fun.(type) { case *ast.Ident: if fn.Name == name { found = true } case *ast.SelectorExpr: if fn.Sel.Name == name { found = true } } return true }) return found } // Every colour in the app's own stylesheet is a token, so the reader's // light/dark choice resolves through theme.css. A literal here paints a // passage against the wrong ground in one theme and nobody notices until // a screenshot. // // MUTATION-TESTED 2026-09-03: adding `color: #fff` to memoria.css makes // this fail naming the line. Reverted. func TestNoColourLiteralsInAppCSS(t *testing.T) { raw, err := os.ReadFile(filepath.Join("..", "memoria", "static", "memoria.css")) if err != nil { t.Fatal(err) } // Hex colours, rgb()/hsl()/oklch() and the named colours most likely // to be typed by hand. `var(--rst-...)` is the only sanctioned way. bad := regexp.MustCompile(`(?i)(#[0-9a-f]{3,8}\b|\brgba?\(|\bhsla?\(|\boklch\(|:\s*(white|black|red|blue|green|grey|gray)\b)`) for i, line := range strings.Split(string(raw), "\n") { if strings.HasPrefix(strings.TrimSpace(line), "*") || strings.HasPrefix(strings.TrimSpace(line), "/*") { continue } if m := bad.FindString(line); m != "" { t.Errorf("memoria.css:%d has the colour literal %q; use a token", i+1, m) } } } // Every --rst-* token memoria.css reads is either defined by the // stylesheets it ships with, or reached through var()'s fallback. // // This is the gate for a whole bug class, not for the one bug that // prompted it. An undefined custom property does not fall back to // anything: the declaration containing it is invalid at computed-value // time, so the RULE VANISHES rather than the colour going wrong. Nothing // warns, nothing looks broken in the markup, and the page simply renders // with body text where a muted grey was meant to be. That is what // `--rst-text-mut` (no D) did to the result trail, and it is the same // shape as the missing theme.css link AGENTS.md already records. // // A fallback is allowed and is not a smell: `var(--rst-font-mono, // ui-monospace, monospace)` is deliberate — rastrillo defines no mono // token and the fallback stack is the whole point. A misspelling with a // fallback degrades to the fallback; a misspelling without one deletes // the rule. Only the second is a bug, and only the second fails here. // // MUTATION-TESTED 2026-09-04, each reverted: // // (a) `color: var(--rst-text-mut)` restored on [rst-result-trail] — // the original bug → "memoria.css:121 reads var(--rst-text-mut), // which nothing defines and which has no fallback" // (b) `background: var(--rst-nonesuch)` added → the same failure at // the new line // (c) the same reference WITH a fallback, `var(--rst-nonesuch, // var(--rst-bg))` → stays green, which is the distinction this // gate is drawing func TestEveryTokenReferencedHereIsDefinedOrHasAFallback(t *testing.T) { static := filepath.Join("..", "memoria", "static") // Where tokens come from. memoria.css itself is included: a page // may define its own token as long as it defines it somewhere. defined := map[string]bool{} def := regexp.MustCompile(`(?:^|[;{,\s])(--rst-[a-zA-Z0-9_-]+)\s*:`) for _, name := range []string{"theme.css", "tokens.css", "memoria.css"} { raw, err := os.ReadFile(filepath.Join(static, name)) if err != nil { t.Fatalf("reading %s: %v", name, err) } for _, m := range def.FindAllStringSubmatch(string(raw), -1) { defined[m[1]] = true } } // Vacuity guard, and it has to be here: if the definition regexp // stopped matching, `defined` would be empty and every reference // below would be reported — loudly wrong rather than quietly wrong, // but still not this gate doing its job. theme.css alone carries // well over twenty tokens. if len(defined) < 20 { t.Fatalf("only %d tokens were found across theme.css, tokens.css and memoria.css; "+ "the definition pattern has stopped matching and this gate is measuring "+ "nothing", len(defined)) } for _, must := range []string{"--rst-text-muted", "--rst-line", "--rst-bg"} { if !defined[must] { t.Fatalf("%s was not found among the defined tokens; the definition pattern is "+ "missing real definitions and this gate would report false failures", must) } } raw, err := os.ReadFile(filepath.Join(static, "memoria.css")) if err != nil { t.Fatal(err) } // The character after the token name is the whole test: `)` means // "no fallback, this must resolve", `,` means a fallback follows. // Nested var()s in a fallback are separate matches and are checked // on their own terms. use := regexp.MustCompile(`var\(\s*(--rst-[a-zA-Z0-9_-]+)\s*([,)])`) seen := 0 for i, line := range strings.Split(string(raw), "\n") { for _, m := range use.FindAllStringSubmatch(line, -1) { seen++ if m[2] == "," { continue // a fallback carries it } if !defined[m[1]] { t.Errorf("memoria.css:%d reads var(%s), which nothing defines and which has "+ "no fallback. The declaration is then invalid at computed-value time — "+ "the whole rule disappears, silently, and the element renders as though "+ "the line had never been written.", i+1, m[1]) } } } if seen < 5 { t.Fatalf("only %d token references were found in memoria.css; the reference pattern "+ "has stopped matching and this gate proves nothing", seen) } } // requireSession falls through when auth is nil, which is what makes // these tests possible. That seam is only safe because the app refuses to // start without sign-in configured, so this pins the refusals. // // An earlier version of this gate grepped main.go for os.Exit near each // variable name. It PASSED when the DEK refusal was replaced with a // generated fallback, because the base64 error path below it supplied an // os.Exit within the search window. That is why the validation now lives // in a function and this tests its behaviour instead of its text. // // MUTATION-TESTED 2026-09-03: making any one of the three fields default // instead of erroring makes the matching subtest fail. Reverted. func TestConfigRefusesIncompleteEnvironment(t *testing.T) { full := map[string]string{ "MEMORIA_DEK": base64.StdEncoding.EncodeToString(make([]byte, 32)), "MEMORIA_INSTANCE_KEY": "instance-key", "MEMORIA_OWNERS": "paul@example.com", "MEMORIA_ORIGIN": "https://memoria.example.com", "CARLOS_SMTP_HOST": "smtp.example.com", "CARLOS_SMTP_FROM": "noreply@example.com", } // The complete environment must load, or every subtest below passes // for the wrong reason. if _, err := memoria.LoadConfig(envFrom(full)); err != nil { t.Fatalf("a complete environment did not load: %v", err) } for _, missing := range []string{"MEMORIA_DEK", "MEMORIA_INSTANCE_KEY", "MEMORIA_OWNERS", "CARLOS_SMTP_HOST"} { t.Run("without "+missing, func(t *testing.T) { env := map[string]string{} for k, v := range full { if k != missing { env[k] = v } } _, err := memoria.LoadConfig(envFrom(env)) if err == nil { t.Fatalf("LoadConfig succeeded without %s; the app would start "+ "with no %s and the nil-auth pass-through becomes reachable", missing, missing) } if !errors.Is(err, memoria.ErrMissing) { t.Errorf("error for missing %s is %v, want ErrMissing", missing, err) } if !strings.Contains(err.Error(), missing) { t.Errorf("error for missing %s does not name it: %v", missing, err) } }) } } // A DEK of the wrong length must be refused rather than padded. A short // key that got zero-extended would produce ciphertext that looks fine and // is trivially breakable. func TestConfigRefusesMalformedDEK(t *testing.T) { for name, dek := range map[string]string{ "not base64": "!!!!not base64!!!!", "too short": base64.StdEncoding.EncodeToString(make([]byte, 16)), "too long": base64.StdEncoding.EncodeToString(make([]byte, 64)), "empty base64": base64.StdEncoding.EncodeToString(nil), } { t.Run(name, func(t *testing.T) { _, err := memoria.LoadConfig(envFrom(map[string]string{ "MEMORIA_DEK": dek, "MEMORIA_INSTANCE_KEY": "k", "MEMORIA_OWNERS": "a@b.c", "CARLOS_SMTP_HOST": "smtp.example.com", "CARLOS_SMTP_FROM": "noreply@example.com", })) if err == nil { t.Fatalf("LoadConfig accepted a %s DEK", name) } }) } } // Origin is the CSRF yardstick and decides cookie attributes, so a // silent production default would be a real bug. It may default, but the // config must say that it did. func TestConfigFlagsADefaultedOrigin(t *testing.T) { cfg, err := memoria.LoadConfig(envFrom(map[string]string{ "MEMORIA_DEK": base64.StdEncoding.EncodeToString(make([]byte, 32)), "MEMORIA_INSTANCE_KEY": "k", "MEMORIA_OWNERS": "a@b.c", "CARLOS_SMTP_HOST": "smtp.example.com", "CARLOS_SMTP_FROM": "noreply@example.com", })) if err != nil { t.Fatal(err) } if !cfg.UsedDefaultOrigin() { t.Error("origin was defaulted but UsedDefaultOrigin() is false; main would not warn") } cfg, err = memoria.LoadConfig(envFrom(map[string]string{ "MEMORIA_DEK": base64.StdEncoding.EncodeToString(make([]byte, 32)), "MEMORIA_INSTANCE_KEY": "k", "MEMORIA_OWNERS": "a@b.c", "CARLOS_SMTP_HOST": "smtp.example.com", "CARLOS_SMTP_FROM": "noreply@example.com", "MEMORIA_ORIGIN": "https://real.example.com", })) if err != nil { t.Fatal(err) } if cfg.UsedDefaultOrigin() { t.Error("a real origin is reported as defaulted; main would warn spuriously") } } // The explicit opt-in must work, or local development is impossible and // somebody will delete the refusal instead. func TestConfigAllowsLoggedMailWhenAskedExplicitly(t *testing.T) { cfg, err := memoria.LoadConfig(envFrom(map[string]string{ "MEMORIA_DEK": base64.StdEncoding.EncodeToString(make([]byte, 32)), "MEMORIA_INSTANCE_KEY": "k", "MEMORIA_OWNERS": "a@b.c", "MEMORIA_ALLOW_LOGGED_MAIL": "1", })) if err != nil { t.Fatalf("the explicit dev opt-in was refused: %v", err) } if !cfg.AllowLoggedMail { t.Error("AllowLoggedMail is false after opting in; main would not warn") } } func envFrom(m map[string]string) func(string) string { return func(k string) string { return m[k] } } func bytesContainsFold(hay []byte, needle string) bool { return strings.Contains(strings.ToLower(string(hay)), strings.ToLower(needle)) } // designCSS is every stylesheet the templates are styled by, as one // string. func designCSS(t *testing.T) string { t.Helper() css := "" for _, name := range []string{"tokens.css", "theme.css", "memoria.css"} { raw, err := os.ReadFile(filepath.Join("..", "memoria", "static", name)) if err != nil { t.Fatalf("read %s: %v", name, err) } css += string(raw) } return css } // Every `rst-` attribute a template writes is one the stylesheets // actually style. // // **Both halves of this gate came from shipping the mistake.** The // connectors page grew `rst-list-item-warn`, an attribute invented on the // spot that styles nothing; and `rst-callout="danger"` had been in // results.html since it was written, when tokens.css styles // `[rst-callout][data-tone="negative"]` — a value on the attribute itself // matches the base selector and no tone at all. Two tests pinned the // second one in place, because they were written from the same guess as // the markup. // // This is the frontend twin of AGENTS.md's rule about opening the section // you cite: the design system has a vocabulary, and a word that is not in // it renders as nothing at all rather than as an error. Nobody notices // until a screenshot. // // **The exemption list is the honest part.** Some attributes are hooks for // tests and scripts rather than for CSS, and they are named here one by // one — a blanket "ignore anything not found" would turn this gate back // into the thing it replaces. // // MUTATION-TESTED 2026-09-07, each verified red then reverted: // // add rst-list-item-warn back to connectors.html -> red, naming it // write rst-callout="danger" again -> red // misspell rst-callout-body -> red func TestEveryTemplateAttributeIsInTheDesignSystem(t *testing.T) { css := designCSS(t) // **Hooks, not styles**, and the list is derived rather than typed: // anything one of the vendored scripts reads is a hook by definition, // and pattern-matching that is the honest version of an exemption. js := "" for _, name := range []string{"rastrillo.js", "search.js", "drop.js"} { raw, err := os.ReadFile(filepath.Join("..", "memoria", "static", name)) if err != nil { t.Fatalf("read %s: %v", name, err) } js += string(raw) } // And the ones that are neither: **pre-existing attributes that style // nothing and no script reads.** They render as plain elements, which // is a silent defect rather than an error, and this gate found them // the day it was written (2026-09-07). // // Listed rather than ignored, so the list is visible and can shrink. // A blanket "skip what is not found" would turn this gate back into // the absence of the gate. // **Pre-existing attributes that style nothing and no script reads.** // They render as plain elements — a silent defect rather than an // error. Listed rather than pattern-skipped so the list is visible and // can shrink; a blanket "ignore what is missing" would be the absence // of the gate wearing its name. // // The list shrank with the shared chrome (2026-09-30): rst-cell-mut // (a class-only utility, written as an attribute nineteen times) is // now memoria.css's [rst-muted]; the Sources page's rst-list-item and // rst-list-title are [rst-sources] rows; rst-facts is styled; the // header's rst-brand and the Delete button's rst-danger are gone. unstyled := templateUnstyled // **Values this app writes that the design system does not define.** // Same treatment and same reason as the list above: named, dated, and // visible rather than pattern-skipped. // // `rst-btn` takes primary, ghost and danger. Every button in this app // spelled "quiet" or "compact" therefore renders as the default // button — the wrong weight in the header, on the sign-out control, // and on the connector actions. Found 2026-09-07 by the value half of // this gate, the day it could see values at all. // // **Not fixed here on purpose.** Which variant a "quiet compact" // button should actually be is a visual decision about how the app // looks, not a defect with one correct repair, and making it silently // inside a commit about a circuit breaker would be the drive-by this // project's AGENTS.md warns about. Recorded for Paul. unstyledValues := map[string]bool{ "rst-btn=quiet": true, "rst-btn=compact": true, } exempt := templateExempt attr := rstWord // **Whole tokens, never substrings**, and this is not fussiness. The // first version tested membership with strings.Contains, and // `rst-callout` is a prefix of `rst-callout-body` — which the scripts // read — so every check involving rst-callout was satisfied by a // different attribute that merely starts the same way. Two mutations // survived on exactly that, including the one this gate exists for. tokens := func(in string) map[string]bool { out := map[string]bool{} for _, m := range attr.FindAllString(in, -1) { out[m] = true } return out } // **The stylesheet vocabulary comes from ATTRIBUTE selectors only**, // `[rst-…`, and not from every rst- word in the file. Two things were // wrong with reading the text: it counts words in comments, and — the // one that matters — it counts the CLASS spelling of utilities that // have no attribute twin. tokens.css says which those are // ("Utilities keep the class spelling and are not paired: rst-sr-only, // rst-mono, … rst-cell-mut"), memoria.css says `rst-cell-mut` written // as an attribute selects nothing, and AGENTS.md says utilities stay // classes. The gate said they were fine. cssAttrs := attrSelectors(css) jsAttrs := tokens(js) dir := filepath.Join("..", "memoria", "templates") entries, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } seen := map[string]bool{} for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".html") { continue } raw, err := os.ReadFile(filepath.Join(dir, e.Name())) if err != nil { t.Fatal(err) } for _, m := range attrNames(string(raw)) { if unstyled[m] || seen[m] || jsAttrs[m] { continue } seen[m] = true if !cssAttrs[m] { t.Errorf("%s writes %q, which no stylesheet mentions: an "+ "attribute outside the design system's vocabulary renders "+ "as nothing at all rather than as an error", e.Name(), m) } } } // The presence half: the walk really did find attributes, so the // absence check above is an assertion rather than a description of an // empty directory. if len(seen) < 5 { t.Fatalf("only %d attributes found across the templates; the walk is "+ "not reaching them and this gate proves nothing", len(seen)) } // **And the half the name check cannot make.** // // `rst-callout="warn"` passes everything above: the NAME is in the // stylesheet, and the value is simply ignored. That is precisely the // bug this gate was written for — `rst-callout="danger"` shipped and // rendered untoned, because tokens.css styles `[rst-callout]` plus // `[data-tone="negative"]` and never a value on the attribute itself. // A mutation restoring it stayed green against the name check alone. // // So: an attribute written WITH a value must be one the stylesheets // select on with a value. `rst-btn="primary compact"` is fine because // tokens.css contains `[rst-btn~="primary"]`; `rst-callout="warn"` is // not, because nothing anywhere selects on a value of rst-callout. // Derived from the CSS rather than listed, so it stays true as the // design system grows. valued := regexp.MustCompile(`(rst-[a-z0-9-]+)="([^"]*)"`) for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".html") { continue } raw, err := os.ReadFile(filepath.Join(dir, e.Name())) if err != nil { t.Fatal(err) } for _, m := range valued.FindAllStringSubmatch(string(raw), -1) { name, value := m[1], m[2] if exempt[name] || unstyled[name] || jsAttrs[name] { continue } // **Each value, not merely the attribute taking values.** // Checking that something somewhere selects on a value of // this name passes `rst-btn="nonsense"`, because // `[rst-btn~="primary"]` exists. A value nobody styles is the // same silent nothing as an attribute nobody styles. // // A template expression is skipped and its guarantee moves to // the function producing it — see tone(). if strings.Contains(value, "{{") { continue } for _, word := range strings.Fields(value) { if unstyledValues[name+"="+word] { continue } if strings.Contains(css, name+`~="`+word+`"`) || strings.Contains(css, name+`="`+word+`"`) { continue } t.Errorf("%s writes %s=%q, and no stylesheet selects "+ "%s~=%q: the attribute matches, the value is ignored, "+ "and the element renders unstyled", e.Name(), name, value, name, word) } } } // data-tone carries the tones, so its values must be ones the // stylesheets define — a tone nobody styles is the same silent // nothing by another spelling. // The attribute spelling of a tone is `rst-tone`; `data-tone` is the // class-grammar half of the same pair and selects nothing beside // `[rst-callout]`. Both are checked, so writing either where the // other belongs is caught. tone := regexp.MustCompile(`(rst-tone|data-tone)="([^"]*)"`) for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".html") { continue } raw, err := os.ReadFile(filepath.Join(dir, e.Name())) if err != nil { t.Fatal(err) } for _, m := range tone.FindAllStringSubmatch(string(raw), -1) { if m[1] == "data-tone" { t.Errorf("%s writes data-tone=%q on an attribute-spelled "+ "element: that is the class half of the pair, and it "+ "selects nothing here. The attribute spelling is rst-tone", e.Name(), m[2]) continue } if strings.Contains(m[2], "{{") { // A template expression. This gate reads files, not // renders, so it cannot know what this evaluates to — // and guessing would be worse than declining. // // **The guarantee moves rather than disappearing**: every // dynamic tone in this app comes from `memoria.tone`, and // TestEveryToneIsOneTheStylesheetsDefine asserts that // function's whole range against the same stylesheets. // One place produces them, so one test covers them. continue } if !strings.Contains(css, `[rst-callout][rst-tone~="`+m[2]+`"]`) { t.Errorf("%s writes rst-tone=%q, which no stylesheet selects "+ "on: the callout renders untoned", e.Name(), m[2]) } } } } // rstWord is one rst- name, wherever it is written. var rstWord = regexp.MustCompile(`rst-[a-z0-9-]+`) // templateExempt names rst- words the design-system gate does not hold // to the stylesheets at all. var templateExempt = map[string]bool{ "rst-withheld": true, // a test hook for §6.5's notice block } // classOnlyUtilities are tokens.css utilities that keep the class // spelling and have no attribute twin ("Utilities keep the class // spelling and are not paired"). Inside a class="…" value they are // correct and the gate skips them; anywhere else they style nothing and // the gate reports them like any other unstyled name. This gate reads // rst- words in class values too, and select.js's mention of // rst-sr-only used to satisfy it; select.js went when the app moved off // rastrillo's frozen path (no page links it). var classOnlyUtilities = map[string]bool{ "rst-sr-only": true, } // classValue is one class attribute and its value, either quote. var classValue = regexp.MustCompile(`(\sclass=)("[^"]*"|'[^']*')`) // **Pre-existing attributes that style nothing and no script reads.** // See TestEveryTemplateAttributeIsInTheDesignSystem. var templateUnstyled = map[string]bool{ "rst-lede": true, "rst-steps": true, "rst-inline": true, "rst-bg": true, } // attrSelectors is the stylesheets' ATTRIBUTE vocabulary: the names // written as `[rst-…` selectors, never the class spelling. func attrSelectors(css string) map[string]bool { out := map[string]bool{} for _, m := range regexp.MustCompile(`\[(rst-[a-z0-9-]+)`).FindAllStringSubmatch(css, -1) { out[m[1]] = true } return out } // attrNames is every rst- name a template writes that the gate must // find in the stylesheets' attribute vocabulary, exemptions removed. func attrNames(markup string) []string { // Native details groups use name values, not styled attributes. // Mutation-checked 2026-09-11: rst-not-a-real-attribute still fails. markup = strings.ReplaceAll(markup, `name="rst-menus"`, `name="menu-group"`) // Class-only utilities leave the class values they belong in, and // only those: written as an attribute they are still scanned. markup = classValue.ReplaceAllStringFunc(markup, func(m string) string { return rstWord.ReplaceAllStringFunc(m, func(w string) string { if classOnlyUtilities[w] { return "" } return w }) }) var out []string for _, m := range rstWord.FindAllString(markup, -1) { if templateExempt[m] { continue } out = append(out, m) } return out } // A class-only utility is exempt as a CLASS and only as a class. // tokens.css styles .rst-sr-only and has no [rst-sr-only] twin, so // `

` renders on screen — the very text the utility was // meant to hide. The exemption used to be a bare word, which waved that // through along with the class it was written for (Astra, memoria #24). func TestClassOnlyUtilityPassesOnlyAsAClass(t *testing.T) { cssAttrs := attrSelectors(designCSS(t)) if cssAttrs["rst-sr-only"] { t.Fatal("the stylesheets now select [rst-sr-only]; the attribute spelling is styled and this test's premise is gone") } unstyled := func(markup string) []string { var out []string for _, m := range attrNames(markup) { if !cssAttrs[m] && !templateUnstyled[m] { out = append(out, m) } } return out } for _, ok := range []string{ ``, `

`, `

x

`, `

x

`, } { if got := unstyled(ok); len(got) != 0 { t.Errorf("%s: the gate rejects %v; a class-only utility written as a class is correct", ok, got) } } for _, bad := range []string{ `

x

`, `

x

`, `

x

`, `

x

`, `

x

`, } { if got := unstyled(bad); len(got) != 1 || got[0] != "rst-sr-only" { t.Errorf("%s: the gate reports %v, want [rst-sr-only]: written as an attribute the utility styles nothing", bad, got) } } }