| 1 | package memoriatest |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "go/ast" |
| 6 | "go/parser" |
| 7 | "go/token" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strconv" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | ) |
| 14 | |
| 15 | // Gate 17, spec §10: no route exists that the spec has not listed. |
| 16 | // |
| 17 | // This is the regression gate for the sibling-facing push surface draft 1 |
| 18 | // shipped by accident, and — since decision 14 — the gate that makes |
| 19 | // "memoria is a search page, not a chat" enforceable rather than prose. §10 |
| 20 | // had claimed this gate since draft 1 while nothing walked the router. |
| 21 | // |
| 22 | // It keys on METHOD and path together, because that is what §10's list |
| 23 | // declares. The first version keyed on path alone, and a review |
| 24 | // mutation-tested it and found five idioms that walked straight past: |
| 25 | // r.Route("/chat", …) with a declared "/" inside it; r.With(mw).Post, |
| 26 | // where the receiver is a call and not an identifier; r.Delete on a path |
| 27 | // declared only for GET; r.Handle("/"), which is every method at once; and |
| 28 | // worst, mux.HandleFunc("/documents", …), which serves a DECLARED path |
| 29 | // while bypassing requireSession entirely. Path-only blessed all five. |
| 30 | // |
| 31 | // The two namespaces are kept apart on purpose. A chi registration carries |
| 32 | // its method in the call (r.Get); an http.ServeMux pattern carries it in |
| 33 | // the string ("GET /static/"). Mixing them let "/" bless both mux.Handle |
| 34 | // and a chi r.Post at once. |
| 35 | // |
| 36 | // Known limits, stated because a gate's honest scope is part of it: |
| 37 | // - A route registered by a helper in a package this walk does not cover |
| 38 | // is not seen. The walk covers internal/memoria and cmd. |
| 39 | // - Chi's Route nests patterns; this checks outer and inner patterns |
| 40 | // against one flat list, so adopting Route means adding its relative |
| 41 | // segments here. That errs toward failing, which is the right direction. |
| 42 | // - A receiver renamed away from r/mux is not seen. The declared list is |
| 43 | // the real control; this walk is only the finder. |
| 44 | // |
| 45 | // MUTATION-TESTED 2026-09-05, each reverted. The first three were on the |
| 46 | // path-only version and still hold; the last five are the ones a review |
| 47 | // found green and this version now catches: |
| 48 | // |
| 49 | // r.Post("/chat", a.index) -> undeclared route: POST /chat |
| 50 | // r.Get(root, a.index) with root a var -> route pattern is not a literal |
| 51 | // walk root pointed at an empty directory -> seen == 0 |
| 52 | // r.Route("/chat", func(r chi.Router){...}) -> undeclared route: ANY /chat |
| 53 | // r.With(mw).Post("/chat", a.index) -> undeclared route: POST /chat |
| 54 | // r.Delete("/documents/{id}", a.index) -> undeclared route: DELETE /documents/{id} |
| 55 | // r.Handle("/", h) -> undeclared route: ANY / |
| 56 | // mux.HandleFunc("/documents", a.index) -> undeclared mux pattern: /documents |
| 57 | func TestNoUndeclaredRoutes(t *testing.T) { |
| 58 | // The list is at package scope — see declaredChiRoutes. |
| 59 | declaredChi := declaredChiRoutes |
| 60 | // http.ServeMux patterns, verbatim — these are mounts, and the method |
| 61 | // (where there is one) lives inside the string. |
| 62 | declaredMux := map[string]bool{ |
| 63 | "GET /static/credits.css": true, |
| 64 | "GET /static/": true, |
| 65 | "GET /static/estilo/fonts/v1/": true, |
| 66 | // The sign-in door's sky layers: public artwork, no session, |
| 67 | // the same bargain as the font. TestSkyRouteIsEstilosPrefix |
| 68 | // keeps the literal equal to sky.Prefix. |
| 69 | "GET /static/estilo/sky/v1/": true, |
| 70 | "/": true, |
| 71 | "/auth/": true, |
| 72 | "POST /signout": true, |
| 73 | } |
| 74 | // Router method -> index of the pattern argument. Handle, HandleFunc, |
| 75 | // Mount and Route bind every method, so they read as ANY. |
| 76 | patternArg := map[string]int{ |
| 77 | "Get": 0, "Post": 0, "Put": 0, "Patch": 0, "Delete": 0, |
| 78 | "Head": 0, "Options": 0, "Connect": 0, "Trace": 0, |
| 79 | "Handle": 0, "HandleFunc": 0, "Mount": 0, "Route": 0, |
| 80 | "Method": 1, "MethodFunc": 1, |
| 81 | } |
| 82 | anyMethod := map[string]bool{ |
| 83 | "Handle": true, "HandleFunc": true, "Mount": true, "Route": true, |
| 84 | } |
| 85 | |
| 86 | // chi's chaining constructors, and only these. Following an arbitrary |
| 87 | // selector chain instead was the first attempt and it was wrong: `r` is |
| 88 | // also the conventional name for an *http.Request, so |
| 89 | // r.URL.Query().Get("err") resolved to "r" and reported the query |
| 90 | // parameter as an undeclared GET route. Two real handler lines, caught |
| 91 | // by running it. |
| 92 | chainThrough := map[string]bool{"With": true, "Group": true, "Route": true} |
| 93 | |
| 94 | // rootReceiver resolves r.With(mw).Post back to r, and refuses to |
| 95 | // resolve anything else, so the walk sees chi's standard middleware |
| 96 | // spelling without inventing routes out of request accessors. |
| 97 | var rootReceiver func(ast.Expr) string |
| 98 | rootReceiver = func(e ast.Expr) string { |
| 99 | switch v := e.(type) { |
| 100 | case *ast.Ident: |
| 101 | return v.Name |
| 102 | case *ast.CallExpr: |
| 103 | if s, ok := v.Fun.(*ast.SelectorExpr); ok && chainThrough[s.Sel.Name] { |
| 104 | return rootReceiver(s.X) |
| 105 | } |
| 106 | } |
| 107 | return "" |
| 108 | } |
| 109 | |
| 110 | var problems []string |
| 111 | seen := 0 |
| 112 | |
| 113 | inspect := func(path string, fset *token.FileSet, file *ast.File) { |
| 114 | ast.Inspect(file, func(n ast.Node) bool { |
| 115 | call, ok := n.(*ast.CallExpr) |
| 116 | if !ok { |
| 117 | return true |
| 118 | } |
| 119 | sel, ok := call.Fun.(*ast.SelectorExpr) |
| 120 | if !ok { |
| 121 | return true |
| 122 | } |
| 123 | recv := rootReceiver(sel.X) |
| 124 | if recv != "r" && recv != "mux" { |
| 125 | return true |
| 126 | } |
| 127 | idx, ok := patternArg[sel.Sel.Name] |
| 128 | if !ok || len(call.Args) <= idx { |
| 129 | return true |
| 130 | } |
| 131 | seen++ |
| 132 | line := fset.Position(call.Pos()).Line |
| 133 | lit, ok := call.Args[idx].(*ast.BasicLit) |
| 134 | if !ok || lit.Kind != token.STRING { |
| 135 | problems = append(problems, fmt.Sprintf( |
| 136 | "%s:%d: route pattern is not a literal, so this gate cannot read it", |
| 137 | path, line)) |
| 138 | return true |
| 139 | } |
| 140 | pattern, err := strconv.Unquote(lit.Value) |
| 141 | if err != nil { |
| 142 | problems = append(problems, fmt.Sprintf( |
| 143 | "%s:%d: unreadable route pattern %s", path, line, lit.Value)) |
| 144 | return true |
| 145 | } |
| 146 | if recv == "mux" { |
| 147 | if !declaredMux[pattern] { |
| 148 | problems = append(problems, fmt.Sprintf( |
| 149 | "%s:%d: undeclared mux pattern: %s", path, line, pattern)) |
| 150 | } |
| 151 | return true |
| 152 | } |
| 153 | method := strings.ToUpper(sel.Sel.Name) |
| 154 | if anyMethod[sel.Sel.Name] { |
| 155 | method = "ANY" |
| 156 | } |
| 157 | if key := method + " " + pattern; !declaredChi[key] { |
| 158 | problems = append(problems, fmt.Sprintf( |
| 159 | "%s:%d: undeclared route: %s", path, line, key)) |
| 160 | } |
| 161 | return true |
| 162 | }) |
| 163 | } |
| 164 | |
| 165 | walk := func(root string) error { |
| 166 | return filepath.Walk(root, func(p string, info os.FileInfo, err error) error { |
| 167 | if err != nil { |
| 168 | return err |
| 169 | } |
| 170 | if info.IsDir() || !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") { |
| 171 | return nil |
| 172 | } |
| 173 | fset := token.NewFileSet() |
| 174 | file, err := parser.ParseFile(fset, p, nil, parser.SkipObjectResolution) |
| 175 | if err != nil { |
| 176 | return err |
| 177 | } |
| 178 | inspect(p, fset, file) |
| 179 | return nil |
| 180 | }) |
| 181 | } |
| 182 | |
| 183 | for _, root := range []string{ |
| 184 | filepath.Join("..", "memoria"), |
| 185 | filepath.Join("..", "..", "cmd"), |
| 186 | } { |
| 187 | if err := walk(root); err != nil { |
| 188 | t.Fatal(err) |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | // A count that fails is not a count of zero (AGENTS.md). If the walk |
| 193 | // found no registrations at all then the finder is broken, and a green |
| 194 | // gate over an empty set is this codebase's worst failure shape. |
| 195 | // |
| 196 | // Note what this does and does not prove: cmd/ registers no routes |
| 197 | // today, so emptying either root alone still trips it. It is a smoke |
| 198 | // check on the finder, not a census of what the finder should see. |
| 199 | if seen == 0 { |
| 200 | t.Fatal("found no route registrations at all: the walk is broken, not the app clean") |
| 201 | } |
| 202 | |
| 203 | for _, p := range problems { |
| 204 | t.Error(p) |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | // Production mounts auth through MountAuth, which is where the sign-in |
| 209 | // guard is attached. |
| 210 | // |
| 211 | // An adversarial pass mutated `main.go` to build its own chi router, |
| 212 | // register `POST /auth/begin` on it bare, and hand it to the mux — and |
| 213 | // every test stayed green, because the tests build their own app and |
| 214 | // nothing observed how production wires itself. Gate 17's route walk did |
| 215 | // not catch it either: it follows receivers named r and mux, and states |
| 216 | // that limit honestly. |
| 217 | // |
| 218 | // This is a source check and it buys one thing: main cannot quietly stop |
| 219 | // going through MountAuth. It does not prove the guard works — gate 33 |
| 220 | // does that — only that production is the thing gate 33 tested. |
| 221 | // |
| 222 | // MUTATION-TESTED 2026-09-05: deleting either call from main.go fails |
| 223 | // here. Reverted. |
| 224 | func TestProductionMountsAuthThroughTheGuardedPath(t *testing.T) { |
| 225 | src, err := os.ReadFile(filepath.Join("..", "..", "cmd", "memoria", "main.go")) |
| 226 | if err != nil { |
| 227 | t.Fatalf("read main.go: %v", err) |
| 228 | } |
| 229 | for _, want := range []string{"UseAuth(", "MountAuth("} { |
| 230 | if !strings.Contains(string(src), want) { |
| 231 | t.Errorf("cmd/memoria/main.go does not call %s — auth is being wired "+ |
| 232 | "some other way, and the sign-in guard is attached inside "+ |
| 233 | "MountAuth", want) |
| 234 | } |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | // declaredChiRoutes is every chi registration, as METHOD + pattern. |
| 239 | // |
| 240 | // Package scope, 2026-09-27, so a second gate can walk it: the footer's |
| 241 | // egress claim must be right on EVERY page, and a hand-written list of |
| 242 | // pages is how the first build of §8.9 missed the footer altogether. |
| 243 | // TestNoUndeclaredRoutes is unchanged in behaviour. |
| 244 | // |
| 245 | // §9.1's /query is deliberately absent: the app serves GET / and it is the |
| 246 | // spec that is wrong, which §8.4 records. Adding to this list is a |
| 247 | // decision, which is the point of it being a list. |
| 248 | var declaredChiRoutes = map[string]bool{ |
| 249 | "POST /native/request": true, |
| 250 | "POST /native/exchange": true, |
| 251 | "GET /native/start": true, |
| 252 | "GET /native/authorize": true, |
| 253 | "POST /native/authorize": true, |
| 254 | "GET /api/native/session": true, |
| 255 | "POST /api/native/notes": true, |
| 256 | "POST /api/native/search": true, |
| 257 | "GET /help/open-source": true, |
| 258 | "GET /signin": true, |
| 259 | "GET /signin/local": true, |
| 260 | "GET /signed-out": true, |
| 261 | "GET /account/home": true, |
| 262 | "POST /account/home": true, |
| 263 | "GET /account/home/confirm": true, |
| 264 | "POST /account/home/confirm": true, |
| 265 | "GET /auth/start": true, |
| 266 | "POST /auth/assertion": true, |
| 267 | "GET /auth/finish": true, |
| 268 | "GET /auth/local/complete": true, |
| 269 | "GET /": true, |
| 270 | // Adding content, moved off the index to its own page 2026-09-29. |
| 271 | "GET /add": true, |
| 272 | // GET /search renders the results region alone, from the same |
| 273 | // template GET /?q= renders it inside a page, so live search can |
| 274 | // replace that region on a keystroke. Same session gate, no passage |
| 275 | // the page did not already speak; delete the script and nothing |
| 276 | // calls it. Declared 2026-09-06 when this gate caught it arriving |
| 277 | // from main — the second time in one merge that a route could not |
| 278 | // reach the app until somebody wrote it down. |
| 279 | "GET /search": true, |
| 280 | "POST /documents": true, |
| 281 | "GET /documents/{id}": true, |
| 282 | "POST /documents/{id}/delete": true, |
| 283 | // The shared chrome (estilo spec/shared-chrome, round 2), declared |
| 284 | // 2026-09-30: |
| 285 | // - GET /documents/{id}/delete asks before a delete (it was a |
| 286 | // one-click POST); same session gate as the POST beside it. |
| 287 | // - POST /appearance is estilo's chrome.HandleAppearance: one |
| 288 | // host-only preference cookie and a SafeReturn redirect. Inside |
| 289 | // the app-wide CSRF check, deliberately OUTSIDE the session gate |
| 290 | // (§8.3), and it reads and writes no content. |
| 291 | // - GET /auth/landed is where a verified magic link lands |
| 292 | // (standalone), and on to the page the person was heading for. |
| 293 | "GET /documents/{id}/delete": true, |
| 294 | "POST /appearance": true, |
| 295 | "GET /auth/landed": true, |
| 296 | "POST /auth/begin": true, |
| 297 | "GET /auth/callback": true, |
| 298 | "GET /auth/verify": true, |
| 299 | // rastrillo #64 (pin fb806a2, 2026-10-06): GET /auth/verify is the |
| 300 | // confirm door and spends nothing; this POST, from its Sign in |
| 301 | // button, redeems the link. auth's own same-origin check runs before |
| 302 | // the token is touched; in Home mode it sits under LocalGuard like |
| 303 | // the GET. |
| 304 | "POST /auth/verify": true, |
| 305 | "POST /signout": true, |
| 306 | |
| 307 | // The connector surface (§5.1), added by the Notion work and |
| 308 | // declared here on 2026-09-06 when this gate caught it — which is |
| 309 | // the gate working: five new routes arrived on main and none of |
| 310 | // them could reach the app until somebody wrote them down. |
| 311 | // |
| 312 | // All five sit inside requireOwner AND requireSession. They are |
| 313 | // memoria-owned content's ingest path (§4.2), not a sibling |
| 314 | // surface: decision 7 still holds, and §9.1's rule that a sibling |
| 315 | // presenting a connector token is refused is a different gate. |
| 316 | "GET /connectors": true, |
| 317 | "GET /connectors/notion": true, |
| 318 | "POST /connectors/notion": true, |
| 319 | "POST /connectors/notion/sync": true, |
| 320 | "POST /connectors/notion/disconnect": true, |
| 321 | } |
| 322 | |