Files
OpenFlare/backend/plugins/domain/auth_dependency_ordering_test.go
T
Ryan 53ae3007d0 fix(cordis): fail-closed auth guards for user/message_gateway/admin (#1)
* autoresearch iter 23: fail-closed auth guarding for user/message_gateway

Both plugins resolve contracts.AuthService in Apply to build their route
middleware, but declared only DBService in Inject(). The kernel gates a
plugin's Apply solely on declared deps, and cmd/app.go registers user
before auth, so user mounted first, core.Inject failed, and loginMW
silently degraded to a pass-through closure — leaving /api/v1/user
change-password, profile and access-tokens unguarded. message_gateway
was saved only by its later list position.

Declare AuthService in Inject() for both, and pin the property with a
reconcile-level test that mirrors production registration order and
asserts the real auth middleware reaches the route table.

* autoresearch iter 24: make auth middleware fallbacks fail closed

user, message_gateway and admin each fell back to a c.Next() closure when
contracts.AuthService could not be resolved, so a route would be served as
if authenticated. For admin this is reachable at runtime: OnDispose calls
service.ResetServices(), which nils the global the per-request guard reads,
so requests still in flight during dispose bypass authorization entirely.

Add ginutil.AuthUnavailable() and bind every fallback to it, with a test
that drives each plugin's registered guard without an auth service present
and asserts the request is aborted rather than passed through.

* chore(autoresearch): log iter 23 (fail-open auth ordering, proven)

* autoresearch iter 24 follow-up: let staticcheck infer the auth guard type

* docs(autoresearch): log iters 24-25 and lessons 9-11 (declared-dep bug class, gate discipline)
2026-08-29 11:21:04 +08:00

220 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package domain_test
import (
"context"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"Wavelet/core"
"Wavelet/core/contracts"
"Wavelet/core/extpoints"
"Wavelet/plugins/domain/admin"
"Wavelet/plugins/domain/message_gateway"
"Wavelet/plugins/domain/user"
)
// The kernel only gates a plugin's Apply on the services it DECLARES in Inject,
// so a plugin that consumes contracts.AuthService without declaring it can be
// mounted before auth exists. Those plugins fall back to a pass-through
// middleware, which silently un-guards their routes.
func sentinelLogin(c *gin.Context) { c.Next() }
func sentinelAdmin(c *gin.Context) { c.Next() }
func sentinelNoToken(c *gin.Context) { c.Next() }
type stubAuthService struct{ contracts.AuthService }
func (stubAuthService) RequireAuthMiddleware() any { return gin.HandlerFunc(sentinelLogin) }
func (stubAuthService) RequireAdminMiddleware() any { return gin.HandlerFunc(sentinelAdmin) }
func (stubAuthService) DisallowTokenAuthMiddleware() any { return gin.HandlerFunc(sentinelNoToken) }
type stubDBService struct{ contracts.DBService }
// providerPlugin publishes a contract into the container at Apply time, the way
// the real infra and domain plugins do.
type providerPlugin struct {
name string
provide func(*core.Context) error
}
func (p providerPlugin) Name() string { return p.name }
func (p providerPlugin) Apply(ctx *core.Context) error { return p.provide(ctx) }
func dbProvider() core.Plugin {
return providerPlugin{name: "stub-database", provide: func(ctx *core.Context) error {
core.Provide[contracts.DBService](ctx, stubDBService{})
return nil
}}
}
func authProvider() core.Plugin {
return providerPlugin{name: "stub-auth", provide: func(ctx *core.Context) error {
core.Provide[contracts.AuthService](ctx, stubAuthService{})
return nil
}}
}
func findRoute(routes []extpoints.RouteDefinition, method, path string) (extpoints.RouteDefinition, bool) {
for _, rd := range routes {
if rd.Method == method && rd.Path == path {
return rd, true
}
}
return extpoints.RouteDefinition{}, false
}
// codePointer resolves the function code pointer of a registered handler so
// identity can be compared without depending on gin internals.
func codePointer(handler any) uintptr {
switch fn := handler.(type) {
case gin.HandlerFunc:
return reflect.ValueOf(fn).Pointer()
case func(*gin.Context):
return reflect.ValueOf(fn).Pointer()
default:
return 0
}
}
func assertsMiddleware(t *testing.T, routes []extpoints.RouteDefinition, method, path string, want func(*gin.Context)) {
t.Helper()
rd, ok := findRoute(routes, method, path)
if !ok {
t.Fatalf("route %s %s was never registered", method, path)
}
wantPtr := codePointer(gin.HandlerFunc(want))
for _, h := range rd.Handlers {
if codePointer(h) == wantPtr {
return
}
}
for _, m := range rd.Middlewares {
if codePointer(m) == wantPtr {
return
}
}
t.Errorf("route %s %s is not guarded by the auth middleware: handlers=%d middlewares=%d",
method, path, len(rd.Handlers), len(rd.Middlewares))
}
// TestRoutesMountedBeforeAuthServiceAreGuarded 回归:cmd/app.go 把 user/message_gateway
// 注册在 auth 之前,若插件未在 Inject 中声明 contracts.AuthService,reconcile 会先
// Apply 它们,导致鉴权中间件退化为透传闭包,路由完全不受保护。
func TestRoutesMountedBeforeAuthServiceAreGuarded(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx := core.NewContext(context.Background())
// Registration order mirrors cmd/app.go: the auth provider comes last.
app := core.NewApp(
core.WithContext(ctx),
core.WithPlugins(
dbProvider(),
user.New(),
message_gateway.New(),
authProvider(),
),
)
require.NoError(t, app.ApplyPlugins())
routes := ctx.Router().Routes()
assertsMiddleware(t, routes, "POST", "/api/v1/user/change-password", sentinelLogin)
assertsMiddleware(t, routes, "PUT", "/api/v1/user/profile", sentinelLogin)
assertsMiddleware(t, routes, "GET", "/api/v1/user/access-tokens", sentinelLogin)
assertsMiddleware(t, routes, "GET", "/api/v1/user/access-tokens", sentinelNoToken)
assertsMiddleware(t, routes, "GET", "/api/v1/message-gateway/channels", sentinelLogin)
assertsMiddleware(t, routes, "GET", "/api/v1/admin/message-gateway/channels", sentinelAdmin)
}
// TestAuthConsumersDeclareAuthDependency 是同一缺陷的架构面:声明依赖是内核排序的唯一
// 依据,漏声明会让正确性取决于注册表顺序。
func TestAuthConsumersDeclareAuthDependency(t *testing.T) {
want := reflect.TypeFor[contracts.AuthService]()
for _, tc := range []struct {
name string
deps []reflect.Type
}{
{"user", user.New().Inject()},
{"message_gateway", message_gateway.New().Inject()},
} {
t.Run(tc.name, func(t *testing.T) {
assert.Contains(t, tc.deps, want,
"%s resolves contracts.AuthService in Apply, so it must declare it in Inject", tc.name)
})
}
}
// firstGuard returns the outermost middleware registered for the first route
// matching method and path prefix — the auth guard the plugin resolved at Apply.
func firstGuard(t *testing.T, routes []extpoints.RouteDefinition, method, prefix string) gin.HandlerFunc {
t.Helper()
for _, rd := range routes {
if rd.Method != method || !strings.HasPrefix(rd.Path, prefix) {
continue
}
for _, candidate := range rd.Middlewares {
if mw, ok := candidate.(gin.HandlerFunc); ok {
return mw
}
}
for _, candidate := range rd.Handlers {
if mw, ok := candidate.(gin.HandlerFunc); ok {
return mw
}
}
t.Fatalf("route %s %s has no inspectable guard", method, rd.Path)
}
t.Fatalf("no route registered for %s %s*", method, prefix)
return nil
}
// TestAuthGuardFailsClosed 回归:鉴权服务无法解析时兜底必须是拒绝。旧实现兜底为
// c.Next(),所以任何装配缺失——例如 admin 的 OnDispose 调用 service.ResetServices
// 把全局 authService 置 nil——都会让路由以“已登录”的姿态直达业务处理函数。
func TestAuthGuardFailsClosed(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
apply func(*core.Context) error
method string
prefix string
}{
{"user", user.New().Apply, http.MethodPost, "/api/v1/user/change-password"},
{"message_gateway", message_gateway.New().Apply, http.MethodGet, "/api/v1/message-gateway"},
{"admin", admin.New().Apply, http.MethodGet, "/api/v1/admin"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx := core.NewContext(context.Background())
require.NoError(t, tc.apply(ctx))
guard := firstGuard(t, ctx.Router().Routes(), tc.method, tc.prefix)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(tc.method, tc.prefix, nil)
guard(c)
assert.True(t, c.IsAborted(),
"%s guard must reject the request when contracts.AuthService is unavailable", tc.name)
assert.NotEmpty(t, c.Errors,
"%s guard must record why the request was rejected", tc.name)
})
}
}