From 254533c01373b87054bf461af3a72559621711fc Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 30 Aug 2026 10:53:43 +0800 Subject: [PATCH] feat(cap): expose CaptchaService and unversioned /api/cap routes --- backend/core/contracts/captcha.go | 12 +++ backend/plugins/domain/cap/plugin.go | 17 +++++ .../cap/plugin_captcha_contract_test.go | 52 +++++++++++++ backend/plugins/domain/user/plugin.go | 20 ++++- .../domain/user/plugin_captcha_test.go | 74 +++++++++++++++++++ 5 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 backend/core/contracts/captcha.go create mode 100644 backend/plugins/domain/cap/plugin_captcha_contract_test.go create mode 100644 backend/plugins/domain/user/plugin_captcha_test.go diff --git a/backend/core/contracts/captcha.go b/backend/core/contracts/captcha.go new file mode 100644 index 00000000..197c048f --- /dev/null +++ b/backend/core/contracts/captcha.go @@ -0,0 +1,12 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package contracts + +// CaptchaService defines the contract for CAPTCHA challenge issuance, +// redemption, and scoped verification middleware. +type CaptchaService interface { + VerifyMiddleware(scope string) any + ChallengeHandler() any + RedeemHandler() any +} diff --git a/backend/plugins/domain/cap/plugin.go b/backend/plugins/domain/cap/plugin.go index ae047a78..c9ecf749 100644 --- a/backend/plugins/domain/cap/plugin.go +++ b/backend/plugins/domain/cap/plugin.go @@ -77,6 +77,8 @@ func (p *Plugin) Apply(ctx *core.Context) error { InvalidateRuntimeSettings() }) + core.Provide[contracts.CaptchaService](ctx, captchaService{}) + // Register HTTP Routes capGroup := ctx.Router().Group("/api/v1/cap") { @@ -85,6 +87,11 @@ func (p *Plugin) Apply(ctx *core.Context) error { capGroup.POST("/redeem", Redeem) } + legacy := ctx.Router().Group("/api/cap") + legacy.POST("/challenge", Challenge) + legacy.POST("/redeem", Redeem) + ctx.Router().RegisterWhitelist("/api/cap/challenge", "/api/cap/redeem") + // Register Settings Schemas ctx.Settings().Register(extpoints.SettingSchema{ Key: "cap.login_enabled", @@ -103,3 +110,13 @@ func (p *Plugin) Apply(ctx *core.Context) error { return nil } + +type captchaService struct{} + +func (captchaService) VerifyMiddleware(scope string) any { + return VerifyMiddleware(GetDefaultManager(), scope) +} + +func (captchaService) ChallengeHandler() any { return Challenge } + +func (captchaService) RedeemHandler() any { return Redeem } diff --git a/backend/plugins/domain/cap/plugin_captcha_contract_test.go b/backend/plugins/domain/cap/plugin_captcha_contract_test.go new file mode 100644 index 00000000..2eb104f8 --- /dev/null +++ b/backend/plugins/domain/cap/plugin_captcha_contract_test.go @@ -0,0 +1,52 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cap + +import ( + "context" + "testing" + + "Wavelet/core" + "Wavelet/core/contracts" +) + +func TestApplyProvidesCaptchaService(t *testing.T) { + ctx := core.NewContext(context.Background()) + if err := New().Apply(ctx); err != nil { + t.Fatal(err) + } + svc, err := core.Inject[contracts.CaptchaService](ctx) + if err != nil || svc == nil { + t.Fatalf("Inject CaptchaService: svc=%v err=%v", svc, err) + } + if svc.ChallengeHandler() == nil || svc.RedeemHandler() == nil { + t.Fatal("handlers must be non-nil") + } + if svc.VerifyMiddleware("login") == nil { + t.Fatal("VerifyMiddleware(login) must be non-nil") + } +} + +func TestApplyRegistersUnversionedCapRoutes(t *testing.T) { + ctx := core.NewContext(context.Background()) + if err := New().Apply(ctx); err != nil { + t.Fatal(err) + } + want := map[string]bool{ + "POST /api/v1/cap/challenge": false, + "POST /api/cap/challenge": false, + "POST /api/cap/redeem": false, + } + for _, rd := range ctx.Router().Routes() { + key := rd.Method + " " + rd.Path + if _, ok := want[key]; ok { + want[key] = true + } + } + for key, ok := range want { + if !ok { + t.Errorf("missing route %s", key) + } + } +} diff --git a/backend/plugins/domain/user/plugin.go b/backend/plugins/domain/user/plugin.go index b9818d54..ba5ce590 100644 --- a/backend/plugins/domain/user/plugin.go +++ b/backend/plugins/domain/user/plugin.go @@ -120,13 +120,27 @@ 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 + } + } + // 3. Register HTTP Routes userGroup := ctx.Router().Group("/api/v1/user") { - userGroup.POST("/login", Login) - userGroup.POST("/register", Register) + userGroup.POST("/login", loginCap, Login) + userGroup.POST("/register", registerCap, Register) userGroup.GET("/logout", Logout) - userGroup.POST("/send-email-code", SendEmailCode) + userGroup.POST("/send-email-code", emailCap, SendEmailCode) userGroup.POST("/change-password", loginMW, ChangePassword) userGroup.PUT("/profile", loginMW, UpdateProfile) diff --git a/backend/plugins/domain/user/plugin_captcha_test.go b/backend/plugins/domain/user/plugin_captcha_test.go new file mode 100644 index 00000000..62d542a8 --- /dev/null +++ b/backend/plugins/domain/user/plugin_captcha_test.go @@ -0,0 +1,74 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package user_test + +import ( + "context" + "reflect" + "testing" + + "github.com/gin-gonic/gin" + + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/plugins/domain/user" +) + +func TestApplyWithoutCaptchaServiceKeepsAuthRoutes(t *testing.T) { + ctx := core.NewContext(context.Background()) + if err := user.New().Apply(ctx); err != nil { + t.Fatal(err) + } + want := map[string]bool{ + "POST /api/v1/user/login": false, + "POST /api/v1/user/register": false, + "POST /api/v1/user/send-email-code": false, + } + for _, rd := range ctx.Router().Routes() { + key := rd.Method + " " + rd.Path + if _, ok := want[key]; ok { + want[key] = true + } + } + for key, ok := range want { + if !ok { + t.Errorf("missing route %s", key) + } + } +} + +func TestInjectDoesNotRequireCaptchaService(t *testing.T) { + for _, dep := range user.New().Inject() { + if dep == reflect.TypeFor[contracts.CaptchaService]() { + t.Fatal("CaptchaService must not be a hard Inject() dependency") + } + } +} + +type fakeCaptchaService struct{} + +func (fakeCaptchaService) VerifyMiddleware(scope string) any { + return gin.HandlerFunc(func(c *gin.Context) { c.Next() }) +} + +func (fakeCaptchaService) ChallengeHandler() any { return gin.HandlerFunc(func(c *gin.Context) {}) } + +func (fakeCaptchaService) RedeemHandler() any { return gin.HandlerFunc(func(c *gin.Context) {}) } + +func TestApplyWithCaptchaServiceWrapsLogin(t *testing.T) { + ctx := core.NewContext(context.Background()) + core.Provide[contracts.CaptchaService](ctx, fakeCaptchaService{}) + if err := user.New().Apply(ctx); err != nil { + t.Fatal(err) + } + for _, rd := range ctx.Router().Routes() { + if rd.Method == "POST" && rd.Path == "/api/v1/user/login" { + if len(rd.Handlers) <= 1 { + t.Fatalf("login handler chain length = %d, want > 1", len(rd.Handlers)) + } + return + } + } + t.Fatal("missing POST /api/v1/user/login") +}