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 "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
57func 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.
224func 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.
248var 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