| 1 | package memoriatest |
| 2 | |
| 3 | import ( |
| 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. |
| 23 | const 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. |
| 37 | func 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. |
| 106 | func 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. |
| 186 | func 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. |
| 332 | func 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. |
| 397 | func 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. |
| 445 | func 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. |
| 461 | func 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. |
| 555 | func 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. |
| 589 | func 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 |
| 635 | func 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. |
| 710 | func 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. |
| 751 | func 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. |
| 776 | func 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. |
| 808 | func 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 | |
| 823 | func envFrom(m map[string]string) func(string) string { |
| 824 | return func(k string) string { return m[k] } |
| 825 | } |
| 826 | |
| 827 | func 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. |
| 833 | func 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 |
| 873 | func 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. |
| 1091 | var 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. |
| 1095 | var 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). |
| 1107 | var classOnlyUtilities = map[string]bool{ |
| 1108 | "rst-sr-only": true, |
| 1109 | } |
| 1110 | |
| 1111 | // classValue is one class attribute and its value, either quote. |
| 1112 | var classValue = regexp.MustCompile(`(\sclass=)("[^"]*"|'[^']*')`) |
| 1113 | |
| 1114 | // **Pre-existing attributes that style nothing and no script reads.** |
| 1115 | // See TestEveryTemplateAttributeIsInTheDesignSystem. |
| 1116 | var 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. |
| 1125 | func 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. |
| 1135 | func 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). |
| 1164 | func 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 | |