Files
OrchestrAD/backend/internal/api/docs_cookie_test.go
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

58 lines
1.9 KiB
Go

package api
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
// TestDocsCookieScopedToDocsPath guards the security property that the docs
// cookie can never authenticate an /api/v1 request: it must be HttpOnly and
// path-scoped to /api/docs.
func TestDocsCookieScopedToDocsPath(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)
req.Header.Set("X-Forwarded-Proto", "https")
SetDocsSessionCookie(rec, req, "tok", time.Now().Add(time.Hour))
cookies := rec.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("expected 1 cookie, got %d", len(cookies))
}
c := cookies[0]
if c.Name != DocsCookieName || c.Value != "tok" {
t.Errorf("cookie = %s=%s", c.Name, c.Value)
}
if c.Path != DocsCookiePath {
t.Errorf("path = %q, want %q", c.Path, DocsCookiePath)
}
if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteLaxMode {
t.Errorf("cookie flags: httpOnly=%v secure=%v sameSite=%v", c.HttpOnly, c.Secure, c.SameSite)
}
// Clearing expires it on the same path.
rec = httptest.NewRecorder()
ClearDocsSessionCookie(rec, req)
c = rec.Result().Cookies()[0]
if c.MaxAge != -1 || c.Path != DocsCookiePath {
t.Errorf("clear cookie: maxAge=%d path=%q", c.MaxAge, c.Path)
}
}
// TestDocsPageAuthRedirectsAnonymous: an unauthenticated browser hitting the
// docs page is sent to the login page with a return target, not a JSON 401.
func TestDocsPageAuthRedirectsAnonymous(t *testing.T) {
h := DocsPageAuthMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("handler should not run for anonymous request")
}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/docs?method=post", nil))
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/login?redirect=%2Fapi%2Fdocs%3Fmethod%3Dpost" {
t.Errorf("Location = %q", loc)
}
}