package memoriatest import ( "fmt" "go/ast" "go/parser" "go/token" "os" "path/filepath" "strconv" "strings" "testing" ) // Gate 17, spec §10: no route exists that the spec has not listed. // // This is the regression gate for the sibling-facing push surface draft 1 // shipped by accident, and — since decision 14 — the gate that makes // "memoria is a search page, not a chat" enforceable rather than prose. §10 // had claimed this gate since draft 1 while nothing walked the router. // // It keys on METHOD and path together, because that is what §10's list // declares. The first version keyed on path alone, and a review // mutation-tested it and found five idioms that walked straight past: // r.Route("/chat", …) with a declared "/" inside it; r.With(mw).Post, // where the receiver is a call and not an identifier; r.Delete on a path // declared only for GET; r.Handle("/"), which is every method at once; and // worst, mux.HandleFunc("/documents", …), which serves a DECLARED path // while bypassing requireSession entirely. Path-only blessed all five. // // The two namespaces are kept apart on purpose. A chi registration carries // its method in the call (r.Get); an http.ServeMux pattern carries it in // the string ("GET /static/"). Mixing them let "/" bless both mux.Handle // and a chi r.Post at once. // // Known limits, stated because a gate's honest scope is part of it: // - A route registered by a helper in a package this walk does not cover // is not seen. The walk covers internal/memoria and cmd. // - Chi's Route nests patterns; this checks outer and inner patterns // against one flat list, so adopting Route means adding its relative // segments here. That errs toward failing, which is the right direction. // - A receiver renamed away from r/mux is not seen. The declared list is // the real control; this walk is only the finder. // // MUTATION-TESTED 2026-09-05, each reverted. The first three were on the // path-only version and still hold; the last five are the ones a review // found green and this version now catches: // // r.Post("/chat", a.index) -> undeclared route: POST /chat // r.Get(root, a.index) with root a var -> route pattern is not a literal // walk root pointed at an empty directory -> seen == 0 // r.Route("/chat", func(r chi.Router){...}) -> undeclared route: ANY /chat // r.With(mw).Post("/chat", a.index) -> undeclared route: POST /chat // r.Delete("/documents/{id}", a.index) -> undeclared route: DELETE /documents/{id} // r.Handle("/", h) -> undeclared route: ANY / // mux.HandleFunc("/documents", a.index) -> undeclared mux pattern: /documents func TestNoUndeclaredRoutes(t *testing.T) { // The list is at package scope — see declaredChiRoutes. declaredChi := declaredChiRoutes // http.ServeMux patterns, verbatim — these are mounts, and the method // (where there is one) lives inside the string. declaredMux := map[string]bool{ "GET /static/credits.css": true, "GET /static/": true, "GET /static/estilo/fonts/v1/": true, // The sign-in door's sky layers: public artwork, no session, // the same bargain as the font. TestSkyRouteIsEstilosPrefix // keeps the literal equal to sky.Prefix. "GET /static/estilo/sky/v1/": true, "/": true, "/auth/": true, "POST /signout": true, } // Router method -> index of the pattern argument. Handle, HandleFunc, // Mount and Route bind every method, so they read as ANY. patternArg := map[string]int{ "Get": 0, "Post": 0, "Put": 0, "Patch": 0, "Delete": 0, "Head": 0, "Options": 0, "Connect": 0, "Trace": 0, "Handle": 0, "HandleFunc": 0, "Mount": 0, "Route": 0, "Method": 1, "MethodFunc": 1, } anyMethod := map[string]bool{ "Handle": true, "HandleFunc": true, "Mount": true, "Route": true, } // chi's chaining constructors, and only these. Following an arbitrary // selector chain instead was the first attempt and it was wrong: `r` is // also the conventional name for an *http.Request, so // r.URL.Query().Get("err") resolved to "r" and reported the query // parameter as an undeclared GET route. Two real handler lines, caught // by running it. chainThrough := map[string]bool{"With": true, "Group": true, "Route": true} // rootReceiver resolves r.With(mw).Post back to r, and refuses to // resolve anything else, so the walk sees chi's standard middleware // spelling without inventing routes out of request accessors. var rootReceiver func(ast.Expr) string rootReceiver = func(e ast.Expr) string { switch v := e.(type) { case *ast.Ident: return v.Name case *ast.CallExpr: if s, ok := v.Fun.(*ast.SelectorExpr); ok && chainThrough[s.Sel.Name] { return rootReceiver(s.X) } } return "" } var problems []string seen := 0 inspect := func(path string, fset *token.FileSet, file *ast.File) { ast.Inspect(file, func(n ast.Node) bool { call, ok := n.(*ast.CallExpr) if !ok { return true } sel, ok := call.Fun.(*ast.SelectorExpr) if !ok { return true } recv := rootReceiver(sel.X) if recv != "r" && recv != "mux" { return true } idx, ok := patternArg[sel.Sel.Name] if !ok || len(call.Args) <= idx { return true } seen++ line := fset.Position(call.Pos()).Line lit, ok := call.Args[idx].(*ast.BasicLit) if !ok || lit.Kind != token.STRING { problems = append(problems, fmt.Sprintf( "%s:%d: route pattern is not a literal, so this gate cannot read it", path, line)) return true } pattern, err := strconv.Unquote(lit.Value) if err != nil { problems = append(problems, fmt.Sprintf( "%s:%d: unreadable route pattern %s", path, line, lit.Value)) return true } if recv == "mux" { if !declaredMux[pattern] { problems = append(problems, fmt.Sprintf( "%s:%d: undeclared mux pattern: %s", path, line, pattern)) } return true } method := strings.ToUpper(sel.Sel.Name) if anyMethod[sel.Sel.Name] { method = "ANY" } if key := method + " " + pattern; !declaredChi[key] { problems = append(problems, fmt.Sprintf( "%s:%d: undeclared route: %s", path, line, key)) } return true }) } walk := func(root string) error { return filepath.Walk(root, func(p string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() || !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") { return nil } fset := token.NewFileSet() file, err := parser.ParseFile(fset, p, nil, parser.SkipObjectResolution) if err != nil { return err } inspect(p, fset, file) return nil }) } for _, root := range []string{ filepath.Join("..", "memoria"), filepath.Join("..", "..", "cmd"), } { if err := walk(root); err != nil { t.Fatal(err) } } // A count that fails is not a count of zero (AGENTS.md). If the walk // found no registrations at all then the finder is broken, and a green // gate over an empty set is this codebase's worst failure shape. // // Note what this does and does not prove: cmd/ registers no routes // today, so emptying either root alone still trips it. It is a smoke // check on the finder, not a census of what the finder should see. if seen == 0 { t.Fatal("found no route registrations at all: the walk is broken, not the app clean") } for _, p := range problems { t.Error(p) } } // Production mounts auth through MountAuth, which is where the sign-in // guard is attached. // // An adversarial pass mutated `main.go` to build its own chi router, // register `POST /auth/begin` on it bare, and hand it to the mux — and // every test stayed green, because the tests build their own app and // nothing observed how production wires itself. Gate 17's route walk did // not catch it either: it follows receivers named r and mux, and states // that limit honestly. // // This is a source check and it buys one thing: main cannot quietly stop // going through MountAuth. It does not prove the guard works — gate 33 // does that — only that production is the thing gate 33 tested. // // MUTATION-TESTED 2026-09-05: deleting either call from main.go fails // here. Reverted. func TestProductionMountsAuthThroughTheGuardedPath(t *testing.T) { src, err := os.ReadFile(filepath.Join("..", "..", "cmd", "memoria", "main.go")) if err != nil { t.Fatalf("read main.go: %v", err) } for _, want := range []string{"UseAuth(", "MountAuth("} { if !strings.Contains(string(src), want) { t.Errorf("cmd/memoria/main.go does not call %s — auth is being wired "+ "some other way, and the sign-in guard is attached inside "+ "MountAuth", want) } } } // declaredChiRoutes is every chi registration, as METHOD + pattern. // // Package scope, 2026-09-27, so a second gate can walk it: the footer's // egress claim must be right on EVERY page, and a hand-written list of // pages is how the first build of §8.9 missed the footer altogether. // TestNoUndeclaredRoutes is unchanged in behaviour. // // §9.1's /query is deliberately absent: the app serves GET / and it is the // spec that is wrong, which §8.4 records. Adding to this list is a // decision, which is the point of it being a list. var declaredChiRoutes = map[string]bool{ "POST /native/request": true, "POST /native/exchange": true, "GET /native/start": true, "GET /native/authorize": true, "POST /native/authorize": true, "GET /api/native/session": true, "POST /api/native/notes": true, "POST /api/native/search": true, "GET /help/open-source": true, "GET /signin": true, "GET /signin/local": true, "GET /signed-out": true, "GET /account/home": true, "POST /account/home": true, "GET /account/home/confirm": true, "POST /account/home/confirm": true, "GET /auth/start": true, "POST /auth/assertion": true, "GET /auth/finish": true, "GET /auth/local/complete": true, "GET /": true, // Adding content, moved off the index to its own page 2026-09-29. "GET /add": true, // GET /search renders the results region alone, from the same // template GET /?q= renders it inside a page, so live search can // replace that region on a keystroke. Same session gate, no passage // the page did not already speak; delete the script and nothing // calls it. Declared 2026-09-06 when this gate caught it arriving // from main — the second time in one merge that a route could not // reach the app until somebody wrote it down. "GET /search": true, "POST /documents": true, "GET /documents/{id}": true, "POST /documents/{id}/delete": true, // The shared chrome (estilo spec/shared-chrome, round 2), declared // 2026-09-30: // - GET /documents/{id}/delete asks before a delete (it was a // one-click POST); same session gate as the POST beside it. // - POST /appearance is estilo's chrome.HandleAppearance: one // host-only preference cookie and a SafeReturn redirect. Inside // the app-wide CSRF check, deliberately OUTSIDE the session gate // (§8.3), and it reads and writes no content. // - GET /auth/landed is where a verified magic link lands // (standalone), and on to the page the person was heading for. "GET /documents/{id}/delete": true, "POST /appearance": true, "GET /auth/landed": true, "POST /auth/begin": true, "GET /auth/callback": true, "GET /auth/verify": true, // rastrillo #64 (pin fb806a2, 2026-10-06): GET /auth/verify is the // confirm door and spends nothing; this POST, from its Sign in // button, redeems the link. auth's own same-origin check runs before // the token is touched; in Home mode it sits under LocalGuard like // the GET. "POST /auth/verify": true, "POST /signout": true, // The connector surface (§5.1), added by the Notion work and // declared here on 2026-09-06 when this gate caught it — which is // the gate working: five new routes arrived on main and none of // them could reach the app until somebody wrote them down. // // All five sit inside requireOwner AND requireSession. They are // memoria-owned content's ingest path (§4.2), not a sibling // surface: decision 7 still holds, and §9.1's rule that a sibling // presenting a connector token is refused is a different gate. "GET /connectors": true, "GET /connectors/notion": true, "POST /connectors/notion": true, "POST /connectors/notion/sync": true, "POST /connectors/notion/disconnect": true, }