package docs import ( "bytes" "encoding/json" "flag" "os" "path/filepath" "reflect" "regexp" "strings" "testing" ) // The drift gate: every doc that declares an endpoint in frontmatter must // (1) match a real route registration, (2) match its middleware-derived auth // label, and (3) have request/response examples compatible with the schema // registry. Docs without frontmatter (overviews, contracts) are out of scope. // // Run verbose to see UNVERIFIED markers left by drafting passes: // // go test ./internal/docs/ -run TestDocsMatchRoutes -v // ---------- route extraction ---------- type extractedRoute struct { method string path string middleware []string } var ( groupRe = regexp.MustCompile(`^(\w+)\s*:=\s*(\S+)\.Group\("([^"]*)"(?:,\s*(.+))?\)`) useRe = regexp.MustCompile(`^(\w+)\.Use\((.+)\)$`) echoRouteRe = regexp.MustCompile(`^(?:e|cfg\.Echo)\.(GET|POST|PUT|PATCH|DELETE)\("([^"]*)"(?:,\s*(.+))?\)`) groupRouteRe = regexp.MustCompile(`^(\w+)\.(GET|POST|PUT|PATCH|DELETE)\("([^"]*)"(?:,\s*(.+))?\)`) ) // extractRoutesFromRouterSource parses internal/router/*.go the way the echo // router resolves them: group prefixes concatenate, and some files receive // their group as a function parameter (registerScannerRoutes(protected, ...)). // This is a test — if it drifts from reality, a doc vs route mismatch shows up // in live behavior; treat failures here as "the parser needs updating", never // as license to edit docs to match the parser. func extractRoutesFromRouterSource(t *testing.T) map[string]extractedRoute { t.Helper() type group struct { prefix string middleware []string } groups := map[string]group{ "e": {}, "cfg.Echo": {}, } files, err := filepath.Glob("../router/*.go") if err != nil || len(files) == 0 { t.Fatalf("cannot read router sources: %v", err) } routes := map[string]extractedRoute{} add := func(method, path string, mw []string) { routes[method+" "+path] = extractedRoute{method, path, mw} } for _, file := range files { base := filepath.Base(file) if strings.HasSuffix(base, "_test.go") { continue } // registerScannerRoutes receives its groups as parameters; seed the // names the call site actually passes (router.go: protected=/api jwt, // admin=/api jwt+admin). if base == "scanner.go" { // Middleware values must carry the marker substrings authLabel // matches on (AdminMiddleware, createJWTMiddleware) — the seeded // "jwt"/"admin" tokens were invisible to it. groups["protected"] = group{prefix: "/api", middleware: []string{"jwtMiddleware"}} groups["admin"] = group{prefix: "/api", middleware: []string{"jwtMiddleware", "AdminMiddleware"}} } raw, err := os.ReadFile(file) if err != nil { t.Fatalf("read %s: %v", file, err) } for _, line := range strings.Split(string(raw), "\n") { s := strings.TrimSpace(line) if m := groupRe.FindStringSubmatch(s); m != nil { varname, parent, prefix, mwArg := m[1], m[2], m[3], m[4] parentInfo := groups[parent] g := group{ prefix: strings.TrimSuffix(parentInfo.prefix+prefix, "/"), middleware: append(append([]string{}, parentInfo.middleware...), splitMW(mwArg)...), } groups[varname] = g continue } //opds-style middleware: `grp.Use(cfg.DeviceAuthMiddleware.Authenticate)` //applies to every route registered on that group afterwards. if m := useRe.FindStringSubmatch(s); m != nil { if g, ok := groups[m[1]]; ok { g.middleware = append(g.middleware, splitMW(m[2])...) groups[m[1]] = g } continue } if m := echoRouteRe.FindStringSubmatch(s); m != nil { add(m[1], m[2], splitMW(m[3])) continue } if m := groupRouteRe.FindStringSubmatch(s); m != nil { g, ok := groups[m[1]] if !ok { continue } full := strings.TrimSuffix(g.prefix+m[3], "/") add(m[2], full, append(append([]string{}, g.middleware...), splitMW(m[4])...)) } } } return routes } func splitMW(s string) []string { if strings.TrimSpace(s) == "" { return nil } var out []string for _, part := range strings.Split(s, ",") { out = append(out, strings.TrimSpace(part)) } return out } // authLabel reduces a middleware chain to the frontmatter auth vocabulary. func authLabel(chain []string) string { has := func(needle string) bool { for _, c := range chain { if strings.Contains(c, needle) { return true } } return false } switch { case has("AdminMiddleware"): return "jwt+admin" case has("DeviceAuthMiddleware"): return "device-token" case has("createOptionalAuthMiddleware"): return "optional-jwt" case has("JWTMiddleware"): // Wrapper-inlined forms like createJWTMiddleware(cfg) carry uppercase // JWT; bare jwtMiddleware carries lowercase. Match both. fallthrough case has("jwt"): return "jwt" default: return "none" } } // parseHandlerMiddleware extracts per-route middleware markers from the // registration's argument list (wrappers like rateLimitMiddleware(handler)). func parseHandlerMiddleware(rest string) []string { if rest == "" { return nil } var mw []string if strings.HasPrefix(strings.TrimSpace(rest), "rateLimitMiddleware") { mw = append(mw, "rateLimit") } if strings.Contains(rest, "AdminMiddleware") { mw = append(mw, "admin") } if strings.Contains(rest, "DeviceAuthMiddleware") { mw = append(mw, "device-token") } if strings.Contains(rest, "createJWTMiddleware") { mw = append(mw, "jwt") } return mw } // ---------- the gate ---------- // TestBrunoMirrorsDocs keeps the executable-verification layer honest: // every frontmatter'd endpoint doc must have a generated OpenCollection // (Bruno) request whose method and URL shape match. Bruno is regenerated // from the docs; if this test fails, the collection drifted from the // canonical docs or a new doc was added without regenerating. // // Regenerate with: go test ./internal/docs -run TestBrunoMirrorsDocs -update var updateBruno = flag.Bool("update", false, "rewrite generated Bruno mirrors from the canonical docs") func TestBrunoMirrorsDocs(t *testing.T) { docsDir := docsRoot docs, err := LoadAPIDocs(docsDir) if err != nil { t.Fatalf("walk %s: %v", docsDir, err) } if *updateBruno { written, deleted, err := updateBrunoMirrors(docs) if err != nil { t.Fatalf("regenerate bruno mirrors: %v", err) } if err := updateOpenAPISpec(docs); err != nil { t.Fatalf("regenerate openapi spec: %v", err) } t.Logf("bruno mirrors: %d written, %d deleted", len(written), len(deleted)) for _, rel := range written { t.Logf(" wrote %s", rel) } for _, rel := range deleted { t.Logf(" deleted %s", rel) } t.Logf("openapi spec written") } else if _, err := os.Stat(filepath.Join(docsRoot, "..", "openapi.yaml")); err != nil { t.Errorf("docs/developer/openapi.yaml missing — regenerate with: go test ./internal/docs -run TestBrunoMirrorsDocs -update") } // Collect bruno requests: "METHOD normalized-path" -> relpath. bruno := map[string]string{} files, _ := filepath.Glob("../../bruno/**/*.yml") for _, file := range files { rel, _ := filepath.Rel("../../bruno", file) parts := strings.Split(rel, string(filepath.Separator)) if len(parts) > 0 && (parts[0] == "NewDevDBSetup" || parts[0] == "environments") { continue } if strings.Contains(file, "/scenarios/") || strings.Contains(file, "/filters/") || strings.Contains(file, "/search/") { continue // hand-authored edge-case suites, not 1:1 mirrors } raw, err := os.ReadFile(file) if err != nil { t.Fatalf("read %s: %v", file, err) } var method, url string for _, line := range strings.Split(string(raw), "\n") { s := strings.TrimSpace(line) if m := regexp.MustCompile(`^method:\s*(\w+)$`).FindStringSubmatch(s); m != nil && method == "" { method = strings.ToUpper(m[1]) } if m := regexp.MustCompile(`^url:\s*'?([^']+)`).FindStringSubmatch(s); m != nil && url == "" { url = m[1] } } if method == "" || url == "" { continue // folder.yml and other metadata } // {{base_url}}/api/x/{{var}} -> /api/x/{} (params collapse to {}) path := regexp.MustCompile(`^\{\{(?:base|opds_base)_url\}\}`).ReplaceAllString(url, "") path = regexp.MustCompile(`\{\{[^}]+\}\}`).ReplaceAllString(path, "{}") bruno[method+" "+path] = rel } for _, doc := range docs { path := regexp.MustCompile(`:[^/{}]+`).ReplaceAllString(doc.Frontmatter.Path, "{}") key := doc.Frontmatter.Method + " " + path if _, ok := bruno[key]; !ok { t.Errorf("%s: no Bruno request mirrors %q — regenerate: go test ./internal/docs -run TestBrunoMirrorsDocs -update", doc.RelPath, key) } } } func TestDocsMatchRoutes(t *testing.T) { routes := extractRoutesFromRouterSource(t) docsDir := "../../docs/developer/api" docs, err := LoadAPIDocs(docsDir) if err != nil { t.Fatalf("walk %s: %v", docsDir, err) } if len(docs) == 0 { t.Fatal("no frontmatter'd endpoint docs found — the gate is checking nothing") } for _, doc := range docs { key := doc.Frontmatter.Method + " " + doc.Frontmatter.Path route, ok := routes[key] if !ok { t.Errorf("%s: frontmatter declares %q but no such route is registered (wrong path, method, or the route moved)", doc.RelPath, key) continue } if want := authLabel(route.middleware); doc.Frontmatter.Auth != want { t.Errorf("%s: frontmatter auth=%q but route middleware resolves to %q", doc.RelPath, doc.Frontmatter.Auth, want) } for _, marker := range doc.Unverified { t.Logf("UNVERIFIED in %s: %s", doc.RelPath, marker) } schema, registered := Schemas[key] if !registered { t.Errorf("%s: route %q has no internal/docs schema registry entry — add one in the same change", doc.RelPath, key) continue } validateRequestExample(t, &doc, schema) validateResponseExample(t, &doc, schema) } } func validateRequestExample(t *testing.T, doc *APIDoc, schema Schema) { t.Helper() if schema.Request == nil { if len(doc.ExampleRequest) > 0 { t.Errorf("%s: route takes no request body but the doc has an example-request block", doc.RelPath) } return } if len(doc.ExampleRequest) == 0 { t.Errorf("%s: no example-request block — required for routes with a body", doc.RelPath) return } // AnyJSONObject request: arbitrary keys (config maps). Shape-check only. if _, ok := schema.Request.(AnyJSONObject); ok { var m map[string]json.RawMessage if err := json.Unmarshal(doc.ExampleRequest, &m); err != nil { t.Errorf("%s: example-request must be a JSON object", doc.RelPath) } return } // Typo guard: field names must exist on the bind struct. dec := json.NewDecoder(bytes.NewReader(doc.ExampleRequest)) dec.DisallowUnknownFields() v := reflect.New(reflect.TypeOf(schema.Request)).Interface() if err := dec.Decode(v); err != nil { t.Errorf("%s: example-request is not compatible with %T: %v", doc.RelPath, schema.Request, err) return } // Staleness guard: required fields must be present in the example. rt := reflect.TypeOf(schema.Request) for rt != nil && rt.Kind() == reflect.Ptr { rt = rt.Elem() } if rt != nil && rt.Kind() == reflect.Struct { for _, field := range requiredJSONFields(rt) { if !jsonHasKey(doc.ExampleRequest, field) { t.Errorf("%s: example-request missing required field %q", doc.RelPath, field) } } } } func validateResponseExample(t *testing.T, doc *APIDoc, schema Schema) { t.Helper() if schema.Response == nil { if doc.HasExampleResponse { t.Errorf("%s: route returns no JSON body but the doc has an example-response block", doc.RelPath) } return } if !doc.HasExampleResponse { t.Errorf("%s: no example-response block — required for JSON routes", doc.RelPath) return } // AnyJSONObject: response is an object with arbitrary/dynamic keys // (e.g. map[string]string config). Only shape-check, never key-check. if _, ok := schema.Response.(AnyJSONObject); ok { var m map[string]json.RawMessage if err := json.Unmarshal(doc.ExampleResponse, &m); err != nil { t.Errorf("%s: example-response must be a JSON object", doc.RelPath) } return } want, isSlice := responseTopLevelKeys(t, schema.Response) var got map[string]bool if isSlice { // Array responses: compare the first sample element's keys. var arr []json.RawMessage if err := json.Unmarshal(doc.ExampleResponse, &arr); err != nil { t.Errorf("%s: example-response must be a JSON array for this route (handler returns a slice)", doc.RelPath) return } if len(arr) == 0 { t.Errorf("%s: example-response array needs at least one sample element to check keys", doc.RelPath) return } got = topLevelKeys(arr[0]) } else { got = topLevelKeys(doc.ExampleResponse) } for _, k := range sortedKeys(want) { if !got[k] { t.Errorf("%s: example-response missing key %q (the handler emits it)", doc.RelPath, k) } } for _, k := range sortedKeys(got) { if !want[k] { t.Errorf("%s: example-response has key %q but the handler does not emit it", doc.RelPath, k) } } } // requiredJSONFields lists json field names of struct type whose validate tag // marks them required. Callers must pass a struct kind. func requiredJSONFields(rt reflect.Type) []string { if rt.Kind() == reflect.Ptr { rt = rt.Elem() } var out []string for i := 0; i < rt.NumField(); i++ { f := rt.Field(i) jsonTag := strings.Split(f.Tag.Get("json"), ",")[0] if jsonTag == "" || jsonTag == "-" { continue } for _, rule := range strings.Split(f.Tag.Get("validate"), ",") { if strings.TrimSpace(rule) == "required" { out = append(out, jsonTag) break } } } return out } func jsonHasKey(data []byte, key string) bool { var m map[string]json.RawMessage if err := json.Unmarshal(data, &m); err != nil { return false } _, ok := m[key] return ok } func topLevelKeys(data []byte) map[string]bool { var m map[string]json.RawMessage if err := json.Unmarshal(data, &m); err != nil { return nil } out := make(map[string]bool, len(m)) for k := range m { out[k] = true } return out } // responseTopLevelKeys derives the expected top-level key set from the // registered Response: marshal a struct (or its element for slices), or use a // RawMessage pin verbatim. The second return reports that the handler emits a // JSON ARRAY (slice response) — the doc's example must then be an array too. func responseTopLevelKeys(t *testing.T, resp any) (map[string]bool, bool) { t.Helper() if raw, ok := resp.(json.RawMessage); ok { trimmed := bytes.TrimLeft(raw, " \t\r\n") return topLevelKeys(raw), len(trimmed) > 0 && trimmed[0] == '[' } rt := reflect.TypeOf(resp) if rt == nil { return nil, false } isSlice := false if rt.Kind() == reflect.Slice { if rt.Elem().Kind() == reflect.Uint8 { return topLevelKeys(reflect.ValueOf(resp).Bytes()), false // []byte payloads } rt = rt.Elem() isSlice = true } if rt.Kind() == reflect.Ptr { rt = rt.Elem() } if rt.Kind() != reflect.Struct { t.Fatalf("schema response %T: unsupported kind %s (use a struct, slice of struct, or json.RawMessage)", resp, rt.Kind()) } b, err := json.Marshal(reflect.New(rt).Interface()) if err != nil { t.Fatalf("marshal schema response %T: %v", resp, err) } return topLevelKeys(b), isSlice } func sortedKeys(m map[string]bool) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } // deterministic order for stable failure messages for i := 1; i < len(out); i++ { for j := i; j > 0 && out[j] < out[j-1]; j-- { out[j], out[j-1] = out[j-1], out[j] } } return out } // Sanity: the extraction itself must find the pilot routes, otherwise the // gate would pass vacuously if the parser ever broke. func TestRouteExtractionFindsPilots(t *testing.T) { routes := extractRoutesFromRouterSource(t) for _, key := range []string{ "POST /api/collections", "GET /api/collections", "GET /api/collections/:id", "PUT /api/collections/:id", "DELETE /api/collections/:id", "GET /api/admin/hash-conflicts", "POST /api/admin/hash-conflicts/:id/resolve", "POST /api/sync/kobo/:token/markup", "POST /api/auth/login", } { if _, ok := routes[key]; !ok { t.Errorf("route extraction missing %q (parser drift — fix extractRoutesFromRouterSource, do not edit docs to match)", key) } } if label := authLabel(routes["GET /api/admin/hash-conflicts"].middleware); label != "jwt+admin" { t.Errorf("hash-conflicts auth label = %q, want jwt+admin", label) } if label := authLabel(routes["POST /api/sync/kobo/:token/markup"].middleware); label != "device-token" { t.Errorf("kobo markup auth label = %q, want device-token", label) } if label := authLabel(routes["POST /api/auth/login"].middleware); label != "none" { t.Errorf("login auth label = %q, want none (public, rate-limited)", label) } }