e0b002975f
The OpenAPI spec and Swagger UI were public. Put them behind the same authentication as the rest of the API, and add a compact route list so a client can ask "what can I call?" without opening dev tools. Access: - /api/openapi.json and /api/routes require a bearer token or API key. - /api/docs additionally accepts a session cookie set at login, so a signed-in operator can open the docs in a new tab; an anonymous browser is redirected to /login?redirect=... and returned afterwards. - The cookie is HttpOnly and path-scoped to /api/docs, so it is never sent to /api/v1/* and cannot authenticate an API call (no CSRF surface). Verified: cookie-only request to /api/v1/rules returns 401. Discovery: both the spec and GET /api/routes accept ?method=get,post and ?path=<substring> (comma-separated, case-insensitive). The route list returns method, path, summary, tag, public, and `allowed` — false when a read-scoped API key cannot invoke that route. /api/docs passes the same query through to the spec it loads. UI: a </> icon in the header (both layouts) and an Administration → API Docs menu entry, opened in a new tab via a new `external` nav-item flag. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
178 lines
5.8 KiB
Go
178 lines
5.8 KiB
Go
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 "<METHOD> /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)
|
|
}
|
|
}
|
|
}
|