package api import ( "net/http" "strings" "testing" "github.com/go-chi/chi/v5" ) func TestBuildOpenAPISpecFromRouter(t *testing.T) { r := chi.NewRouter() noop := func(w http.ResponseWriter, _ *http.Request) {} r.Route("/api/v1", func(r chi.Router) { r.Get("/rules/metadata", noop) r.Post("/rules", noop) r.Get("/ad-connections/{id}/directory", noop) }) spec := BuildOpenAPISpec(r) paths, ok := spec["paths"].(map[string]any) if !ok { t.Fatal("paths missing") } // Walked routes are documented. for _, p := range []string{"/api/v1/rules/metadata", "/api/v1/rules", "/api/v1/ad-connections/{id}/directory"} { if _, ok := paths[p]; !ok { t.Errorf("path %q not documented", p) } } // Registry detail is applied: POST /rules carries a request body. post := paths["/api/v1/rules"].(map[string]any)["post"].(map[string]any) if _, ok := post["requestBody"]; !ok { t.Errorf("POST /rules should have a requestBody from the registry") } // Path parameters are derived. dir := paths["/api/v1/ad-connections/{id}/directory"].(map[string]any)["get"].(map[string]any) if _, ok := dir["parameters"]; !ok { t.Errorf("directory route should declare path/query parameters") } // Components include the RuleInput schema used for automation. schemas := spec["components"].(map[string]any)["schemas"].(map[string]any) if _, ok := schemas["RuleInput"]; !ok { t.Errorf("RuleInput schema missing") } } // TestCollectionRoutesNormalizeTrailingSlash guards the real router shape where // a collection root is registered as Post("/") under Route("/x"), which chi // emits with a trailing slash. The spec must normalize it so the request body // and pagination params still attach. func TestCollectionRoutesNormalizeTrailingSlash(t *testing.T) { r := chi.NewRouter() noop := func(w http.ResponseWriter, _ *http.Request) {} r.Route("/api/v1", func(r chi.Router) { r.Route("/ad-connections", func(r chi.Router) { r.Get("/", noop) r.Post("/", noop) }) }) paths := BuildOpenAPISpec(r)["paths"].(map[string]any) conn, ok := paths["/api/v1/ad-connections"].(map[string]any) if !ok { t.Fatalf("collection route not normalized; got paths: %v", keysOf(paths)) } post := conn["post"].(map[string]any) body, ok := post["requestBody"].(map[string]any) if !ok { t.Fatalf("POST collection missing requestBody") } ref := body["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)["$ref"] if ref != "#/components/schemas/ConnectionInput" { t.Errorf("unexpected body schema ref: %v", ref) } get := conn["get"].(map[string]any) if _, ok := get["parameters"]; !ok { t.Errorf("GET collection should have pagination parameters") } } // TestFilterSpecAndListRoutes covers the method= / path= narrowing shared by // the spec and the compact route list, plus the read-scope "allowed" flag. func TestFilterSpecAndListRoutes(t *testing.T) { r := chi.NewRouter() noop := func(w http.ResponseWriter, _ *http.Request) {} r.Route("/api/v1", func(r chi.Router) { r.Route("/rules", func(r chi.Router) { r.Get("/", noop) r.Post("/", noop) r.Put("/{id}", noop) r.Delete("/{id}", noop) }) r.Get("/schedules", noop) }) spec := BuildOpenAPISpec(r) // method filter only := FilterSpec(spec, RouteFilter{Methods: []string{"post", "put"}})["paths"].(map[string]any) if _, ok := only["/api/v1/schedules"]; ok { t.Errorf("GET-only path should be dropped by a post,put filter") } if ops := only["/api/v1/rules"].(map[string]any); len(ops) != 1 || ops["post"] == nil { t.Errorf("rules should keep only post, got %v", keysOf(ops)) } // path substring filter (case-insensitive) byPath := ListRoutes(spec, RouteFilter{Paths: []string{"RULE"}}, "") if len(byPath) != 4 { t.Fatalf("expected 4 rules routes, got %d: %+v", len(byPath), byPath) } for _, rt := range byPath { if !strings.Contains(rt.Path, "rules") { t.Errorf("unexpected route %s %s", rt.Method, rt.Path) } if !rt.Allowed { t.Errorf("session caller should be allowed on %s %s", rt.Method, rt.Path) } } // sorted by path then method if byPath[0].Method != "GET" || byPath[1].Method != "POST" { t.Errorf("expected GET then POST on the collection, got %s, %s", byPath[0].Method, byPath[1].Method) } // read-scoped key: mutating routes flagged not allowed ro := ListRoutes(spec, RouteFilter{}, "read") for _, rt := range ro { want := rt.Method == "GET" if rt.Allowed != want { t.Errorf("%s %s allowed=%v, want %v for a read key", rt.Method, rt.Path, rt.Allowed, want) } } // empty filter keeps everything all := ListRoutes(spec, RouteFilter{}, "") if len(all) != 5 { t.Errorf("expected 5 routes, got %d", len(all)) } } func TestRouteFilterFromQuery(t *testing.T) { req, _ := http.NewRequest(http.MethodGet, "/api/routes?method=GET,%20Post&path=rules,%20Conn", nil) f := routeFilterFromQuery(req) if len(f.Methods) != 2 || f.Methods[0] != "get" || f.Methods[1] != "post" { t.Errorf("methods = %v", f.Methods) } if len(f.Paths) != 2 || f.Paths[1] != "conn" { t.Errorf("paths = %v", f.Paths) } if !f.Matches("POST", "/api/v1/ad-connections") || f.Matches("DELETE", "/api/v1/rules/{id}") { t.Errorf("filter matching wrong") } } func keysOf(m map[string]any) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } return out } // TestOperationRegistryWellFormed guards against typos: every registry key must // be " /api/..." so it can only enrich a real API route. func TestOperationRegistryWellFormed(t *testing.T) { methods := map[string]bool{"GET": true, "POST": true, "PUT": true, "DELETE": true, "PATCH": true} for key := range operationRegistry { parts := strings.SplitN(key, " ", 2) if len(parts) != 2 || !methods[parts[0]] || !strings.HasPrefix(parts[1], "/api/") { t.Errorf("malformed operationRegistry key %q", key) } } }