mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
53ae3007d0
* 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)
220 lines
7.1 KiB
Go
220 lines
7.1 KiB
Go
// 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)
|
||
})
|
||
}
|
||
}
|