From 455e2f8be59eee0272e04799c4f1e1450eb6c975 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 2 Sep 2026 17:07:07 +0800 Subject: [PATCH] fix(config): serve public settings and enforce login CAP Public config now comes from admin as a flat visibility=1 map instead of a cross-plugin query that compared an integer column to "visible". Login and register resolve CaptchaService per request so CAP is not skipped when user applies before cap. --- backend/core/contracts/config_public.go | 8 +- backend/docs/docs.go | 2 +- backend/docs/swagger.json | 2 +- backend/docs/swagger.yaml | 2 +- .../plugins/domain/admin/handler/config.go | 2 +- backend/plugins/domain/admin/plugin.go | 1 + backend/plugins/domain/admin/plugin_test.go | 5 ++ .../plugins/domain/admin/service/config.go | 8 ++ .../admin/service/system_config_test.go | 45 +++++++++++ backend/plugins/domain/auth/plugin.go | 1 + backend/plugins/domain/cap/repository.go | 8 +- backend/plugins/domain/domain_test.go | 63 +++++++++++++++ backend/plugins/domain/system/plugin.go | 53 +++++++------ .../domain/system/public_config_test.go | 24 ++++-- backend/plugins/domain/system/repository.go | 35 --------- backend/plugins/domain/user/plugin.go | 48 ++++++++---- .../domain/user/plugin_captcha_test.go | 76 +++++++++++++++++++ 17 files changed, 289 insertions(+), 94 deletions(-) delete mode 100644 backend/plugins/domain/system/repository.go diff --git a/backend/core/contracts/config_public.go b/backend/core/contracts/config_public.go index 31cd92b2..57fd4d37 100644 --- a/backend/core/contracts/config_public.go +++ b/backend/core/contracts/config_public.go @@ -5,8 +5,10 @@ package contracts import "context" -// PublicConfigProvider supplies the payload for GET /api/v1/config/public -// when a downstream plugin replaces Wavelet's default {configs, app} JSON. +// PublicConfigProvider supplies GET /api/v1/config/public. +// The owner of w_system_configs (admin) must provide this. The payload is a +// flat key/value map of visibility=1 rows; the frontend reads keys such as +// cap_login_enabled directly off data. type PublicConfigProvider interface { - PublicConfig(ctx context.Context) (any, error) + PublicConfig(ctx context.Context) (map[string]string, error) } diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 028c21b1..79ac24d2 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -4538,7 +4538,7 @@ const docTemplate = `{ }, "/api/v1/config/public": { "get": { - "description": "返回系统配置表中 visibility 为 1 的配置键值集合", + "description": "返回系统配置表中 visibility 为 1 的扁平键值集合(如 cap_login_enabled)", "consumes": [ "application/json" ], diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index a01fa772..85a0289a 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -4531,7 +4531,7 @@ }, "/api/v1/config/public": { "get": { - "description": "返回系统配置表中 visibility 为 1 的配置键值集合", + "description": "返回系统配置表中 visibility 为 1 的扁平键值集合(如 cap_login_enabled)", "consumes": [ "application/json" ], diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 52dbb9ce..d895e98e 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -4115,7 +4115,7 @@ paths: get: consumes: - application/json - description: 返回系统配置表中 visibility 为 1 的配置键值集合 + description: 返回系统配置表中 visibility 为 1 的扁平键值集合(如 cap_login_enabled) produces: - application/json responses: diff --git a/backend/plugins/domain/admin/handler/config.go b/backend/plugins/domain/admin/handler/config.go index a431c874..abbf60ae 100644 --- a/backend/plugins/domain/admin/handler/config.go +++ b/backend/plugins/domain/admin/handler/config.go @@ -16,7 +16,7 @@ import ( // GetPublicConfig 获取公共配置 // @Summary 获取公共配置 -// @Description 返回系统配置表中 visibility 为 1 的配置键值集合 +// @Description 返回系统配置表中 visibility 为 1 的扁平键值集合(如 cap_login_enabled) // @Tags config // @Accept json // @Produce json diff --git a/backend/plugins/domain/admin/plugin.go b/backend/plugins/domain/admin/plugin.go index 418f1d11..2512a9e5 100644 --- a/backend/plugins/domain/admin/plugin.go +++ b/backend/plugins/domain/admin/plugin.go @@ -92,6 +92,7 @@ func (p *Plugin) Apply(ctx *core.Context) error { core.Bind[contracts.StorageService](ctx, service.SetStorageService) core.Bind[contracts.RiskControlService](ctx, service.SetRiskControlService) service.SetEventEmitter(ctx.Events().Emit) + core.Provide[contracts.PublicConfigProvider](ctx, service.PublicConfigAdapter{}) ctx.OnDispose(func() error { service.ResetServices() diff --git a/backend/plugins/domain/admin/plugin_test.go b/backend/plugins/domain/admin/plugin_test.go index 451897be..4aace149 100644 --- a/backend/plugins/domain/admin/plugin_test.go +++ b/backend/plugins/domain/admin/plugin_test.go @@ -5,6 +5,7 @@ package admin_test import ( "Wavelet/core" + "Wavelet/core/contracts" "Wavelet/plugins/domain/admin" "context" "testing" @@ -40,6 +41,10 @@ func TestAdminPluginUnit(t *testing.T) { setting, ok := ctx.Settings().Get("admin.system_cleanup_cron") require.True(t, ok) assert.Equal(t, "0 4 * * *", setting.Default) + + provider, err := core.Inject[contracts.PublicConfigProvider](ctx) + require.NoError(t, err) + require.NotNil(t, provider) } func TestAdminMigrationsIncludeTaskExecutionsAndSchedules(t *testing.T) { diff --git a/backend/plugins/domain/admin/service/config.go b/backend/plugins/domain/admin/service/config.go index f453536b..a0ac0233 100644 --- a/backend/plugins/domain/admin/service/config.go +++ b/backend/plugins/domain/admin/service/config.go @@ -18,6 +18,14 @@ import ( const maskedConfigValue = "******" +// PublicConfigAdapter exposes visibility=1 system configs as PublicConfigProvider. +type PublicConfigAdapter struct{} + +// PublicConfig returns the unauthenticated public config map. +func (PublicConfigAdapter) PublicConfig(ctx context.Context) (map[string]string, error) { + return PublicSystemConfigs(ctx) +} + // PublicSystemConfigs returns the key/value map exposed to unauthenticated clients. func PublicSystemConfigs(ctx context.Context) (map[string]string, error) { configs, err := repository.ListVisibleSystemConfigs(ctx) diff --git a/backend/plugins/domain/admin/service/system_config_test.go b/backend/plugins/domain/admin/service/system_config_test.go index bce4321c..07d93edb 100644 --- a/backend/plugins/domain/admin/service/system_config_test.go +++ b/backend/plugins/domain/admin/service/system_config_test.go @@ -69,6 +69,51 @@ func setupSystemConfigTest(t *testing.T) (*gorm.DB, func()) { return sqliteDB, cleanup } +func TestPublicSystemConfigsExposesVisibleKeys(t *testing.T) { + dbConn, cleanup := setupSystemConfigTest(t) + defer cleanup() + repository.ResetSystemConfigRAMCacheForTest() + ctx := context.Background() + + hidden := model.SystemConfig{ + Key: "secret_key", + Value: "nope", + Type: "system", + Visibility: model.ConfigVisibilityHidden, + } + visible := model.SystemConfig{ + Key: model.ConfigKeyCapLoginEnabled, + Value: "true", + Type: "system", + Visibility: model.ConfigVisibilityVisible, + } + if err := dbConn.Create(&hidden).Error; err != nil { + t.Fatalf("Create(hidden) error = %v", err) + } + if err := dbConn.Create(&visible).Error; err != nil { + t.Fatalf("Create(visible) error = %v", err) + } + + got, err := service.PublicSystemConfigs(ctx) + if err != nil { + t.Fatalf("PublicSystemConfigs() error = %v", err) + } + if got[model.ConfigKeyCapLoginEnabled] != "true" { + t.Fatalf("PublicSystemConfigs()[%s] = %q, want %q", model.ConfigKeyCapLoginEnabled, got[model.ConfigKeyCapLoginEnabled], "true") + } + if _, ok := got["secret_key"]; ok { + t.Fatalf("PublicSystemConfigs() leaked hidden key secret_key") + } + + viaProvider, err := service.PublicConfigAdapter{}.PublicConfig(ctx) + if err != nil { + t.Fatalf("PublicConfigAdapter.PublicConfig() error = %v", err) + } + if viaProvider[model.ConfigKeyCapLoginEnabled] != "true" { + t.Fatalf("PublicConfigAdapter.PublicConfig()[%s] = %q, want %q", model.ConfigKeyCapLoginEnabled, viaProvider[model.ConfigKeyCapLoginEnabled], "true") + } +} + func TestListSystemConfigsByKeys_EmptyKeys(t *testing.T) { result, err := repository.ListSystemConfigsByKeys(context.Background(), nil) if err != nil { diff --git a/backend/plugins/domain/auth/plugin.go b/backend/plugins/domain/auth/plugin.go index eff4d138..fac1c7a1 100644 --- a/backend/plugins/domain/auth/plugin.go +++ b/backend/plugins/domain/auth/plugin.go @@ -119,6 +119,7 @@ func (p *Plugin) Apply(ctx *core.Context) error { "/api/v1/user/login", "/api/v1/user/register", "/api/v1/user/send-email-code", + "/api/v1/config/public", "/api/v1/cap/challenge", "/api/v1/cap/redeem", "/api/healthz", diff --git a/backend/plugins/domain/cap/repository.go b/backend/plugins/domain/cap/repository.go index 4caa0040..ad9c26f2 100644 --- a/backend/plugins/domain/cap/repository.go +++ b/backend/plugins/domain/cap/repository.go @@ -24,12 +24,10 @@ func setDBService(s contracts.DBService) { dbSvc = s } -// getDB resolves a GORM handle, preferring the *core.Context when supplied by callers. +// getDB resolves a GORM handle from the request/app context, then the Bind fallback. func getDB(ctx context.Context) *gorm.DB { - if c, ok := ctx.(*core.Context); ok && c != nil { - if s, err := core.Inject[contracts.DBService](c); err == nil && s != nil { - return s.DB(ctx) - } + if s, err := core.InjectFrom[contracts.DBService](ctx); err == nil && s != nil { + return s.DB(ctx) } dbMu.RLock() s := dbSvc diff --git a/backend/plugins/domain/domain_test.go b/backend/plugins/domain/domain_test.go index 32a031a1..a5b3ab13 100644 --- a/backend/plugins/domain/domain_test.go +++ b/backend/plugins/domain/domain_test.go @@ -11,16 +11,21 @@ import ( "Wavelet/plugins/domain/auth" "Wavelet/plugins/domain/message_gateway" "Wavelet/plugins/domain/risk_control" + "Wavelet/plugins/domain/system" "Wavelet/plugins/domain/user" "Wavelet/plugins/infra/cache" "Wavelet/plugins/infra/logger" "Wavelet/plugins/infra/storage" "context" + "encoding/json" "io/fs" + "net/http" + "net/http/httptest" "path/filepath" "testing" "github.com/alicebob/miniredis/v2" + "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" @@ -388,6 +393,64 @@ func TestAdminPlugin(t *testing.T) { schema, ok := ctx.Settings().Get("admin.system_cleanup_cron") require.True(t, ok) assert.Equal(t, "0 4 * * *", schema.Default) + + provider, err := core.Inject[contracts.PublicConfigProvider](ctx) + require.NoError(t, err) + require.NotNil(t, provider) +} + +func TestPublicConfigExposesVisibleAdminRows(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(nil)) + require.NoError(t, ctx.Config().Resolve()) + testDB := setupTestDB(t) + + require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx)) + require.NoError(t, cache.New().Apply(ctx)) + require.NoError(t, logger.New().Apply(ctx)) + + require.NoError(t, testDB.Create(&admin.SystemConfig{ + Key: "cap_login_enabled", + Value: "true", + Type: "system", + Visibility: 1, + }).Error) + + require.NoError(t, admin.New().Apply(ctx)) + require.NoError(t, system.New().Apply(ctx)) + + var handler gin.HandlerFunc + for _, rd := range ctx.Router().Routes() { + if rd.Method != "GET" || rd.Path != "/api/v1/config/public" { + continue + } + require.NotEmpty(t, rd.Handlers) + switch h := rd.Handlers[0].(type) { + case gin.HandlerFunc: + handler = h + case func(*gin.Context): + handler = h + default: + t.Fatalf("unexpected handler type %T", rd.Handlers[0]) + } + break + } + require.NotNil(t, handler) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/config/public", nil) + c.Request = c.Request.WithContext(core.WithAppContext(c.Request.Context(), ctx)) + handler(c) + + var body struct { + Data map[string]string `json:"data"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "body = %s", w.Body.String()) + assert.Equal(t, "true", body.Data["cap_login_enabled"]) + _, wrapped := body.Data["configs"] + assert.False(t, wrapped, "payload must be a flat map, got %v", body.Data) } func TestAllDomainPluginsCombined(t *testing.T) { diff --git a/backend/plugins/domain/system/plugin.go b/backend/plugins/domain/system/plugin.go index b03f98e8..9d9be0d5 100644 --- a/backend/plugins/domain/system/plugin.go +++ b/backend/plugins/domain/system/plugin.go @@ -9,8 +9,8 @@ import ( "Wavelet/core/contracts" "Wavelet/pkg/logger" "Wavelet/pkg/response" + "context" "net/http" - "reflect" "github.com/gin-gonic/gin" ) @@ -28,13 +28,6 @@ func (p *Plugin) Name() string { return "system" } -// Inject declares required dependencies for the system domain plugin. -func (p *Plugin) Inject() []reflect.Type { - return []reflect.Type{ - reflect.TypeFor[contracts.DBService](), - } -} - // Manifest returns the plugin metadata. func (p *Plugin) Manifest() core.Manifest { return core.Manifest{ @@ -47,37 +40,31 @@ func (p *Plugin) Manifest() core.Manifest { // Apply registers system routes. func (p *Plugin) Apply(ctx *core.Context) error { - appName := ctx.Config().String("app.app_name", "Wavelet") - // 1. Health check ctx.Router().GET("/api/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) ctx.Router().RegisterWhitelist("/api/healthz") - // 2. Public config + // 2. Public config — owned data comes from PublicConfigProvider (admin). ctx.Router().GET("/api/v1/config/public", func(c *gin.Context) { - if p, err := core.Inject[contracts.PublicConfigProvider](ctx); err == nil && p != nil { - data, err := p.PublicConfig(c.Request.Context()) - if err != nil { - logger.ErrorF(c.Request.Context(), "[System] public config provider failed: %v", err) - response.AbortInternal(c, "public config unavailable") - return - } - c.JSON(http.StatusOK, response.OK(data)) + provider := resolvePublicConfigProvider(c.Request.Context(), ctx) + if provider == nil { + c.JSON(http.StatusOK, response.OK(map[string]string{})) return } - configs, err := listPublicSystemConfigs(c.Request.Context(), ctx) + data, err := provider.PublicConfig(c.Request.Context()) if err != nil { - logger.ErrorF(c.Request.Context(), "[System] query public system configs failed: %v", err) + logger.ErrorF(c.Request.Context(), "[System] public config provider failed: %v", err) + response.AbortInternal(c, "public config unavailable") + return } - c.JSON(http.StatusOK, response.OK(gin.H{ - "configs": configs, - "app": gin.H{ - "name": appName, - }, - })) + if data == nil { + data = map[string]string{} + } + c.JSON(http.StatusOK, response.OK(data)) }) + ctx.Router().RegisterWhitelist("/api/v1/config/public") // 3. Custom injection ctx.Router().GET("/custom", func(c *gin.Context) { @@ -86,3 +73,15 @@ func (p *Plugin) Apply(ctx *core.Context) error { return nil } + +func resolvePublicConfigProvider(reqCtx context.Context, appCtx *core.Context) contracts.PublicConfigProvider { + if p, err := core.InjectFrom[contracts.PublicConfigProvider](reqCtx); err == nil && p != nil { + return p + } + if appCtx != nil { + if p, err := core.Inject[contracts.PublicConfigProvider](appCtx); err == nil && p != nil { + return p + } + } + return nil +} diff --git a/backend/plugins/domain/system/public_config_test.go b/backend/plugins/domain/system/public_config_test.go index 47d8840f..d2065556 100644 --- a/backend/plugins/domain/system/public_config_test.go +++ b/backend/plugins/domain/system/public_config_test.go @@ -18,13 +18,17 @@ import ( "github.com/gin-gonic/gin" ) -type stubPublic struct{ payload any } +type stubPublic struct{ payload map[string]string } -func (s stubPublic) PublicConfig(context.Context) (any, error) { return s.payload, nil } +func (s stubPublic) PublicConfig(context.Context) (map[string]string, error) { + return s.payload, nil +} type errPublic struct{ err error } -func (s errPublic) PublicConfig(context.Context) (any, error) { return nil, s.err } +func (s errPublic) PublicConfig(context.Context) (map[string]string, error) { + return nil, s.err +} func TestPublicConfigUsesProviderWhenPresent(t *testing.T) { gin.SetMode(gin.TestMode) @@ -54,16 +58,22 @@ func TestPublicConfigDefaultWithoutProvider(t *testing.T) { if err := New().Apply(ctx); err != nil { t.Fatal(err) } + if !ctx.Router().IsWhitelisted("/api/v1/config/public") { + t.Fatal("GET /api/v1/config/public not whitelisted") + } raw := invokePublicConfig(t, publicConfigHandler(t, ctx)) var data map[string]any if err := json.Unmarshal(raw, &data); err != nil { t.Fatal(err) } - if _, ok := data["configs"]; !ok { - t.Fatalf("data = %s, want key configs", raw) + if len(data) != 0 { + t.Fatalf("data = %s, want empty flat map", raw) } - if _, ok := data["app"]; !ok { - t.Fatalf("data = %s, want key app", raw) + if _, ok := data["configs"]; ok { + t.Fatalf("data = %s, default payload must not wrap configs", raw) + } + if _, ok := data["app"]; ok { + t.Fatalf("data = %s, default payload must not wrap app", raw) } } diff --git a/backend/plugins/domain/system/repository.go b/backend/plugins/domain/system/repository.go deleted file mode 100644 index 612feb4c..00000000 --- a/backend/plugins/domain/system/repository.go +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package system - -import ( - "Wavelet/core" - "Wavelet/core/contracts" - "context" -) - -// publicSystemConfig 前端公共配置接口的只读投影。 -type publicSystemConfig struct { - Key string `json:"key"` - Value string `json:"value"` -} - -// listPublicSystemConfigs 读取对前端可见的系统配置项。 -// -// 注意:w_system_configs 的所有者插件是 admin,contracts 目前尚未暴露读取契约, -// 因此此处仍只能直连只读查询;待 admin 提供 SettingsService 契约后应改为调用契约。 -func listPublicSystemConfigs(ctx context.Context, appCtx *core.Context) ([]publicSystemConfig, error) { - dbSvc, err := core.Inject[contracts.DBService](appCtx) - if err != nil { - return nil, err - } - if dbSvc == nil { - return nil, nil - } - var configs []publicSystemConfig - err = dbSvc.DB(ctx).Table("w_system_configs"). - Where("visibility = ?", "visible"). - Find(&configs).Error - return configs, err -} diff --git a/backend/plugins/domain/user/plugin.go b/backend/plugins/domain/user/plugin.go index 42e93383..744e7b90 100644 --- a/backend/plugins/domain/user/plugin.go +++ b/backend/plugins/domain/user/plugin.go @@ -9,6 +9,7 @@ import ( "Wavelet/core/contracts" "Wavelet/core/extpoints" "Wavelet/pkg/ginutil" + "context" "embed" "reflect" @@ -113,19 +114,12 @@ func (p *Plugin) Apply(ctx *core.Context) error { } core.Provide[contracts.UserService](ctx, p.userSvc) - passThrough := gin.HandlerFunc(func(c *gin.Context) { c.Next() }) - loginCap, registerCap, emailCap := passThrough, passThrough, passThrough - if capSvc, err := core.Inject[contracts.CaptchaService](ctx); err == nil && capSvc != nil { - if mw, ok := capSvc.VerifyMiddleware("login").(gin.HandlerFunc); ok { - loginCap = mw - } - if mw, ok := capSvc.VerifyMiddleware("register").(gin.HandlerFunc); ok { - registerCap = mw - } - if mw, ok := capSvc.VerifyMiddleware("send_email_code").(gin.HandlerFunc); ok { - emailCap = mw - } - } + // CAP middleware is resolved per request. user Apply runs before cap in + // the default plugin list; snapshotting CaptchaService here would leave + // login/register as a permanent pass-through. + loginCap := captchaGuard(ctx, "login") + registerCap := captchaGuard(ctx, "register") + emailCap := captchaGuard(ctx, "send_email_code") // 3. Register HTTP Routes userGroup := ctx.Router().Group("/api/v1/user") @@ -182,3 +176,31 @@ func (p *Plugin) Apply(ctx *core.Context) error { return nil } + +func captchaGuard(appCtx *core.Context, scope string) gin.HandlerFunc { + return func(c *gin.Context) { + svc := resolveCaptchaService(c.Request.Context(), appCtx) + if svc == nil { + c.Next() + return + } + mw, ok := svc.VerifyMiddleware(scope).(gin.HandlerFunc) + if !ok || mw == nil { + c.Next() + return + } + mw(c) + } +} + +func resolveCaptchaService(reqCtx context.Context, appCtx *core.Context) contracts.CaptchaService { + if s, err := core.InjectFrom[contracts.CaptchaService](reqCtx); err == nil && s != nil { + return s + } + if appCtx != nil { + if s, err := core.Inject[contracts.CaptchaService](appCtx); err == nil && s != nil { + return s + } + } + return nil +} diff --git a/backend/plugins/domain/user/plugin_captcha_test.go b/backend/plugins/domain/user/plugin_captcha_test.go index 62d542a8..6fc31d60 100644 --- a/backend/plugins/domain/user/plugin_captcha_test.go +++ b/backend/plugins/domain/user/plugin_captcha_test.go @@ -5,6 +5,8 @@ package user_test import ( "context" + "net/http" + "net/http/httptest" "reflect" "testing" @@ -72,3 +74,77 @@ func TestApplyWithCaptchaServiceWrapsLogin(t *testing.T) { } t.Fatal("missing POST /api/v1/user/login") } + +type denyCaptchaService struct{} + +func (denyCaptchaService) VerifyMiddleware(string) any { + return gin.HandlerFunc(func(c *gin.Context) { + c.AbortWithStatus(http.StatusUnauthorized) + }) +} + +func (denyCaptchaService) ChallengeHandler() any { return gin.HandlerFunc(func(c *gin.Context) {}) } + +func (denyCaptchaService) RedeemHandler() any { return gin.HandlerFunc(func(c *gin.Context) {}) } + +func TestLoginCaptchaGuardResolvesServiceAfterApply(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx := core.NewContext(context.Background()) + if err := user.New().Apply(ctx); err != nil { + t.Fatal(err) + } + core.Provide[contracts.CaptchaService](ctx, denyCaptchaService{}) + + handler := loginCaptchaGuard(t, ctx) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/user/login", nil) + handler(c) + + if !c.IsAborted() { + t.Fatal("login captcha guard did not abort after late CaptchaService provide") + } + if w.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", w.Code, http.StatusUnauthorized) + } +} + +func TestLoginCaptchaGuardPassesWithoutCaptchaService(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx := core.NewContext(context.Background()) + if err := user.New().Apply(ctx); err != nil { + t.Fatal(err) + } + + handler := loginCaptchaGuard(t, ctx) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/user/login", nil) + handler(c) + + if c.IsAborted() { + t.Fatal("login captcha guard aborted without CaptchaService") + } +} + +func loginCaptchaGuard(t *testing.T, ctx *core.Context) gin.HandlerFunc { + t.Helper() + for _, rd := range ctx.Router().Routes() { + if rd.Method != "POST" || rd.Path != "/api/v1/user/login" { + continue + } + if len(rd.Handlers) == 0 { + t.Fatal("POST /api/v1/user/login has no handlers") + } + switch h := rd.Handlers[0].(type) { + case gin.HandlerFunc: + return h + case func(*gin.Context): + return h + default: + t.Fatalf("unexpected handler type %T", rd.Handlers[0]) + } + } + t.Fatal("missing POST /api/v1/user/login") + return nil +}