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>
58 lines
1.9 KiB
Go
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)
|
|
}
|
|
}
|