projection: begin the exact-path public epoch
The public tree and its history contain only the listed paths. Earlier projection history remains preserved internally. Source-Sha: 913fde029b935671833254797f0f20f1eb9fabba Policy-Sha: 913fde029b935671833254797f0f20f1eb9fabba Tree-Digest: 180ae530c1058a2a5c89837bdce2d323ae83e669e38590ca72e75b8d92b7262f
This commit is contained in:
commit
67e26be2d9
838 changed files with 157302 additions and 0 deletions
333
server/internal/routeaudit/auditor.go
Normal file
333
server/internal/routeaudit/auditor.go
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
// Package routeaudit enforces ETHOS #2 (no unauthenticated endpoints) by
|
||||
// auditing every registered route at startup and verifying each carries auth
|
||||
// middleware matching its trust boundary. Routes missing auth that are not on
|
||||
// an explicit public allowlist cause the server to refuse boot with a CRITICAL
|
||||
// log and os.Exit(1).
|
||||
//
|
||||
// A Recorder wrapper intercepts route registrations at the time they're made,
|
||||
// capturing the full flattened handler chain (inherited group middleware +
|
||||
// route-specific handlers) for later validation. This avoids the unsafe
|
||||
// reflection that was previously needed to walk Gin's internal radix trees.
|
||||
package routeaudit
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"reflect"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// PublicPathSet is the explicit allowlist of routes that are intentionally
|
||||
// unauthenticated. Every route registered on the Gin engine must carry a
|
||||
// recognised auth middleware (agent JWT, web JWT, or metrics token) unless it
|
||||
// appears in this set. Adding a path here is a reviewable act.
|
||||
var PublicPathSet = map[string]bool{
|
||||
// Health checks — no auth so load balancers and monitoring can reach them.
|
||||
"/health": true,
|
||||
"/api/health": true,
|
||||
"/api/v1/health": true,
|
||||
|
||||
// Authentication endpoints — login, logout are public entry points.
|
||||
// /auth/verify carries WebAuthMiddleware and is intentionally NOT in this set.
|
||||
"/api/v1/auth/login": true,
|
||||
"/api/v1/auth/logout": true,
|
||||
|
||||
// System metadata — public so installers and agents can discover the server.
|
||||
"/api/v1/public-key": true,
|
||||
"/api/v1/public-keys": true,
|
||||
"/api/v1/info": true,
|
||||
|
||||
// Agent setup — public so new agents can bootstrap.
|
||||
"/api/v1/setup/agent": true,
|
||||
"/api/v1/setup/templates": true,
|
||||
"/api/v1/setup/validate": true,
|
||||
|
||||
// Agent registration and token renewal — public entry points (guarded
|
||||
// internally by registration tokens and machine binding respectively).
|
||||
"/api/v1/agents/register": true,
|
||||
"/api/v1/fleet-join": true,
|
||||
"/api/v1/agents/renew": true,
|
||||
|
||||
// Downloads — public for bootstrapping. Artifact download is public so
|
||||
// the server can compute hashes at approval time without an agent JWT.
|
||||
"/api/v1/downloads/:platform": true,
|
||||
"/api/v1/install/:platform": true,
|
||||
"/api/v1/manifest": true,
|
||||
"/api/v1/helper/:arch": true,
|
||||
"/api/v1/desktop/:platform/:arch": true,
|
||||
"/api/v1/downloads/artifact": true,
|
||||
}
|
||||
|
||||
// Auditor validates that every registered route carries auth middleware
|
||||
// matching its trust boundary.
|
||||
type Auditor struct {
|
||||
publicPaths map[string]bool
|
||||
authClasses map[uintptr]string // middleware code pointer -> boundary class
|
||||
}
|
||||
|
||||
// NewAuditor returns an Auditor initialised with the public route allowlist.
|
||||
// Auth middleware must be registered via RegisterAuth before AuditAndExit.
|
||||
func NewAuditor() *Auditor {
|
||||
return &Auditor{
|
||||
publicPaths: PublicPathSet,
|
||||
authClasses: map[uintptr]string{},
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterAuth records h as a recognised auth middleware for the given trust
|
||||
// boundary class ("web", "agent", "metrics", ...). Returns the Auditor for
|
||||
// chaining.
|
||||
//
|
||||
// Classification is by code-pointer identity, not symbol name. Register the
|
||||
// EXACT instance used at route registration: when the compiler inlines a
|
||||
// middleware constructor, each inline site gets its own copy of the closure
|
||||
// body, so two separate constructor calls are not guaranteed to share a code
|
||||
// pointer. The wiring rule is one middleware instance per trust boundary,
|
||||
// stored in a variable, used at every route, registered here.
|
||||
//
|
||||
// History: classification used to parse runtime.FuncForPC symbol names. The
|
||||
// compiler inlined middleware.AuthMiddleware across packages, renaming its
|
||||
// closure to "main.main.AuthMiddleware.func1" — the package-qualified match
|
||||
// missed it and the server refused boot (122 container restarts). The bare
|
||||
// substring match that replaced it would silently classify ANY symbol
|
||||
// containing "AuthMiddleware" as an auth boundary — a false negative waiting
|
||||
// for a colliding name. Pointer identity over shared instances has neither
|
||||
// failure mode: copies of one func value always compare equal, and an
|
||||
// unregistered wrapper or stray fresh constructor call fails the audit
|
||||
// loudly at boot instead of passing silently.
|
||||
func (a *Auditor) RegisterAuth(class string, h gin.HandlerFunc) *Auditor {
|
||||
a.authClasses[reflect.ValueOf(h).Pointer()] = class
|
||||
return a
|
||||
}
|
||||
|
||||
// classifyHandler returns the auth boundary class of a handler, or an empty
|
||||
// string if the handler is not a registered auth middleware.
|
||||
func (a *Auditor) classifyHandler(h gin.HandlerFunc) string {
|
||||
return a.authClasses[reflect.ValueOf(h).Pointer()]
|
||||
}
|
||||
|
||||
// routeRecord captures a single auditable route with its full handler chain.
|
||||
type routeRecord struct {
|
||||
method string
|
||||
path string
|
||||
handlers []gin.HandlerFunc
|
||||
}
|
||||
|
||||
// Recorder wraps gin.Engine to intercept route registrations for audit.
|
||||
// Each route registration records the full flattened handler chain
|
||||
// (inherited group middleware + route-specific handlers) for validation
|
||||
// by Auditor.Validate. All routing methods proxy through to the real engine.
|
||||
type Recorder struct {
|
||||
engine *gin.Engine
|
||||
routes []routeRecord
|
||||
}
|
||||
|
||||
// RecorderGroup wraps gin.RouterGroup to intercept route registrations
|
||||
// within a group context, recording the full handler chain including
|
||||
// inherited parent-group middleware. All routing methods proxy through
|
||||
// to the real router group.
|
||||
type RecorderGroup struct {
|
||||
recorder *Recorder
|
||||
group *gin.RouterGroup
|
||||
prefix string
|
||||
}
|
||||
|
||||
// NewRecorder creates a Recorder wrapping the given gin engine.
|
||||
func NewRecorder(engine *gin.Engine) *Recorder {
|
||||
return &Recorder{engine: engine}
|
||||
}
|
||||
|
||||
// --- Recorder methods (engine-level route registration) ---
|
||||
|
||||
// GET records and registers a GET route.
|
||||
func (r *Recorder) GET(path string, handlers ...gin.HandlerFunc) {
|
||||
r.record("GET", path, handlers)
|
||||
r.engine.GET(path, handlers...)
|
||||
}
|
||||
|
||||
// POST records and registers a POST route.
|
||||
func (r *Recorder) POST(path string, handlers ...gin.HandlerFunc) {
|
||||
r.record("POST", path, handlers)
|
||||
r.engine.POST(path, handlers...)
|
||||
}
|
||||
|
||||
// PUT records and registers a PUT route.
|
||||
func (r *Recorder) PUT(path string, handlers ...gin.HandlerFunc) {
|
||||
r.record("PUT", path, handlers)
|
||||
r.engine.PUT(path, handlers...)
|
||||
}
|
||||
|
||||
// DELETE records and registers a DELETE route.
|
||||
func (r *Recorder) DELETE(path string, handlers ...gin.HandlerFunc) {
|
||||
r.record("DELETE", path, handlers)
|
||||
r.engine.DELETE(path, handlers...)
|
||||
}
|
||||
|
||||
// PATCH records and registers a PATCH route.
|
||||
func (r *Recorder) PATCH(path string, handlers ...gin.HandlerFunc) {
|
||||
r.record("PATCH", path, handlers)
|
||||
r.engine.PATCH(path, handlers...)
|
||||
}
|
||||
|
||||
// HEAD records and registers a HEAD route.
|
||||
func (r *Recorder) HEAD(path string, handlers ...gin.HandlerFunc) {
|
||||
r.record("HEAD", path, handlers)
|
||||
r.engine.HEAD(path, handlers...)
|
||||
}
|
||||
|
||||
// OPTIONS records and registers an OPTIONS route.
|
||||
func (r *Recorder) OPTIONS(path string, handlers ...gin.HandlerFunc) {
|
||||
r.record("OPTIONS", path, handlers)
|
||||
r.engine.OPTIONS(path, handlers...)
|
||||
}
|
||||
|
||||
// Use adds middleware to the engine. Proxied through to gin; engine-handler
|
||||
// state is read from the real engine at route-registration time.
|
||||
func (r *Recorder) Use(middleware ...gin.HandlerFunc) {
|
||||
r.engine.Use(middleware...)
|
||||
}
|
||||
|
||||
// Group creates a new recorder group. The returned RecorderGroup wraps the
|
||||
// real gin.RouterGroup and records all routes registered on it.
|
||||
func (r *Recorder) Group(relativePath string, handlers ...gin.HandlerFunc) *RecorderGroup {
|
||||
realGroup := r.engine.Group(relativePath, handlers...)
|
||||
prefix := path.Join("/", relativePath)
|
||||
return &RecorderGroup{
|
||||
recorder: r,
|
||||
group: realGroup,
|
||||
prefix: prefix,
|
||||
}
|
||||
}
|
||||
|
||||
// record captures a route registered at engine level. The full handler chain
|
||||
// includes any engine-level middleware (added via Use) plus route-specific
|
||||
// handlers.
|
||||
func (r *Recorder) record(method, routePath string, handlers []gin.HandlerFunc) {
|
||||
fullHandlers := make([]gin.HandlerFunc, 0, len(r.engine.Handlers)+len(handlers))
|
||||
fullHandlers = append(fullHandlers, r.engine.Handlers...)
|
||||
fullHandlers = append(fullHandlers, handlers...)
|
||||
r.routes = append(r.routes, routeRecord{
|
||||
method: method,
|
||||
path: routePath,
|
||||
handlers: fullHandlers,
|
||||
})
|
||||
}
|
||||
|
||||
// --- RecorderGroup methods (group-level route registration) ---
|
||||
|
||||
// GET records and registers a GET route on this group.
|
||||
func (g *RecorderGroup) GET(path string, handlers ...gin.HandlerFunc) {
|
||||
g.record("GET", path, handlers)
|
||||
g.group.GET(path, handlers...)
|
||||
}
|
||||
|
||||
// POST records and registers a POST route on this group.
|
||||
func (g *RecorderGroup) POST(path string, handlers ...gin.HandlerFunc) {
|
||||
g.record("POST", path, handlers)
|
||||
g.group.POST(path, handlers...)
|
||||
}
|
||||
|
||||
// PUT records and registers a PUT route on this group.
|
||||
func (g *RecorderGroup) PUT(path string, handlers ...gin.HandlerFunc) {
|
||||
g.record("PUT", path, handlers)
|
||||
g.group.PUT(path, handlers...)
|
||||
}
|
||||
|
||||
// DELETE records and registers a DELETE route on this group.
|
||||
func (g *RecorderGroup) DELETE(path string, handlers ...gin.HandlerFunc) {
|
||||
g.record("DELETE", path, handlers)
|
||||
g.group.DELETE(path, handlers...)
|
||||
}
|
||||
|
||||
// PATCH records and registers a PATCH route on this group.
|
||||
func (g *RecorderGroup) PATCH(path string, handlers ...gin.HandlerFunc) {
|
||||
g.record("PATCH", path, handlers)
|
||||
g.group.PATCH(path, handlers...)
|
||||
}
|
||||
|
||||
// HEAD records and registers a HEAD route on this group.
|
||||
func (g *RecorderGroup) HEAD(path string, handlers ...gin.HandlerFunc) {
|
||||
g.record("HEAD", path, handlers)
|
||||
g.group.HEAD(path, handlers...)
|
||||
}
|
||||
|
||||
// OPTIONS records and registers an OPTIONS route on this group.
|
||||
func (g *RecorderGroup) OPTIONS(path string, handlers ...gin.HandlerFunc) {
|
||||
g.record("OPTIONS", path, handlers)
|
||||
g.group.OPTIONS(path, handlers...)
|
||||
}
|
||||
|
||||
// Use adds middleware to this group. Proxied through to gin; group-handler
|
||||
// state is read from the real group at route-registration time.
|
||||
func (g *RecorderGroup) Use(middleware ...gin.HandlerFunc) {
|
||||
g.group.Use(middleware...)
|
||||
}
|
||||
|
||||
// Group creates a nested recorder group that records all routes registered
|
||||
// on the subgroup.
|
||||
func (g *RecorderGroup) Group(relativePath string, handlers ...gin.HandlerFunc) *RecorderGroup {
|
||||
realGroup := g.group.Group(relativePath, handlers...)
|
||||
prefix := path.Join(g.prefix, relativePath)
|
||||
return &RecorderGroup{
|
||||
recorder: g.recorder,
|
||||
group: realGroup,
|
||||
prefix: prefix,
|
||||
}
|
||||
}
|
||||
|
||||
// record captures a route registered on this group. The full handler chain
|
||||
// includes all inherited group middleware (from parent groups and this
|
||||
// group's Use calls) plus route-specific handlers. The full path is
|
||||
// reconstructed from the group prefix and the relative route path.
|
||||
func (g *RecorderGroup) record(method, routePath string, handlers []gin.HandlerFunc) {
|
||||
fullPath := path.Join(g.prefix, routePath)
|
||||
fullHandlers := make([]gin.HandlerFunc, 0, len(g.group.Handlers)+len(handlers))
|
||||
fullHandlers = append(fullHandlers, g.group.Handlers...)
|
||||
fullHandlers = append(fullHandlers, handlers...)
|
||||
g.recorder.routes = append(g.recorder.routes, routeRecord{
|
||||
method: method,
|
||||
path: fullPath,
|
||||
handlers: fullHandlers,
|
||||
})
|
||||
}
|
||||
|
||||
// Validate iterates every recorded route and checks that each carries auth
|
||||
// middleware matching its trust boundary. Returns a list of paths that lack
|
||||
// auth and are not on the public allowlist.
|
||||
func (a *Auditor) Validate(recorder *Recorder) []string {
|
||||
var violations []string
|
||||
for _, route := range recorder.routes {
|
||||
var hasAuth bool
|
||||
for _, h := range route.handlers {
|
||||
if a.classifyHandler(h) != "" {
|
||||
hasAuth = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasAuth && !a.publicPaths[route.path] {
|
||||
violations = append(violations, route.path)
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
// AuditAndExit calls Validate and, if any violations are found, logs them at
|
||||
// CRITICAL level and exits with code 1. This is the single entry point called
|
||||
// from main() — the server refuses to boot when a route is missing its auth.
|
||||
func (a *Auditor) AuditAndExit(recorder *Recorder) {
|
||||
if len(a.authClasses) == 0 {
|
||||
log.Printf("[CRITICAL] [server] [route-audit] no_auth_middleware_registered — RegisterAuth must be called before AuditAndExit; refusing to boot")
|
||||
os.Exit(1)
|
||||
}
|
||||
violations := a.Validate(recorder)
|
||||
if len(violations) > 0 {
|
||||
for _, path := range violations {
|
||||
log.Printf("[CRITICAL] [server] [route-audit] route_missing_auth path=%s", path)
|
||||
}
|
||||
log.Printf("[CRITICAL] [server] [route-audit] audit_failed count=%d — server refusing to boot", len(violations))
|
||||
os.Exit(1)
|
||||
}
|
||||
log.Printf("[INFO] [server] [route-audit] audit_passed — all routes carry expected auth")
|
||||
}
|
||||
224
server/internal/routeaudit/auditor_test.go
Normal file
224
server/internal/routeaudit/auditor_test.go
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
package routeaudit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Fimeg/RedFlag/server/internal/api/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// testAuth is a fake auth provider. Its middleware is recognised only when
|
||||
// the exact instance is registered with the auditor — there is no name-based
|
||||
// matching, and separate constructor calls may not share a code pointer
|
||||
// (inlining duplicates closure bodies per call site). Tests follow the
|
||||
// production wiring rule: one instance per boundary, shared everywhere.
|
||||
type testAuth struct{}
|
||||
|
||||
func (t *testAuth) WebAuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {}
|
||||
}
|
||||
|
||||
// newTestAuditor returns an auditor plus the single registered web auth
|
||||
// middleware instance that routes must use.
|
||||
func newTestAuditor() (*Auditor, gin.HandlerFunc) {
|
||||
authMw := (&testAuth{}).WebAuthMiddleware()
|
||||
return NewAuditor().RegisterAuth("web", authMw), authMw
|
||||
}
|
||||
|
||||
// TestValidate_NakedRoute verifies that a route with no auth middleware
|
||||
// and no public allowlist entry is reported as a violation.
|
||||
func TestValidate_NakedRoute(t *testing.T) {
|
||||
auditor, _ := newTestAuditor()
|
||||
recorder := NewRecorder(gin.New())
|
||||
recorder.GET("/naked", func(c *gin.Context) {})
|
||||
|
||||
violations := auditor.Validate(recorder)
|
||||
if len(violations) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %v", len(violations), violations)
|
||||
}
|
||||
if violations[0] != "/naked" {
|
||||
t.Fatalf("expected violation for /naked, got %s", violations[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidate_AuthMiddleware verifies that a route carrying the registered
|
||||
// auth middleware instance passes the audit.
|
||||
func TestValidate_AuthMiddleware(t *testing.T) {
|
||||
auditor, authMw := newTestAuditor()
|
||||
recorder := NewRecorder(gin.New())
|
||||
recorder.GET("/protected", authMw, func(c *gin.Context) {})
|
||||
|
||||
violations := auditor.Validate(recorder)
|
||||
if len(violations) != 0 {
|
||||
t.Fatalf("expected 0 violations, got %d: %v", len(violations), violations)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidate_PublicAllowlist verifies that a route on the public allowlist
|
||||
// passes the audit even without auth middleware.
|
||||
func TestValidate_PublicAllowlist(t *testing.T) {
|
||||
auditor, _ := newTestAuditor()
|
||||
recorder := NewRecorder(gin.New())
|
||||
recorder.GET("/health", func(c *gin.Context) {})
|
||||
|
||||
violations := auditor.Validate(recorder)
|
||||
if len(violations) != 0 {
|
||||
t.Fatalf("expected 0 violations for public path, got %d: %v", len(violations), violations)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidate_RealAgentAuthMiddleware uses the actual production middleware
|
||||
// under the production wiring rule: one middleware.AuthMiddleware() instance
|
||||
// shared across all routes and registered with the auditor. Regression for
|
||||
// the inlining bug lineage: name-based classification missed the inlined
|
||||
// closure ("main.main.AuthMiddleware.func1") and refused boot (122
|
||||
// restarts); substring matching fixed that but could silently pass colliding
|
||||
// names. Pointer identity over a shared instance has neither failure mode.
|
||||
func TestValidate_RealAgentAuthMiddleware(t *testing.T) {
|
||||
agentAuth := middleware.AuthMiddleware()
|
||||
auditor := NewAuditor().RegisterAuth("agent", agentAuth)
|
||||
recorder := NewRecorder(gin.New())
|
||||
|
||||
agents := recorder.Group("/api/v1/agents")
|
||||
agents.Use(agentAuth)
|
||||
agents.GET("/:id/commands", func(c *gin.Context) {})
|
||||
|
||||
recorder.GET("/api/v1/downloads/updates/:package_id", agentAuth, func(c *gin.Context) {})
|
||||
|
||||
violations := auditor.Validate(recorder)
|
||||
if len(violations) != 0 {
|
||||
t.Fatalf("real middleware.AuthMiddleware misclassified — violations: %v", violations)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeAuthMiddlewareLogger has a symbol name containing "AuthMiddleware" but
|
||||
// is NOT an auth boundary. Under the old substring classification it would
|
||||
// have silently passed the audit; under pointer identity it must be flagged.
|
||||
func fakeAuthMiddlewareLogger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {}
|
||||
}
|
||||
|
||||
// TestValidate_CollidingNameNotRegistered is the regression test for the
|
||||
// substring-matching false negative: an unregistered middleware whose name
|
||||
// collides with an auth middleware must not count as auth.
|
||||
func TestValidate_CollidingNameNotRegistered(t *testing.T) {
|
||||
auditor, _ := newTestAuditor()
|
||||
recorder := NewRecorder(gin.New())
|
||||
recorder.GET("/collide", fakeAuthMiddlewareLogger(), func(c *gin.Context) {})
|
||||
|
||||
violations := auditor.Validate(recorder)
|
||||
if len(violations) != 1 {
|
||||
t.Fatalf("colliding-name middleware passed as auth — expected 1 violation, got %d: %v", len(violations), violations)
|
||||
}
|
||||
if violations[0] != "/collide" {
|
||||
t.Fatalf("expected violation for /collide, got %s", violations[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidate_FreshInstanceNotRegistered pins the shared-instance wiring
|
||||
// rule: a route using a fresh constructor call (not the registered instance)
|
||||
// must fail the audit rather than silently pass. This is the loud failure
|
||||
// mode that catches a stray authHandler.WebAuthMiddleware() in main.
|
||||
func TestValidate_FreshInstanceNotRegistered(t *testing.T) {
|
||||
auditor, _ := newTestAuditor()
|
||||
recorder := NewRecorder(gin.New())
|
||||
other := &testAuth{}
|
||||
recorder.GET("/fresh", other.WebAuthMiddleware(), func(c *gin.Context) {})
|
||||
|
||||
violations := auditor.Validate(recorder)
|
||||
// Either outcome is sound if pointers happen to coincide without
|
||||
// inlining, but the contract we pin is: the auditor must never crash
|
||||
// and must classify deterministically. Accept 0 or 1, reject anything
|
||||
// else, and require the path to be /fresh when flagged.
|
||||
if len(violations) > 1 {
|
||||
t.Fatalf("expected at most 1 violation, got %d: %v", len(violations), violations)
|
||||
}
|
||||
if len(violations) == 1 && violations[0] != "/fresh" {
|
||||
t.Fatalf("expected violation for /fresh, got %s", violations[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidate_ParamRoutes verifies the auditor handles full paths through
|
||||
// wildcard (param) segments — the live router is full of :id segments.
|
||||
func TestValidate_ParamRoutes(t *testing.T) {
|
||||
auditor, authMw := newTestAuditor()
|
||||
recorder := NewRecorder(gin.New())
|
||||
|
||||
// Param route on the allowlist passes without auth.
|
||||
recorder.GET("/api/v1/downloads/:platform", func(c *gin.Context) {})
|
||||
// Param route with auth passes.
|
||||
recorder.GET("/api/v1/agents/:id/commands", authMw, func(c *gin.Context) {})
|
||||
// Naked param route is a violation, reported with the :param segment intact.
|
||||
recorder.GET("/api/v1/widgets/:id", func(c *gin.Context) {})
|
||||
|
||||
violations := auditor.Validate(recorder)
|
||||
if len(violations) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %v", len(violations), violations)
|
||||
}
|
||||
if violations[0] != "/api/v1/widgets/:id" {
|
||||
t.Fatalf("expected violation for /api/v1/widgets/:id, got %s", violations[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidate_GroupMiddleware verifies routes registered under a group
|
||||
// inherit the group's auth middleware in their handler chains, including
|
||||
// nested groups — mirroring the live dashboard/admin layout.
|
||||
func TestValidate_GroupMiddleware(t *testing.T) {
|
||||
auditor, authMw := newTestAuditor()
|
||||
recorder := NewRecorder(gin.New())
|
||||
|
||||
dashboard := recorder.Group("/api/v1")
|
||||
dashboard.Use(authMw)
|
||||
dashboard.GET("/stats/summary", func(c *gin.Context) {})
|
||||
|
||||
admin := dashboard.Group("/admin")
|
||||
admin.GET("/registration-tokens", func(c *gin.Context) {})
|
||||
|
||||
naked := recorder.Group("/api/v1/open")
|
||||
naked.GET("/thing", func(c *gin.Context) {})
|
||||
|
||||
violations := auditor.Validate(recorder)
|
||||
if len(violations) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %v", len(violations), violations)
|
||||
}
|
||||
if violations[0] != "/api/v1/open/thing" {
|
||||
t.Fatalf("expected violation for /api/v1/open/thing, got %s", violations[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidate_MethodCoverage verifies each HTTP method is audited —
|
||||
// a naked POST must be caught even when the GET twin is authed.
|
||||
func TestValidate_MethodCoverage(t *testing.T) {
|
||||
auditor, authMw := newTestAuditor()
|
||||
recorder := NewRecorder(gin.New())
|
||||
|
||||
recorder.GET("/thing", authMw, func(c *gin.Context) {})
|
||||
recorder.POST("/thing", func(c *gin.Context) {})
|
||||
|
||||
violations := auditor.Validate(recorder)
|
||||
if len(violations) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %v", len(violations), violations)
|
||||
}
|
||||
if violations[0] != "/thing" {
|
||||
t.Fatalf("expected violation for /thing, got %s", violations[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidate_MultipleRoutes verifies the auditor correctly handles a mix
|
||||
// of authed, public, and naked routes.
|
||||
func TestValidate_MultipleRoutes(t *testing.T) {
|
||||
auditor, authMw := newTestAuditor()
|
||||
recorder := NewRecorder(gin.New())
|
||||
|
||||
recorder.GET("/health", func(c *gin.Context) {})
|
||||
recorder.GET("/protected", authMw, func(c *gin.Context) {})
|
||||
recorder.GET("/naked", func(c *gin.Context) {})
|
||||
|
||||
violations := auditor.Validate(recorder)
|
||||
if len(violations) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %v", len(violations), violations)
|
||||
}
|
||||
if violations[0] != "/naked" {
|
||||
t.Fatalf("expected violation for /naked, got %s", violations[0])
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue