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 "encoding/base64"
5 "errors"
6 "fmt"
7 "go/ast"
8 "go/parser"
9 "go/token"
10 "net/http"
11 "os"
12 "path/filepath"
13 "regexp"
14 "strings"
15 "testing"
16
17 memoria "amadan.net/oficina/memoria/internal/memoria"
18)
19
20// canary is text that must never appear in bytes at rest. It is a phrase
21// no library or schema would produce, so a hit is our leak and not a
22// coincidence.
23const canary = "ZEBRAFINCH-QUARTERLY-REVENUE-8834"
24
25// Gate 1. The one rule: the database file never holds a readable
26// passage.
27//
28// This reads the raw bytes rather than querying, because a query would
29// go through the code that decrypts and would pass no matter what is on
30// disk. The WAL is read too: SQLite writes there first, and a gate that
31// checks only the main file passes for as long as a checkpoint has not
32// happened — which in a short test is always.
33//
34// MUTATION-TESTED 2026-09-03: replacing s.sealer.Seal with a plain
35// []byte(...) cast in Store.Ingest makes this fail on both the chunk and
36// the title, naming the file it found the canary in. Reverted.
37func TestPlaintextNeverReachesTheDatabase(t *testing.T) {
38 dir := t.TempDir()
39 path := filepath.Join(dir, "app.db")
40 h, _ := newAppAt(t, path)
41
42 body := "# " + canary + "\n\nJune revenue was 1,350,000 and " + canary + " again in the body.\n"
43 rec := store(t, h, canary+" as a title", body)
44 // The vacuity guard, and it has to be this specific. An earlier
45 // version checked `rec.Code != 303` and then searched for the string
46 // "result" — but the handler redirects with 303 on FAILURE too
47 // (/?err=empty, /?stored=0&refused=1), and "result" is a substring
48 // of "0 results". With
49 // Ingest mutated to always error, that version PASSED against an
50 // empty database: the gate below was scanning a file with nothing in
51 // it. Assert the success location and real result markup.
52 if rec.Code != 303 {
53 t.Fatalf("store: status %d, want 303", rec.Code)
54 }
55 if loc := rec.Header().Get("Location"); loc != "/add?stored=1" {
56 t.Fatalf("store redirected to %q, want /add?stored=1 — the document was not stored, "+
57 "so the scan below would pass vacuously", loc)
58 }
59 if got := get(t, h, "/?q=zebrafinch").Body.String(); !strings.Contains(got, "<mark>") {
60 t.Fatal("no highlighted result: the document was not indexed, and the scan below proves nothing")
61 }
62
63 entries, err := os.ReadDir(dir)
64 if err != nil {
65 t.Fatal(err)
66 }
67 checked := 0
68 for _, e := range entries {
69 if e.IsDir() {
70 continue
71 }
72 raw, err := os.ReadFile(filepath.Join(dir, e.Name()))
73 if err != nil {
74 t.Fatal(err)
75 }
76 checked++
77 if bytesContainsFold(raw, canary) {
78 t.Errorf("%s holds the canary in plaintext: the one rule is broken", e.Name())
79 }
80 }
81 if checked == 0 {
82 t.Fatal("no files to check; the gate proved nothing")
83 }
84}
85
86// Gate 1's other half: no plaintext reaches a TEMP file either.
87//
88// ParseMultipartForm's argument is a MEMORY threshold, and Go spends it
89// cumulatively across parts — it holds file parts in memory until the
90// running total passes the threshold and writes every part after that to
91// a temp file, in the clear, with no error raised anywhere. One 2 MiB
92// file could not reach that with the old single-file cap. Twenty-five
93// can, trivially, which is why this gate exists now and did not before.
94//
95// It works because httptest calls the handler directly: net/http's own
96// server calls MultipartForm.RemoveAll() when the request finishes, so
97// in production a spilled file is deleted — after having been written.
98// Here nothing deletes it, so a spill is still on disk to be found.
99//
100// MUTATION-TESTED 2026-09-04: ParseMultipartForm's argument changed back
101// to maxUpload — the per-file cap, which is what it was before
102// multi-file — makes this fail naming the spilled file (the suffix is
103// random, e.g. "multipart-578803712 in the temp directory holds the
104// canary in plaintext: Go spilled a part to disk") and again with
105// "1 files were left in the temp directory". Reverted.
106func TestAMultiFileDropNeverSpillsPlaintextToATempFile(t *testing.T) {
107 // The app first, so its database does not land in the directory
108 // being scanned: TMPDIR is what os.CreateTemp and t.TempDir both
109 // read, and the point here is to watch one specific directory.
110 h := newApp(t)
111 spill := t.TempDir()
112 t.Setenv("TMPDIR", spill)
113
114 // Twelve parts of half a MiB. Under any per-file threshold this
115 // passes the budget several times over, because the budget is spent
116 // across parts and not per part.
117 body := strings.Repeat(canary+" and more revenue text to pad this passage out.\n\n", 8000)
118 files := make([]file, 0, 12)
119 for i := 0; i < 12; i++ {
120 files = append(files, file{fmt.Sprintf("spill-%02d.txt", i), body})
121 }
122 rec := submit(t, h, "", "", files...)
123
124 // The vacuity guard. A submission that was refused would leave the
125 // directory empty and this gate would pass having watched nothing.
126 if loc := rec.Header().Get("Location"); loc != "/add?stored=12" {
127 t.Fatalf("the drop answered %q rather than storing all 12; the scan below would "+
128 "prove nothing", loc)
129 }
130
131 found := 0
132 err := filepath.Walk(spill, func(p string, info os.FileInfo, err error) error {
133 if err != nil || info.IsDir() {
134 return err
135 }
136 found++
137 raw, err := os.ReadFile(p)
138 if err != nil {
139 return err
140 }
141 if bytesContainsFold(raw, canary) {
142 t.Errorf("%s in the temp directory holds the canary in plaintext: Go spilled a "+
143 "part to disk. ParseMultipartForm's argument is a memory threshold, not a "+
144 "limit, and it is spent across parts — it must be at least the whole body "+
145 "cap or a large drop writes somebody's documents to /tmp in the clear.",
146 filepath.Base(p))
147 }
148 return nil
149 })
150 if err != nil {
151 t.Fatal(err)
152 }
153 if found > 0 {
154 t.Errorf("%d files were left in the temp directory by a drop that should have been "+
155 "parsed entirely in memory", found)
156 }
157}
158
159// Gate 15's egress half. With the assistant off, nothing leaves the
160// instance — and in slice 0 there is no assistant at all, so the honest
161// form of this gate is that no app package can make an outbound request.
162//
163// A runtime gate would be better and is not available: httptest cannot
164// observe a dial that never happens. So this is a source gate, and it is
165// mutation-tested by adding an http.Get to the store, which makes it
166// fail.
167//
168// MUTATION-TESTED 2026-09-03: adding `http.Get("https://example.com")`
169// to Store.Ingest makes this fail naming store.go. Reverted.
170//
171// AMENDED 2026-09-05 for the Notion connector, which must dial
172// api.notion.com. The blanket ban survives; one directory is exempt and
173// pays for the exemption with a runtime host pin. See allowedNetworkDirs.
174//
175// AMENDED AGAIN 2026-09-06 for slice 1's sibling client. Same shape, same
176// price: the package is exempt from the grep and pays for it with a
177// runtime guard the grep cannot perform. Two entries now, and the comment
178// on the list says why a third should be harder.
179//
180// MUTATION-TESTED 2026-09-05: adding an `*http.Client` field to
181// internal/memoria/handlers.go still makes this fail, so narrowing the
182// gate did not blunt it. Reverted.
183// MUTATION-TESTED 2026-09-06: adding a third directory to
184// allowedNetworkDirs fails the count assertion; adding an
185// `http.NewRequest` to handlers.go still fails the blacklist. Reverted.
186func TestNoOutboundNetworkInAppCode(t *testing.T) {
187 // A blacklist, and it is worth saying so: this is a source
188 // heuristic, not a proof. A review defeated the first version with
189 // `new(http.Client).Post(...)`, which none of six spellings matched.
190 // The list is broader now and the walk covers cmd/ and tools/ too,
191 // but the honest statement of what it buys is "an accidental
192 // outbound call is caught"; a determined one is not.
193 forbidden := []string{
194 `http\.Get\(`, `http\.Post\(`, `http\.PostForm\(`, `http\.Head\(`,
195 `http\.Client\{`, `new\(http\.Client\)`, `\*http\.Client`,
196 `http\.DefaultClient`, `http\.DefaultTransport`, `http\.NewRequest`,
197 `net\.Dial`, `net\.Lookup`, `smtp\.`, `os/exec`, `exec\.Command`,
198 `url\.Parse\(.*https?://`,
199 }
200 pats := make([]*regexp.Regexp, 0, len(forbidden))
201 for _, f := range forbidden {
202 pats = append(pats, regexp.MustCompile(f))
203 }
204 // EXACTLY THREE packages may reach the network, and this is the list.
205 //
206 // It is a list rather than a deleted gate because the connector had
207 // to dial api.notion.com and the cheapest way to make this test pass
208 // would have been to widen it until it named nothing. The exemption
209 // is paid for by a RUNTIME guard that is stronger than the grep it
210 // replaces here: notion.pinnedTransport refuses every host but the
211 // one it was built for, and the client refuses redirects outright,
212 // both asserted by notion.TestClientDialsOnlyNotion. A source grep
213 // cannot tell an http.Client pointed at Notion from one pointed
214 // anywhere else; that test can.
215 //
216 // A SECOND entry was added on 2026-09-06, and it was meant to be hard
217 // to justify. Slice 1 gives memoria a sibling client, and a sibling
218 // call is outbound by definition — decision 1's whole point is that a
219 // team's instances are one trust domain, so this is egress the design
220 // requires rather than egress that crept in.
221 //
222 // It is paid for the same way Notion's is, by a runtime guard the grep
223 // could not perform: suite.NewSiblingClient refuses any host outside
224 // memoria's own <team> label and refuses redirects outright, both
225 // before the transport dials, and sibling.TestClientRefusesHostsOutsideTheTeam
226 // asserts it against the real constructor. A source grep cannot tell
227 // an http.Client bound to one team from a bare one; that test can.
228 //
229 // A third entry should be harder still. Two packages that may dial is
230 // two places to read when §3.2's leak list is next revised.
231 //
232 // **The third entry, 2026-09-27: the model provider.** Harder to
233 // justify than either before it, because what it sends is the team's
234 // passages to a party outside the suite — §3.2's largest leak class,
235 // not a sibling inside the trust domain. It is admitted on three
236 // conditions, and each is a thing a reviewer can check rather than a
237 // sentence to take on trust:
238 //
239 // - Paul ruled the egress in (decision 15 as amended, §8.9) under
240 // decision 4's terms: per-team opt-in, one named provider, a working
241 // floor when it is off. memoria.TestTheModelSwitch holds the opt-in.
242 // - The same runtime guard the other two carry: provider.pinnedTransport
243 // refuses every host and scheme but TensorX's and every redirect,
244 // before dialling, asserted against the real constructor by
245 // provider.TestClientDialsOnlyTensorX with requests counted at the
246 // listener. Mutation-tested 2026-09-27: passing everything through,
247 // dropping the scheme check, and following redirects each go red.
248 // - Nothing leaves when AI is off, asserted as an absence of traffic
249 // WITH its presence half (gate 39).
250 //
251 // **A fourth entry should need a spec section of its own.** Three is
252 // three places to read when §3.2 is next revised, and the third is the
253 // one that sends content outside the suite.
254 allowedNetworkDirs := []string{
255 filepath.Join("..", "memoria", "notion"),
256 filepath.Join("..", "memoria", "sibling"),
257 filepath.Join("..", "memoria", "provider"),
258 }
259 if len(allowedNetworkDirs) != 3 {
260 t.Fatalf("%d packages may reach the network, want exactly 3: %v",
261 len(allowedNetworkDirs), allowedNetworkDirs)
262 }
263 exempt := func(p string) bool {
264 for _, d := range allowedNetworkDirs {
265 if strings.HasPrefix(filepath.Clean(p), filepath.Clean(d)+string(filepath.Separator)) {
266 return true
267 }
268 }
269 return false
270 }
271
272 var hits []string
273 scanned := 0
274 walk := func(root string) error {
275 return filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
276 if err != nil {
277 return err
278 }
279 if info.IsDir() || !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") {
280 return nil
281 }
282 if exempt(p) {
283 return nil
284 }
285 src, err := os.ReadFile(p)
286 if err != nil {
287 return err
288 }
289 scanned++
290 for _, pat := range pats {
291 if pat.Match(src) {
292 hits = append(hits, p+" matches "+pat.String())
293 }
294 }
295 return nil
296 })
297 }
298 for _, root := range []string{
299 filepath.Join("..", "memoria"),
300 filepath.Join("..", "..", "cmd"),
301 } {
302 if err := walk(root); err != nil {
303 t.Fatal(err)
304 }
305 }
306 // Vacuity guard. An exempt() that matched everything, or a walk root
307 // that moved, would leave this gate scanning nothing and passing —
308 // which is how a gate stops guarding without anyone noticing.
309 if scanned < 5 {
310 t.Fatalf("only %d files scanned; the gate is not looking at the app", scanned)
311 }
312 for _, h := range hits {
313 t.Errorf("app code can reach the network: %s", h)
314 }
315}
316
317// The footer says no DOCUMENT leaves this instance, and that is the claim
318// this file can support. It deliberately no longer says "nothing leaves",
319// because that was false: rastrillo's sign-in classifier fetches
320// https://<the address's domain>/.well-known/keymail on POST /auth/begin,
321// BEFORE the admission gate — so an anonymous visitor makes the instance
322// dial any domain they type. Found by a code review of this branch.
323//
324// This gate pins the copy, so nobody restores the stronger sentence
325// without also removing the dial.
326//
327// **Since §8.9 the sentence is conditional**: it is the AI-OFF branch of
328// the footer, and with AI on the footer names TensorX instead. This gate
329// reads the template source, so it pins the wording of both branches;
330// TestTheFooterStatesTheModelSwitch renders every declared page both ways
331// and is what checks the right branch is chosen.
332func TestFooterDoesNotOverclaim(t *testing.T) {
333 raw, err := os.ReadFile(filepath.Join("..", "memoria", "templates", "layout.html"))
334 if err != nil {
335 t.Fatal(err)
336 }
337 src := string(raw)
338 if strings.Contains(src, "nothing leaves this instance") {
339 t.Error("the footer claims nothing leaves this instance; sign-in dials the " +
340 "address's domain before anyone is admitted, so that is false")
341 }
342 if !strings.Contains(src, "no document ever") {
343 t.Error("the footer no longer makes the claim this app can actually support")
344 }
345}
346
347// Gate 4, absent until now: no content in any log line. §11 said slice 0
348// ships it. It did not, which is how a mailer that logs the magic link
349// went unnoticed.
350//
351// A FILENAME is content too, and that is the half multi-file upload
352// makes easy to get wrong: a refusal now names the file it refused, and
353// the obvious next line to write is that same name into a log. So the
354// secret is posted as a filename as well — once on a file that stores
355// and once on a file that is refused, because the refusal path is where
356// a filename is nearest to hand.
357//
358// The asynchronous path is exercised too, and for a sharper reason than
359// symmetry. It is the path where a filename is nearest to hand — a
360// request that answers per-file is the natural place to log which file it
361// answered about — and it is the one path whose RESPONSE body is allowed
362// to carry a filename, which makes it easy to forget that the log beside
363// it is not. So the same secret goes up over that path as well, on a file
364// that stores and a file that is refused.
365//
366// The store-failure line is reached DELIBERATELY, and the reason is the
367// whole of this comment's history. This gate used to claim, in a
368// MUTATION-TESTED note, that adding `"file", name` to createDocument's
369// ingest-failure log line made it go red. A code review replayed that
370// mutation and it stayed GREEN: nothing here made Store.Ingest fail —
371// the secret-named .png files are refused in readUpload well before that
372// line, and the .txt files store cleanly — so the line under test never
373// executed and the claim was false. That is exactly the shape AGENTS.md
374// opens with, written on the guard for the log line nearest a filename.
375// The database is now closed under the running app so that the line runs,
376// and its presence is asserted before its contents are, because a gate
377// that checks for an absence has to prove first that the thing whose
378// absence it checks was there to be found.
379//
380// MUTATION-TESTED 2026-09-04, each reverted, each observed red:
381//
382// (a) `"file", name` added to createDocument's ingest-failure log line
383// → "a log line carries document content, a filename or the query",
384// printing the buffer with WOMBAT…-fails.txt in it. This is the
385// mutation the previous version of this comment claimed and did not
386// have.
387// (b) Store.Ingest's post-transaction error return wrapped as
388// `fmt.Errorf("ingest %q: %w", title, err)` — the plausible "which
389// document was it?" edit, and the one that puts content in an ERROR
390// STRING rather than a log call → the same failure, printing
391// err="ingest \"WOMBAT…-fails\": sql: database is closed".
392// (c) `a.logger.Info("search", "q", q)` in the index handler
393// → the same failure, printing q=WOMBAT… for both searches.
394// (d) the vacuity guard's own subject: removing `d.Close()` → "the
395// ingest-failure log line never ran", which is the state this gate
396// was silently in before.
397func TestNoContentInLogs(t *testing.T) {
398 h, logs, d := newAppWithLog(t)
399 const secret = "WOMBATCONFIDENTIAL9931"
400 store(t, h, secret+" title", "## "+secret+"\n\nA passage about "+secret+" and revenue.")
401 submit(t, h, "", "",
402 file{secret + "-good.txt", "A passage about hosting costs and the migration."},
403 file{secret + "-refused.png", "\x00\x00 binary payload"},
404 )
405 // The same again, asynchronously. One file per request, as the tray
406 // sends them.
407 submitJSON(t, h, file{secret + "-async.txt", "A passage about the migration date."})
408 submitJSON(t, h, file{secret + "-async.png", "\x00\x00 binary payload"})
409 get(t, h, "/?q="+secret)
410 get(t, h, "/?q=revenue")
411 get(t, h, "/search?q="+secret)
412 get(t, h, "/search?q=revenue")
413
414 // Now a file whose store genuinely fails. Last, because it takes the
415 // database away from everything above.
416 d.Close()
417 reply := readReply(t, submitJSON(t, h, file{secret + "-fails.txt", "A passage about the migration date."}))
418 if reply.Stored != 0 || len(reply.Refused) != 1 ||
419 !strings.Contains(reply.Refused[0], secret+"-fails.txt") {
420 t.Fatalf("the store did not fail, so the log line this gate is aimed at never ran and "+
421 "everything below it proves nothing: stored=%d refused=%v",
422 reply.Stored, reply.Refused)
423 }
424
425 got := logs.String()
426 if !strings.Contains(got, "ingest failed") {
427 t.Fatalf("nothing in the log says a store failed, so the line that names a file was "+
428 "never written and the check below is vacuous:\n%s", got)
429 }
430 if strings.Contains(got, secret) {
431 t.Errorf("a log line carries document content, a filename or the query:\n%s", got)
432 }
433 for _, bad := range []string{"sign-in link", "auth/verify?"} {
434 if strings.Contains(got, bad) {
435 t.Errorf("a log line carries a credential (%q):\n%s", bad, got)
436 }
437 }
438}
439
440// requireSession fails closed. An app built without auth must refuse
441// every route rather than serve it.
442//
443// MUTATION-TESTED 2026-09-04: restoring the nil-auth pass-through makes
444// this fail. Reverted.
445func TestUnauthenticatedAppRefuses(t *testing.T) {
446 h := newProductionApp(t)
447 // /search is on this list because it is the newest way to read the
448 // corpus and the easiest one to mount outside the group by accident:
449 // it is not a page, so nothing about it looks like a door.
450 for _, target := range []string{"/", "/documents/1", "/?q=revenue", "/search?q=revenue"} {
451 rec := get(t, h, target)
452 if rec.Code == http.StatusOK {
453 t.Errorf("GET %s served 200 with no sign-in configured; requireSession fails open", target)
454 }
455 }
456}
457
458// The test seam must stay in tests. Production wiring may not reach
459// AppForTest, and this is the gate that the previous version of this file
460// only claimed to have.
461func TestProductionDoesNotUseTheTestConstructor(t *testing.T) {
462 err := filepath.Walk(filepath.Join("..", "..", "cmd"), func(p string, info os.FileInfo, err error) error {
463 if err != nil || info.IsDir() || !strings.HasSuffix(p, ".go") {
464 return err
465 }
466 raw, err := os.ReadFile(p)
467 if err != nil {
468 return err
469 }
470 // Every seam, not just the constructor. App() hands cmd/memoria
471 // the same *app that carries these, so `app.AsPerson("x")` in
472 // main would compile, make every request an owner, and pass a
473 // gate that greps for AppForTest alone. Found by a code review.
474 for _, seam := range []string{
475 "AppForTest", "AsPerson", "UseFakeSource", "WaitForSync",
476 // SeedTallyForDrive writes breaker counters into the past.
477 // The breaker's whole claim is that they record what
478 // happened when it happened.
479 "SeedTallyForDrive",
480 // sibling.NewForTest builds a plain http.Client with no team
481 // binding. In production that is §6.4's cross-team refusal
482 // removed — the guard that runs before the transport dials —
483 // and it would compile and serve perfectly well.
484 "NewForTest",
485 // UseFakeModel switches the model layer on without MEMORIA_AI:
486 // in main it bypasses the owner's opt-in, and handed a real
487 // client it sends content the owner never agreed to send.
488 "UseFakeModel",
489 } {
490 if strings.Contains(string(raw), seam) {
491 t.Errorf("%s calls the test seam %s; production must not reach it", p, seam)
492 }
493 }
494 if !strings.Contains(string(raw), "memoria.App(") {
495 return nil
496 }
497 // Every setter the app needs and cannot supply itself.
498 //
499 // UseAuth's omission serves every route unauthenticated, which is
500 // the failure this gate was written for. The other two fail in the
501 // other direction and are here anyway, because a failure that is
502 // closed is not thereby a failure that is noticed:
503 //
504 // LoadSiblings omitted -> a roster is never read, so a
505 // misconfigured one is never refused at boot and the instance
506 // runs with a permanently short page instead.
507 // UseSiblings omitted -> custody stays nil, every sibling
508 // passage withholds, and the notice blames apps that are fine.
509 //
510 // Both present a working instance that quietly serves less than it
511 // holds, and neither shows up in a test that only asks whether the
512 // app starts.
513 //
514 // **Checked as calls, not as text.** A grep for the name passes on
515 // `_ = memoria.LoadSiblings` — a mention, not a call — and a
516 // review's mutation did exactly that and stayed green. The seam
517 // scan above can stay a grep, because there the string appearing
518 // at all is the problem; here the problem is the call NOT
519 // happening, and only the parse can tell those apart.
520 // **This proves the call is SPELLED, not that it is reached**, and
521 // saying so is the point. A review wrapped `app.UseAuth(a)` in a
522 // closure nobody invoked and this stayed green; so did putting
523 // the sibling wiring behind `if len(apps) > 1`. An AST walk over
524 // one file cannot see control flow, and a smarter walk is a race
525 // against however the next person writes it.
526 //
527 // What makes that survivable is that there is now almost nothing
528 // in main to get wrong: the sibling wiring is one call to
529 // `memoria.WireSiblings`, which has its own tests, and this gate
530 // only has to notice the call disappearing. The names below are
531 // the ones whose absence is silent — UseAuth serves every route
532 // unauthenticated, WireSiblings leaves every sibling passage
533 // withheld while blaming apps that are fine, and WireModel leaves
534 // MEMORIA_AI=on doing nothing, with nothing to say so.
535 for _, call := range []string{"UseAuth", "WireSiblings", "WireModel"} {
536 if !callsFunction(t, p, raw, call) {
537 t.Errorf("%s builds the app but never calls %s", p, call)
538 }
539 }
540 return nil
541 })
542 if err != nil {
543 t.Fatal(err)
544 }
545}
546
547// callsFunction reports whether the file actually CALLS name, as opposed
548// to merely mentioning it.
549//
550// The distinction is the whole point: `memoria.LoadSiblings(os.Getenv)` is
551// a call, `_ = memoria.LoadSiblings` is a reference that does nothing, and
552// a grep cannot tell them apart. It matches either a bare `name(...)` or a
553// selector `x.name(...)`, since the wiring here is a mix of package
554// functions and methods on the app.
555func callsFunction(t *testing.T, path string, src []byte, name string) bool {
556 t.Helper()
557 f, err := parser.ParseFile(token.NewFileSet(), path, src, 0)
558 if err != nil {
559 t.Fatalf("parse %s: %v", path, err)
560 }
561 found := false
562 ast.Inspect(f, func(n ast.Node) bool {
563 call, ok := n.(*ast.CallExpr)
564 if !ok {
565 return true
566 }
567 switch fn := call.Fun.(type) {
568 case *ast.Ident:
569 if fn.Name == name {
570 found = true
571 }
572 case *ast.SelectorExpr:
573 if fn.Sel.Name == name {
574 found = true
575 }
576 }
577 return true
578 })
579 return found
580}
581
582// Every colour in the app's own stylesheet is a token, so the reader's
583// light/dark choice resolves through theme.css. A literal here paints a
584// passage against the wrong ground in one theme and nobody notices until
585// a screenshot.
586//
587// MUTATION-TESTED 2026-09-03: adding `color: #fff` to memoria.css makes
588// this fail naming the line. Reverted.
589func TestNoColourLiteralsInAppCSS(t *testing.T) {
590 raw, err := os.ReadFile(filepath.Join("..", "memoria", "static", "memoria.css"))
591 if err != nil {
592 t.Fatal(err)
593 }
594 // Hex colours, rgb()/hsl()/oklch() and the named colours most likely
595 // to be typed by hand. `var(--rst-...)` is the only sanctioned way.
596 bad := regexp.MustCompile(`(?i)(#[0-9a-f]{3,8}\b|\brgba?\(|\bhsla?\(|\boklch\(|:\s*(white|black|red|blue|green|grey|gray)\b)`)
597 for i, line := range strings.Split(string(raw), "\n") {
598 if strings.HasPrefix(strings.TrimSpace(line), "*") || strings.HasPrefix(strings.TrimSpace(line), "/*") {
599 continue
600 }
601 if m := bad.FindString(line); m != "" {
602 t.Errorf("memoria.css:%d has the colour literal %q; use a token", i+1, m)
603 }
604 }
605}
606
607// Every --rst-* token memoria.css reads is either defined by the
608// stylesheets it ships with, or reached through var()'s fallback.
609//
610// This is the gate for a whole bug class, not for the one bug that
611// prompted it. An undefined custom property does not fall back to
612// anything: the declaration containing it is invalid at computed-value
613// time, so the RULE VANISHES rather than the colour going wrong. Nothing
614// warns, nothing looks broken in the markup, and the page simply renders
615// with body text where a muted grey was meant to be. That is what
616// `--rst-text-mut` (no D) did to the result trail, and it is the same
617// shape as the missing theme.css link AGENTS.md already records.
618//
619// A fallback is allowed and is not a smell: `var(--rst-font-mono,
620// ui-monospace, monospace)` is deliberate — rastrillo defines no mono
621// token and the fallback stack is the whole point. A misspelling with a
622// fallback degrades to the fallback; a misspelling without one deletes
623// the rule. Only the second is a bug, and only the second fails here.
624//
625// MUTATION-TESTED 2026-09-04, each reverted:
626//
627// (a) `color: var(--rst-text-mut)` restored on [rst-result-trail] —
628// the original bug → "memoria.css:121 reads var(--rst-text-mut),
629// which nothing defines and which has no fallback"
630// (b) `background: var(--rst-nonesuch)` added → the same failure at
631// the new line
632// (c) the same reference WITH a fallback, `var(--rst-nonesuch,
633// var(--rst-bg))` → stays green, which is the distinction this
634// gate is drawing
635func TestEveryTokenReferencedHereIsDefinedOrHasAFallback(t *testing.T) {
636 static := filepath.Join("..", "memoria", "static")
637
638 // Where tokens come from. memoria.css itself is included: a page
639 // may define its own token as long as it defines it somewhere.
640 defined := map[string]bool{}
641 def := regexp.MustCompile(`(?:^|[;{,\s])(--rst-[a-zA-Z0-9_-]+)\s*:`)
642 for _, name := range []string{"theme.css", "tokens.css", "memoria.css"} {
643 raw, err := os.ReadFile(filepath.Join(static, name))
644 if err != nil {
645 t.Fatalf("reading %s: %v", name, err)
646 }
647 for _, m := range def.FindAllStringSubmatch(string(raw), -1) {
648 defined[m[1]] = true
649 }
650 }
651 // Vacuity guard, and it has to be here: if the definition regexp
652 // stopped matching, `defined` would be empty and every reference
653 // below would be reported — loudly wrong rather than quietly wrong,
654 // but still not this gate doing its job. theme.css alone carries
655 // well over twenty tokens.
656 if len(defined) < 20 {
657 t.Fatalf("only %d tokens were found across theme.css, tokens.css and memoria.css; "+
658 "the definition pattern has stopped matching and this gate is measuring "+
659 "nothing", len(defined))
660 }
661 for _, must := range []string{"--rst-text-muted", "--rst-line", "--rst-bg"} {
662 if !defined[must] {
663 t.Fatalf("%s was not found among the defined tokens; the definition pattern is "+
664 "missing real definitions and this gate would report false failures", must)
665 }
666 }
667
668 raw, err := os.ReadFile(filepath.Join(static, "memoria.css"))
669 if err != nil {
670 t.Fatal(err)
671 }
672 // The character after the token name is the whole test: `)` means
673 // "no fallback, this must resolve", `,` means a fallback follows.
674 // Nested var()s in a fallback are separate matches and are checked
675 // on their own terms.
676 use := regexp.MustCompile(`var\(\s*(--rst-[a-zA-Z0-9_-]+)\s*([,)])`)
677 seen := 0
678 for i, line := range strings.Split(string(raw), "\n") {
679 for _, m := range use.FindAllStringSubmatch(line, -1) {
680 seen++
681 if m[2] == "," {
682 continue // a fallback carries it
683 }
684 if !defined[m[1]] {
685 t.Errorf("memoria.css:%d reads var(%s), which nothing defines and which has "+
686 "no fallback. The declaration is then invalid at computed-value time — "+
687 "the whole rule disappears, silently, and the element renders as though "+
688 "the line had never been written.", i+1, m[1])
689 }
690 }
691 }
692 if seen < 5 {
693 t.Fatalf("only %d token references were found in memoria.css; the reference pattern "+
694 "has stopped matching and this gate proves nothing", seen)
695 }
696}
697
698// requireSession falls through when auth is nil, which is what makes
699// these tests possible. That seam is only safe because the app refuses to
700// start without sign-in configured, so this pins the refusals.
701//
702// An earlier version of this gate grepped main.go for os.Exit near each
703// variable name. It PASSED when the DEK refusal was replaced with a
704// generated fallback, because the base64 error path below it supplied an
705// os.Exit within the search window. That is why the validation now lives
706// in a function and this tests its behaviour instead of its text.
707//
708// MUTATION-TESTED 2026-09-03: making any one of the three fields default
709// instead of erroring makes the matching subtest fail. Reverted.
710func TestConfigRefusesIncompleteEnvironment(t *testing.T) {
711 full := map[string]string{
712 "MEMORIA_DEK": base64.StdEncoding.EncodeToString(make([]byte, 32)),
713 "MEMORIA_INSTANCE_KEY": "instance-key",
714 "MEMORIA_OWNERS": "paul@example.com",
715 "MEMORIA_ORIGIN": "https://memoria.example.com",
716 "CARLOS_SMTP_HOST": "smtp.example.com",
717 "CARLOS_SMTP_FROM": "noreply@example.com",
718 }
719 // The complete environment must load, or every subtest below passes
720 // for the wrong reason.
721 if _, err := memoria.LoadConfig(envFrom(full)); err != nil {
722 t.Fatalf("a complete environment did not load: %v", err)
723 }
724
725 for _, missing := range []string{"MEMORIA_DEK", "MEMORIA_INSTANCE_KEY", "MEMORIA_OWNERS", "CARLOS_SMTP_HOST"} {
726 t.Run("without "+missing, func(t *testing.T) {
727 env := map[string]string{}
728 for k, v := range full {
729 if k != missing {
730 env[k] = v
731 }
732 }
733 _, err := memoria.LoadConfig(envFrom(env))
734 if err == nil {
735 t.Fatalf("LoadConfig succeeded without %s; the app would start "+
736 "with no %s and the nil-auth pass-through becomes reachable", missing, missing)
737 }
738 if !errors.Is(err, memoria.ErrMissing) {
739 t.Errorf("error for missing %s is %v, want ErrMissing", missing, err)
740 }
741 if !strings.Contains(err.Error(), missing) {
742 t.Errorf("error for missing %s does not name it: %v", missing, err)
743 }
744 })
745 }
746}
747
748// A DEK of the wrong length must be refused rather than padded. A short
749// key that got zero-extended would produce ciphertext that looks fine and
750// is trivially breakable.
751func TestConfigRefusesMalformedDEK(t *testing.T) {
752 for name, dek := range map[string]string{
753 "not base64": "!!!!not base64!!!!",
754 "too short": base64.StdEncoding.EncodeToString(make([]byte, 16)),
755 "too long": base64.StdEncoding.EncodeToString(make([]byte, 64)),
756 "empty base64": base64.StdEncoding.EncodeToString(nil),
757 } {
758 t.Run(name, func(t *testing.T) {
759 _, err := memoria.LoadConfig(envFrom(map[string]string{
760 "MEMORIA_DEK": dek,
761 "MEMORIA_INSTANCE_KEY": "k",
762 "MEMORIA_OWNERS": "a@b.c",
763 "CARLOS_SMTP_HOST": "smtp.example.com",
764 "CARLOS_SMTP_FROM": "noreply@example.com",
765 }))
766 if err == nil {
767 t.Fatalf("LoadConfig accepted a %s DEK", name)
768 }
769 })
770 }
771}
772
773// Origin is the CSRF yardstick and decides cookie attributes, so a
774// silent production default would be a real bug. It may default, but the
775// config must say that it did.
776func TestConfigFlagsADefaultedOrigin(t *testing.T) {
777 cfg, err := memoria.LoadConfig(envFrom(map[string]string{
778 "MEMORIA_DEK": base64.StdEncoding.EncodeToString(make([]byte, 32)),
779 "MEMORIA_INSTANCE_KEY": "k",
780 "MEMORIA_OWNERS": "a@b.c",
781 "CARLOS_SMTP_HOST": "smtp.example.com",
782 "CARLOS_SMTP_FROM": "noreply@example.com",
783 }))
784 if err != nil {
785 t.Fatal(err)
786 }
787 if !cfg.UsedDefaultOrigin() {
788 t.Error("origin was defaulted but UsedDefaultOrigin() is false; main would not warn")
789 }
790 cfg, err = memoria.LoadConfig(envFrom(map[string]string{
791 "MEMORIA_DEK": base64.StdEncoding.EncodeToString(make([]byte, 32)),
792 "MEMORIA_INSTANCE_KEY": "k",
793 "MEMORIA_OWNERS": "a@b.c",
794 "CARLOS_SMTP_HOST": "smtp.example.com",
795 "CARLOS_SMTP_FROM": "noreply@example.com",
796 "MEMORIA_ORIGIN": "https://real.example.com",
797 }))
798 if err != nil {
799 t.Fatal(err)
800 }
801 if cfg.UsedDefaultOrigin() {
802 t.Error("a real origin is reported as defaulted; main would warn spuriously")
803 }
804}
805
806// The explicit opt-in must work, or local development is impossible and
807// somebody will delete the refusal instead.
808func TestConfigAllowsLoggedMailWhenAskedExplicitly(t *testing.T) {
809 cfg, err := memoria.LoadConfig(envFrom(map[string]string{
810 "MEMORIA_DEK": base64.StdEncoding.EncodeToString(make([]byte, 32)),
811 "MEMORIA_INSTANCE_KEY": "k",
812 "MEMORIA_OWNERS": "a@b.c",
813 "MEMORIA_ALLOW_LOGGED_MAIL": "1",
814 }))
815 if err != nil {
816 t.Fatalf("the explicit dev opt-in was refused: %v", err)
817 }
818 if !cfg.AllowLoggedMail {
819 t.Error("AllowLoggedMail is false after opting in; main would not warn")
820 }
821}
822
823func envFrom(m map[string]string) func(string) string {
824 return func(k string) string { return m[k] }
825}
826
827func bytesContainsFold(hay []byte, needle string) bool {
828 return strings.Contains(strings.ToLower(string(hay)), strings.ToLower(needle))
829}
830
831// designCSS is every stylesheet the templates are styled by, as one
832// string.
833func designCSS(t *testing.T) string {
834 t.Helper()
835 css := ""
836 for _, name := range []string{"tokens.css", "theme.css", "memoria.css"} {
837 raw, err := os.ReadFile(filepath.Join("..", "memoria", "static", name))
838 if err != nil {
839 t.Fatalf("read %s: %v", name, err)
840 }
841 css += string(raw)
842 }
843 return css
844}
845
846// Every `rst-` attribute a template writes is one the stylesheets
847// actually style.
848//
849// **Both halves of this gate came from shipping the mistake.** The
850// connectors page grew `rst-list-item-warn`, an attribute invented on the
851// spot that styles nothing; and `rst-callout="danger"` had been in
852// results.html since it was written, when tokens.css styles
853// `[rst-callout][data-tone="negative"]` — a value on the attribute itself
854// matches the base selector and no tone at all. Two tests pinned the
855// second one in place, because they were written from the same guess as
856// the markup.
857//
858// This is the frontend twin of AGENTS.md's rule about opening the section
859// you cite: the design system has a vocabulary, and a word that is not in
860// it renders as nothing at all rather than as an error. Nobody notices
861// until a screenshot.
862//
863// **The exemption list is the honest part.** Some attributes are hooks for
864// tests and scripts rather than for CSS, and they are named here one by
865// one — a blanket "ignore anything not found" would turn this gate back
866// into the thing it replaces.
867//
868// MUTATION-TESTED 2026-09-07, each verified red then reverted:
869//
870// add rst-list-item-warn back to connectors.html -> red, naming it
871// write rst-callout="danger" again -> red
872// misspell rst-callout-body -> red
873func TestEveryTemplateAttributeIsInTheDesignSystem(t *testing.T) {
874 css := designCSS(t)
875
876 // **Hooks, not styles**, and the list is derived rather than typed:
877 // anything one of the vendored scripts reads is a hook by definition,
878 // and pattern-matching that is the honest version of an exemption.
879 js := ""
880 for _, name := range []string{"rastrillo.js", "search.js", "drop.js"} {
881 raw, err := os.ReadFile(filepath.Join("..", "memoria", "static", name))
882 if err != nil {
883 t.Fatalf("read %s: %v", name, err)
884 }
885 js += string(raw)
886 }
887
888 // And the ones that are neither: **pre-existing attributes that style
889 // nothing and no script reads.** They render as plain elements, which
890 // is a silent defect rather than an error, and this gate found them
891 // the day it was written (2026-09-07).
892 //
893 // Listed rather than ignored, so the list is visible and can shrink.
894 // A blanket "skip what is not found" would turn this gate back into
895 // the absence of the gate.
896 // **Pre-existing attributes that style nothing and no script reads.**
897 // They render as plain elements — a silent defect rather than an
898 // error. Listed rather than pattern-skipped so the list is visible and
899 // can shrink; a blanket "ignore what is missing" would be the absence
900 // of the gate wearing its name.
901 //
902 // The list shrank with the shared chrome (2026-09-30): rst-cell-mut
903 // (a class-only utility, written as an attribute nineteen times) is
904 // now memoria.css's [rst-muted]; the Sources page's rst-list-item and
905 // rst-list-title are [rst-sources] rows; rst-facts is styled; the
906 // header's rst-brand and the Delete button's rst-danger are gone.
907 unstyled := templateUnstyled
908
909 // **Values this app writes that the design system does not define.**
910 // Same treatment and same reason as the list above: named, dated, and
911 // visible rather than pattern-skipped.
912 //
913 // `rst-btn` takes primary, ghost and danger. Every button in this app
914 // spelled "quiet" or "compact" therefore renders as the default
915 // button — the wrong weight in the header, on the sign-out control,
916 // and on the connector actions. Found 2026-09-07 by the value half of
917 // this gate, the day it could see values at all.
918 //
919 // **Not fixed here on purpose.** Which variant a "quiet compact"
920 // button should actually be is a visual decision about how the app
921 // looks, not a defect with one correct repair, and making it silently
922 // inside a commit about a circuit breaker would be the drive-by this
923 // project's AGENTS.md warns about. Recorded for Paul.
924 unstyledValues := map[string]bool{
925 "rst-btn=quiet": true,
926 "rst-btn=compact": true,
927 }
928 exempt := templateExempt
929 attr := rstWord
930
931 // **Whole tokens, never substrings**, and this is not fussiness. The
932 // first version tested membership with strings.Contains, and
933 // `rst-callout` is a prefix of `rst-callout-body` — which the scripts
934 // read — so every check involving rst-callout was satisfied by a
935 // different attribute that merely starts the same way. Two mutations
936 // survived on exactly that, including the one this gate exists for.
937 tokens := func(in string) map[string]bool {
938 out := map[string]bool{}
939 for _, m := range attr.FindAllString(in, -1) {
940 out[m] = true
941 }
942 return out
943 }
944
945 // **The stylesheet vocabulary comes from ATTRIBUTE selectors only**,
946 // `[rst-…`, and not from every rst- word in the file. Two things were
947 // wrong with reading the text: it counts words in comments, and — the
948 // one that matters — it counts the CLASS spelling of utilities that
949 // have no attribute twin. tokens.css says which those are
950 // ("Utilities keep the class spelling and are not paired: rst-sr-only,
951 // rst-mono, … rst-cell-mut"), memoria.css says `rst-cell-mut` written
952 // as an attribute selects nothing, and AGENTS.md says utilities stay
953 // classes. The gate said they were fine.
954 cssAttrs := attrSelectors(css)
955 jsAttrs := tokens(js)
956 dir := filepath.Join("..", "memoria", "templates")
957 entries, err := os.ReadDir(dir)
958 if err != nil {
959 t.Fatal(err)
960 }
961 seen := map[string]bool{}
962 for _, e := range entries {
963 if e.IsDir() || !strings.HasSuffix(e.Name(), ".html") {
964 continue
965 }
966 raw, err := os.ReadFile(filepath.Join(dir, e.Name()))
967 if err != nil {
968 t.Fatal(err)
969 }
970 for _, m := range attrNames(string(raw)) {
971 if unstyled[m] || seen[m] || jsAttrs[m] {
972 continue
973 }
974 seen[m] = true
975 if !cssAttrs[m] {
976 t.Errorf("%s writes %q, which no stylesheet mentions: an "+
977 "attribute outside the design system's vocabulary renders "+
978 "as nothing at all rather than as an error", e.Name(), m)
979 }
980 }
981 }
982 // The presence half: the walk really did find attributes, so the
983 // absence check above is an assertion rather than a description of an
984 // empty directory.
985 if len(seen) < 5 {
986 t.Fatalf("only %d attributes found across the templates; the walk is "+
987 "not reaching them and this gate proves nothing", len(seen))
988 }
989
990 // **And the half the name check cannot make.**
991 //
992 // `rst-callout="warn"` passes everything above: the NAME is in the
993 // stylesheet, and the value is simply ignored. That is precisely the
994 // bug this gate was written for — `rst-callout="danger"` shipped and
995 // rendered untoned, because tokens.css styles `[rst-callout]` plus
996 // `[data-tone="negative"]` and never a value on the attribute itself.
997 // A mutation restoring it stayed green against the name check alone.
998 //
999 // So: an attribute written WITH a value must be one the stylesheets
1000 // select on with a value. `rst-btn="primary compact"` is fine because
1001 // tokens.css contains `[rst-btn~="primary"]`; `rst-callout="warn"` is
1002 // not, because nothing anywhere selects on a value of rst-callout.
1003 // Derived from the CSS rather than listed, so it stays true as the
1004 // design system grows.
1005 valued := regexp.MustCompile(`(rst-[a-z0-9-]+)="([^"]*)"`)
1006 for _, e := range entries {
1007 if e.IsDir() || !strings.HasSuffix(e.Name(), ".html") {
1008 continue
1009 }
1010 raw, err := os.ReadFile(filepath.Join(dir, e.Name()))
1011 if err != nil {
1012 t.Fatal(err)
1013 }
1014 for _, m := range valued.FindAllStringSubmatch(string(raw), -1) {
1015 name, value := m[1], m[2]
1016 if exempt[name] || unstyled[name] || jsAttrs[name] {
1017 continue
1018 }
1019 // **Each value, not merely the attribute taking values.**
1020 // Checking that something somewhere selects on a value of
1021 // this name passes `rst-btn="nonsense"`, because
1022 // `[rst-btn~="primary"]` exists. A value nobody styles is the
1023 // same silent nothing as an attribute nobody styles.
1024 //
1025 // A template expression is skipped and its guarantee moves to
1026 // the function producing it — see tone().
1027 if strings.Contains(value, "{{") {
1028 continue
1029 }
1030 for _, word := range strings.Fields(value) {
1031 if unstyledValues[name+"="+word] {
1032 continue
1033 }
1034 if strings.Contains(css, name+`~="`+word+`"`) ||
1035 strings.Contains(css, name+`="`+word+`"`) {
1036 continue
1037 }
1038 t.Errorf("%s writes %s=%q, and no stylesheet selects "+
1039 "%s~=%q: the attribute matches, the value is ignored, "+
1040 "and the element renders unstyled",
1041 e.Name(), name, value, name, word)
1042 }
1043 }
1044 }
1045
1046 // data-tone carries the tones, so its values must be ones the
1047 // stylesheets define — a tone nobody styles is the same silent
1048 // nothing by another spelling.
1049 // The attribute spelling of a tone is `rst-tone`; `data-tone` is the
1050 // class-grammar half of the same pair and selects nothing beside
1051 // `[rst-callout]`. Both are checked, so writing either where the
1052 // other belongs is caught.
1053 tone := regexp.MustCompile(`(rst-tone|data-tone)="([^"]*)"`)
1054 for _, e := range entries {
1055 if e.IsDir() || !strings.HasSuffix(e.Name(), ".html") {
1056 continue
1057 }
1058 raw, err := os.ReadFile(filepath.Join(dir, e.Name()))
1059 if err != nil {
1060 t.Fatal(err)
1061 }
1062 for _, m := range tone.FindAllStringSubmatch(string(raw), -1) {
1063 if m[1] == "data-tone" {
1064 t.Errorf("%s writes data-tone=%q on an attribute-spelled "+
1065 "element: that is the class half of the pair, and it "+
1066 "selects nothing here. The attribute spelling is rst-tone",
1067 e.Name(), m[2])
1068 continue
1069 }
1070 if strings.Contains(m[2], "{{") {
1071 // A template expression. This gate reads files, not
1072 // renders, so it cannot know what this evaluates to —
1073 // and guessing would be worse than declining.
1074 //
1075 // **The guarantee moves rather than disappearing**: every
1076 // dynamic tone in this app comes from `memoria.tone`, and
1077 // TestEveryToneIsOneTheStylesheetsDefine asserts that
1078 // function's whole range against the same stylesheets.
1079 // One place produces them, so one test covers them.
1080 continue
1081 }
1082 if !strings.Contains(css, `[rst-callout][rst-tone~="`+m[2]+`"]`) {
1083 t.Errorf("%s writes rst-tone=%q, which no stylesheet selects "+
1084 "on: the callout renders untoned", e.Name(), m[2])
1085 }
1086 }
1087 }
1088}
1089
1090// rstWord is one rst- name, wherever it is written.
1091var rstWord = regexp.MustCompile(`rst-[a-z0-9-]+`)
1092
1093// templateExempt names rst- words the design-system gate does not hold
1094// to the stylesheets at all.
1095var templateExempt = map[string]bool{
1096 "rst-withheld": true, // a test hook for §6.5's notice block
1097}
1098
1099// classOnlyUtilities are tokens.css utilities that keep the class
1100// spelling and have no attribute twin ("Utilities keep the class
1101// spelling and are not paired"). Inside a class="…" value they are
1102// correct and the gate skips them; anywhere else they style nothing and
1103// the gate reports them like any other unstyled name. This gate reads
1104// rst- words in class values too, and select.js's mention of
1105// rst-sr-only used to satisfy it; select.js went when the app moved off
1106// rastrillo's frozen path (no page links it).
1107var classOnlyUtilities = map[string]bool{
1108 "rst-sr-only": true,
1109}
1110
1111// classValue is one class attribute and its value, either quote.
1112var classValue = regexp.MustCompile(`(\sclass=)("[^"]*"|'[^']*')`)
1113
1114// **Pre-existing attributes that style nothing and no script reads.**
1115// See TestEveryTemplateAttributeIsInTheDesignSystem.
1116var templateUnstyled = map[string]bool{
1117 "rst-lede": true,
1118 "rst-steps": true,
1119 "rst-inline": true,
1120 "rst-bg": true,
1121}
1122
1123// attrSelectors is the stylesheets' ATTRIBUTE vocabulary: the names
1124// written as `[rst-…` selectors, never the class spelling.
1125func attrSelectors(css string) map[string]bool {
1126 out := map[string]bool{}
1127 for _, m := range regexp.MustCompile(`\[(rst-[a-z0-9-]+)`).FindAllStringSubmatch(css, -1) {
1128 out[m[1]] = true
1129 }
1130 return out
1131}
1132
1133// attrNames is every rst- name a template writes that the gate must
1134// find in the stylesheets' attribute vocabulary, exemptions removed.
1135func attrNames(markup string) []string {
1136 // Native details groups use name values, not styled attributes.
1137 // Mutation-checked 2026-09-11: rst-not-a-real-attribute still fails.
1138 markup = strings.ReplaceAll(markup, `name="rst-menus"`, `name="menu-group"`)
1139 // Class-only utilities leave the class values they belong in, and
1140 // only those: written as an attribute they are still scanned.
1141 markup = classValue.ReplaceAllStringFunc(markup, func(m string) string {
1142 return rstWord.ReplaceAllStringFunc(m, func(w string) string {
1143 if classOnlyUtilities[w] {
1144 return ""
1145 }
1146 return w
1147 })
1148 })
1149 var out []string
1150 for _, m := range rstWord.FindAllString(markup, -1) {
1151 if templateExempt[m] {
1152 continue
1153 }
1154 out = append(out, m)
1155 }
1156 return out
1157}
1158
1159// A class-only utility is exempt as a CLASS and only as a class.
1160// tokens.css styles .rst-sr-only and has no [rst-sr-only] twin, so
1161// `<p rst-sr-only>` renders on screen — the very text the utility was
1162// meant to hide. The exemption used to be a bare word, which waved that
1163// through along with the class it was written for (Astra, memoria #24).
1164func TestClassOnlyUtilityPassesOnlyAsAClass(t *testing.T) {
1165 cssAttrs := attrSelectors(designCSS(t))
1166 if cssAttrs["rst-sr-only"] {
1167 t.Fatal("the stylesheets now select [rst-sr-only]; the attribute spelling is styled and this test's premise is gone")
1168 }
1169 unstyled := func(markup string) []string {
1170 var out []string
1171 for _, m := range attrNames(markup) {
1172 if !cssAttrs[m] && !templateUnstyled[m] {
1173 out = append(out, m)
1174 }
1175 }
1176 return out
1177 }
1178 for _, ok := range []string{
1179 `<label for="q" class="rst-sr-only">Search</label>`,
1180 `<p class="rst-sr-only" role="status"></p>`,
1181 `<p class="hint rst-sr-only big">x</p>`,
1182 `<p class='rst-sr-only'>x</p>`,
1183 } {
1184 if got := unstyled(ok); len(got) != 0 {
1185 t.Errorf("%s: the gate rejects %v; a class-only utility written as a class is correct", ok, got)
1186 }
1187 }
1188 for _, bad := range []string{
1189 `<p rst-sr-only>x</p>`,
1190 `<p rst-sr-only="">x</p>`,
1191 `<p rst-sr-only class="hint">x</p>`,
1192 `<p class="hint" rst-sr-only>x</p>`,
1193 `<p data-x="rst-sr-only">x</p>`,
1194 } {
1195 if got := unstyled(bad); len(got) != 1 || got[0] != "rst-sr-only" {
1196 t.Errorf("%s: the gate reports %v, want [rst-sr-only]: written as an attribute the utility styles nothing", bad, got)
1197 }
1198 }
1199}
1200