Files
Alphaeus Mote e0b002975f feat(api-docs): require auth for docs and add filterable route discovery
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>
2026-09-03 10:44:54 -04:00

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)
}
}
}