mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-01 22:46:38 +08:00
perf(cap): add runtime settings snapshot for dynamic config
Replace per-request GetByKey calls with a singleflight-backed RuntimeSettings snapshot loaded via ListSystemConfigsByKeys. Invalidate snapshot on admin cap_* writes and via Redis pub/sub. Simplify VerifyMiddleware and update PERFORMANCE.md status.
This commit is contained in:
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/cap"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/upload"
|
||||
"github.com/Rain-kl/Wavelet/internal/common/response"
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
@@ -93,9 +94,7 @@ func CreateSystemConfig(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := model.InvalidateSystemConfigCache(c.Request.Context(), req.Key); err != nil {
|
||||
logger.WarnF(c.Request.Context(), "清理系统配置缓存失败: %v", err)
|
||||
}
|
||||
invalidateSystemConfigCaches(c.Request.Context(), req.Key)
|
||||
|
||||
if err := model.InvalidateVisibleSystemConfigsCache(c.Request.Context()); err != nil {
|
||||
logger.WarnF(c.Request.Context(), "清理公共配置列表缓存失败: %v", err)
|
||||
@@ -282,10 +281,17 @@ func resolveStorageMigrationTasksOnDirectDriverUpdate(
|
||||
}
|
||||
}
|
||||
|
||||
func invalidateCachesAfterConfigUpdate(ctx context.Context, key string) {
|
||||
func invalidateSystemConfigCaches(ctx context.Context, key string) {
|
||||
if err := model.InvalidateSystemConfigCache(ctx, key); err != nil {
|
||||
logger.WarnF(ctx, "清理系统配置缓存失败: %v", err)
|
||||
}
|
||||
if cap.IsRuntimeConfigKey(key) {
|
||||
cap.InvalidateRuntimeSettings()
|
||||
}
|
||||
}
|
||||
|
||||
func invalidateCachesAfterConfigUpdate(ctx context.Context, key string) {
|
||||
invalidateSystemConfigCaches(ctx, key)
|
||||
|
||||
if key == model.ConfigKeyStorageConfig {
|
||||
upload.ResetAccessCaches()
|
||||
|
||||
+40
-126
@@ -15,73 +15,47 @@ import (
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/config"
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
pkgcap "github.com/Rain-kl/Wavelet/pkg/cap"
|
||||
)
|
||||
|
||||
const (
|
||||
managerDefaultChallengeCount = 1
|
||||
managerDefaultChallengeSize = 32
|
||||
defaultChallengeDifficulty = 4
|
||||
defaultChallengeTTL = 10 * time.Minute
|
||||
defaultTokenTTL = 20 * time.Minute
|
||||
redeemTokenIDLength = 8 // 兑换 Token ID 字节长度
|
||||
redeemVerTokenLength = 15 // 兑换验证 Token 字节长度
|
||||
tokenPartsCount = 2 // 兑换 Token 由两部分组成
|
||||
valuePartsCount = 2 // 存储值由 scope 和过期时间组成
|
||||
redeemTokenIDLength = 8 // 兑换 Token ID 字节长度
|
||||
redeemVerTokenLength = 15 // 兑换验证 Token 字节长度
|
||||
tokenPartsCount = 2 // 兑换 Token 由两部分组成
|
||||
valuePartsCount = 2 // 存储值由 scope 和过期时间组成
|
||||
)
|
||||
|
||||
// Config holds settings for the CAPTCHA manager
|
||||
type Config struct {
|
||||
Secret []byte // HMAC signing key
|
||||
ChallengeCount int // Number of PoW puzzles
|
||||
ChallengeSize int // Size of the salt string
|
||||
ChallengeDifficulty int // Length of difficulty target prefix
|
||||
ChallengeTTL time.Duration // Lifespan of the challenge JWT
|
||||
TokenTTL time.Duration // Lifespan of the redeem token
|
||||
}
|
||||
|
||||
// Manager orchestrates challenge generation and solution validation
|
||||
// Manager orchestrates challenge generation and solution validation.
|
||||
type Manager struct {
|
||||
conf Config
|
||||
store pkgcap.Store
|
||||
secret []byte
|
||||
store pkgcap.Store
|
||||
}
|
||||
|
||||
// NewManager creates a new CAPTCHA Manager
|
||||
func NewManager(conf Config, store pkgcap.Store) *Manager {
|
||||
if conf.ChallengeCount <= 0 {
|
||||
conf.ChallengeCount = managerDefaultChallengeCount
|
||||
}
|
||||
if conf.ChallengeSize <= 0 {
|
||||
conf.ChallengeSize = managerDefaultChallengeSize
|
||||
}
|
||||
if conf.ChallengeDifficulty <= 0 {
|
||||
conf.ChallengeDifficulty = defaultChallengeDifficulty
|
||||
}
|
||||
if conf.ChallengeTTL <= 0 {
|
||||
conf.ChallengeTTL = defaultChallengeTTL
|
||||
}
|
||||
if conf.TokenTTL <= 0 {
|
||||
conf.TokenTTL = defaultTokenTTL
|
||||
}
|
||||
// NewManager creates a new CAPTCHA Manager.
|
||||
func NewManager(secret []byte, store pkgcap.Store) *Manager {
|
||||
return &Manager{
|
||||
conf: conf,
|
||||
store: store,
|
||||
secret: secret,
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
// Generate creates a challenge response
|
||||
// Generate creates a challenge response.
|
||||
func (m *Manager) Generate(ctx context.Context, scope string) (*pkgcap.ChallengeResponse, error) {
|
||||
c := pkgcap.ChallengeConfig{
|
||||
Count: m.getChallengeCount(ctx),
|
||||
Size: m.getChallengeSize(ctx),
|
||||
Difficulty: m.getChallengeDifficulty(ctx),
|
||||
Expires: m.getChallengeTTL(ctx),
|
||||
settings, err := CurrentSettings(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pkgcap.GenerateChallenge(m.conf.Secret, c, scope)
|
||||
|
||||
challengeConfig := pkgcap.ChallengeConfig{
|
||||
Count: settings.ChallengeCount,
|
||||
Size: settings.ChallengeSize,
|
||||
Difficulty: settings.ChallengeDifficulty,
|
||||
Expires: settings.ChallengeTTL,
|
||||
}
|
||||
return pkgcap.GenerateChallenge(m.secret, challengeConfig, scope)
|
||||
}
|
||||
|
||||
// RedeemResponse is returned to the client on redeem
|
||||
// RedeemResponse is returned to the client on redeem.
|
||||
type RedeemResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Token string `json:"token,omitempty"`
|
||||
@@ -89,7 +63,7 @@ type RedeemResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Redeem verifies PoW solutions and returns a one-time redeem token
|
||||
// Redeem verifies PoW solutions and returns a one-time redeem token.
|
||||
func (m *Manager) Redeem(ctx context.Context, token string, solutions []int, scope string) (*RedeemResponse, error) {
|
||||
sigHex := pkgcap.JwtSigHex(token)
|
||||
if sigHex == "" {
|
||||
@@ -98,21 +72,17 @@ func (m *Manager) Redeem(ctx context.Context, token string, solutions []int, sco
|
||||
|
||||
nonceKey := "cap:nonce:" + sigHex
|
||||
|
||||
// Atomically claim the nonce slot BEFORE verifying solutions.
|
||||
payload, err := pkgcap.VerifyChallengeSolutions(token, solutions, m.conf.Secret, scope)
|
||||
payload, err := pkgcap.VerifyChallengeSolutions(token, solutions, m.secret, scope)
|
||||
if err != nil {
|
||||
return &RedeemResponse{Success: false, Error: err.Error()}, nil //nolint:nilerr // expected behavior: validation error is returned as response, not system error
|
||||
return &RedeemResponse{Success: false, Error: err.Error()}, nil //nolint:nilerr // validation errors are returned as response, not system errors
|
||||
}
|
||||
|
||||
// Calculate remaining lifetime of the challenge JWT for the nonce TTL.
|
||||
now := time.Now().UnixNano() / int64(time.Millisecond)
|
||||
nonceTTL := time.Duration(payload.Expires-now) * time.Millisecond
|
||||
if nonceTTL < time.Second {
|
||||
nonceTTL = time.Second
|
||||
}
|
||||
|
||||
// Atomic claim: if another goroutine already redeemed this JWT the SetNX
|
||||
// will return false and we reject the request without issuing a token.
|
||||
set, err := m.store.SetNX(ctx, nonceKey, "1", nonceTTL)
|
||||
if err != nil {
|
||||
return &RedeemResponse{Success: false, Error: "nonce_store_error"}, err
|
||||
@@ -121,20 +91,21 @@ func (m *Manager) Redeem(ctx context.Context, token string, solutions []int, sco
|
||||
return &RedeemResponse{Success: false, Error: "already_redeemed"}, nil
|
||||
}
|
||||
|
||||
// Generate a redeem token formatted as "id:verToken"
|
||||
settings, err := CurrentSettings(ctx)
|
||||
if err != nil {
|
||||
return &RedeemResponse{Success: false, Error: "settings_load_error"}, err
|
||||
}
|
||||
|
||||
id := pkgcap.RandomHex(redeemTokenIDLength)
|
||||
verToken := pkgcap.RandomHex(redeemVerTokenLength)
|
||||
verHashBytes := sha256.Sum256([]byte(verToken))
|
||||
verHashHex := hex.EncodeToString(verHashBytes[:])
|
||||
|
||||
tokenKey := "cap:token:" + id + ":" + verHashHex
|
||||
tokenTTL := m.getTokenTTL(ctx)
|
||||
tokenExpires := time.Now().Add(tokenTTL)
|
||||
|
||||
// Value stored is "expiresNano|scope"
|
||||
tokenExpires := time.Now().Add(settings.TokenTTL)
|
||||
storeVal := strconv.FormatInt(tokenExpires.UnixNano(), 10) + "|" + scope
|
||||
|
||||
if err := m.store.Set(ctx, tokenKey, storeVal, tokenTTL); err != nil {
|
||||
if err := m.store.Set(ctx, tokenKey, storeVal, settings.TokenTTL); err != nil {
|
||||
return &RedeemResponse{Success: false, Error: "token_store_error"}, err
|
||||
}
|
||||
|
||||
@@ -162,7 +133,6 @@ func (m *Manager) VerifyToken(ctx context.Context, token string, expectedScope s
|
||||
|
||||
tokenKey := "cap:token:" + id + ":" + verHashHex
|
||||
|
||||
// Atomically retrieve-and-delete
|
||||
val, exists, err := sGetAndDelete(ctx, m.store, tokenKey)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -178,7 +148,7 @@ func (m *Manager) VerifyToken(ctx context.Context, token string, expectedScope s
|
||||
|
||||
expNano, err := strconv.ParseInt(valParts[0], 10, 64)
|
||||
if err != nil {
|
||||
return false, nil //nolint:nilerr // expected behavior: invalid format is treated as validation failure, not system error
|
||||
return false, nil //nolint:nilerr // invalid format is treated as validation failure
|
||||
}
|
||||
tokenScope := valParts[1]
|
||||
|
||||
@@ -187,13 +157,12 @@ func (m *Manager) VerifyToken(ctx context.Context, token string, expectedScope s
|
||||
}
|
||||
|
||||
if time.Now().UnixNano() > expNano {
|
||||
return false, nil // Expired
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// sGetAndDelete safely calls store.GetAndDelete, treating a nil store as a miss.
|
||||
func sGetAndDelete(ctx context.Context, store pkgcap.Store, key string) (string, bool, error) {
|
||||
if store == nil {
|
||||
return "", false, nil
|
||||
@@ -201,67 +170,19 @@ func sGetAndDelete(ctx context.Context, store pkgcap.Store, key string) (string,
|
||||
return store.GetAndDelete(ctx, key)
|
||||
}
|
||||
|
||||
func (m *Manager) getChallengeCount(ctx context.Context) int {
|
||||
val, err := model.GetIntByKey(ctx, model.ConfigKeyCapChallengeCount)
|
||||
if err != nil || val <= 0 {
|
||||
return m.conf.ChallengeCount
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
func (m *Manager) getChallengeSize(ctx context.Context) int {
|
||||
val, err := model.GetIntByKey(ctx, model.ConfigKeyCapChallengeSize)
|
||||
if err != nil || val <= 0 {
|
||||
return m.conf.ChallengeSize
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
func (m *Manager) getChallengeDifficulty(ctx context.Context) int {
|
||||
val, err := model.GetIntByKey(ctx, model.ConfigKeyCapChallengeDifficulty)
|
||||
if err != nil || val <= 0 {
|
||||
return m.conf.ChallengeDifficulty
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
func (m *Manager) getChallengeTTL(ctx context.Context) time.Duration {
|
||||
val, err := model.GetIntByKey(ctx, model.ConfigKeyCapChallengeTTL)
|
||||
if err != nil || val <= 0 {
|
||||
return m.conf.ChallengeTTL
|
||||
}
|
||||
return time.Duration(val) * time.Second
|
||||
}
|
||||
|
||||
func (m *Manager) getTokenTTL(ctx context.Context) time.Duration {
|
||||
val, err := model.GetIntByKey(ctx, model.ConfigKeyCapTokenTTL)
|
||||
if err != nil || val <= 0 {
|
||||
return m.conf.TokenTTL
|
||||
}
|
||||
return time.Duration(val) * time.Second
|
||||
}
|
||||
|
||||
var (
|
||||
defaultManager *Manager
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// GetDefaultManager yields the global singleton CAPTCHA manager
|
||||
// GetDefaultManager yields the global singleton CAPTCHA manager.
|
||||
func GetDefaultManager() *Manager {
|
||||
once.Do(func() {
|
||||
var secret []byte
|
||||
secret := []byte("default-captcha-secret-key-at-least-16-bytes")
|
||||
if config.Config != nil && config.Config.App.SessionSecret != "" {
|
||||
secret = []byte(config.Config.App.SessionSecret)
|
||||
} else {
|
||||
secret = []byte("default-captcha-secret-key-at-least-16-bytes")
|
||||
}
|
||||
|
||||
challengeCount := managerDefaultChallengeCount
|
||||
challengeSize := managerDefaultChallengeSize
|
||||
challengeDifficulty := defaultChallengeDifficulty
|
||||
challengeTTL := defaultChallengeTTL
|
||||
tokenTTL := defaultTokenTTL
|
||||
|
||||
var store pkgcap.Store
|
||||
if config.Config != nil && config.Config.Redis.Enabled && db.Redis != nil {
|
||||
store = pkgcap.NewRedisStore(db.Redis)
|
||||
@@ -269,14 +190,7 @@ func GetDefaultManager() *Manager {
|
||||
store = pkgcap.NewMemoryStore(1 * time.Minute)
|
||||
}
|
||||
|
||||
defaultManager = NewManager(Config{
|
||||
Secret: secret,
|
||||
ChallengeCount: challengeCount,
|
||||
ChallengeSize: challengeSize,
|
||||
ChallengeDifficulty: challengeDifficulty,
|
||||
ChallengeTTL: challengeTTL,
|
||||
TokenTTL: tokenTTL,
|
||||
}, store)
|
||||
defaultManager = NewManager(secret, store)
|
||||
})
|
||||
return defaultManager
|
||||
}
|
||||
}
|
||||
@@ -13,151 +13,141 @@ import (
|
||||
pkgcap "github.com/Rain-kl/Wavelet/pkg/cap"
|
||||
)
|
||||
|
||||
func TestCapFullFlow(t *testing.T) {
|
||||
secret := []byte("a-very-long-secret-key-at-least-16-bytes")
|
||||
store := pkgcap.NewMemoryStore(1 * time.Minute)
|
||||
|
||||
manager := NewManager(Config{
|
||||
Secret: secret,
|
||||
ChallengeCount: 3, // small count for fast test
|
||||
func installTestManagerSettings(t *testing.T) func() {
|
||||
t.Helper()
|
||||
return InstallTestRuntimeSettings(RuntimeSettings{
|
||||
ChallengeCount: 3,
|
||||
ChallengeSize: 32,
|
||||
ChallengeDifficulty: 3, // small difficulty for fast test
|
||||
ChallengeDifficulty: 3,
|
||||
ChallengeTTL: 5 * time.Second,
|
||||
TokenTTL: 10 * time.Second,
|
||||
}, store)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCapFullFlow(t *testing.T) {
|
||||
cleanup := installTestManagerSettings(t)
|
||||
defer cleanup()
|
||||
|
||||
secret := []byte("a-very-long-secret-key-at-least-16-bytes")
|
||||
store := pkgcap.NewMemoryStore(1 * time.Minute)
|
||||
manager := NewManager(secret, store)
|
||||
|
||||
scope := "test-scope"
|
||||
ctx := context.Background()
|
||||
resp, err := manager.Generate(ctx, scope)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
t.Fatalf("Generate() error = %v", err)
|
||||
}
|
||||
|
||||
if resp.Challenge.C != 3 {
|
||||
t.Errorf("Expected count 3, got %d", resp.Challenge.C)
|
||||
t.Fatalf("Generate().Challenge.C = %d, want %d", resp.Challenge.C, 3)
|
||||
}
|
||||
|
||||
// Solve the challenge (acting as client)
|
||||
solutions := pkgcap.Solve(resp.Token, resp.Challenge.C, resp.Challenge.S, resp.Challenge.D)
|
||||
|
||||
// Redeem
|
||||
redeemResp, err := manager.Redeem(ctx, resp.Token, solutions, scope)
|
||||
if err != nil {
|
||||
t.Fatalf("Redeem failed: %v", err)
|
||||
t.Fatalf("Redeem() error = %v", err)
|
||||
}
|
||||
if !redeemResp.Success {
|
||||
t.Fatalf("Redeem returned success=false: %s", redeemResp.Error)
|
||||
t.Fatalf("Redeem().Success = false, error = %s", redeemResp.Error)
|
||||
}
|
||||
if redeemResp.Token == "" {
|
||||
t.Fatalf("Expected token, got empty")
|
||||
t.Fatal("Redeem().Token is empty")
|
||||
}
|
||||
|
||||
// Verify the token
|
||||
valid, err := manager.VerifyToken(ctx, redeemResp.Token, scope)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyToken failed: %v", err)
|
||||
t.Fatalf("VerifyToken() error = %v", err)
|
||||
}
|
||||
if !valid {
|
||||
t.Fatalf("Expected redeem token to be valid")
|
||||
t.Fatal("VerifyToken() = false, want true")
|
||||
}
|
||||
|
||||
// Verify token is one-time use
|
||||
validAgain, err := manager.VerifyToken(ctx, redeemResp.Token, scope)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyToken second call failed: %v", err)
|
||||
t.Fatalf("VerifyToken() second call error = %v", err)
|
||||
}
|
||||
if validAgain {
|
||||
t.Fatalf("Expected redeem token to be single-use (invalidated after verification)")
|
||||
t.Fatal("VerifyToken() second call = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedeemConcurrentRace verifies that when N goroutines simultaneously call
|
||||
// Redeem with the same challenge JWT, exactly one succeeds and the rest are
|
||||
// rejected with "already_redeemed". This guards against the TOCTOU fix.
|
||||
func TestRedeemConcurrentRace(t *testing.T) {
|
||||
const goroutines = 50
|
||||
|
||||
cleanup := installTestManagerSettings(t)
|
||||
defer cleanup()
|
||||
|
||||
secret := []byte("race-test-secret-key-at-least-16-bytes")
|
||||
store := pkgcap.NewMemoryStore(1 * time.Minute)
|
||||
manager := NewManager(Config{
|
||||
Secret: secret,
|
||||
ChallengeCount: 1,
|
||||
ChallengeSize: 32,
|
||||
ChallengeDifficulty: 3,
|
||||
ChallengeTTL: 30 * time.Second,
|
||||
TokenTTL: 30 * time.Second,
|
||||
}, store)
|
||||
manager := NewManager(secret, store)
|
||||
|
||||
ctx := context.Background()
|
||||
resp, err := manager.Generate(ctx, "login")
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
t.Fatalf("Generate() error = %v", err)
|
||||
}
|
||||
solutions := pkgcap.Solve(resp.Token, resp.Challenge.C, resp.Challenge.S, resp.Challenge.D)
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
success atomic.Int32
|
||||
barrier = make(chan struct{}) // synchronise goroutine start
|
||||
)
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-barrier // wait for the gun
|
||||
r, _ := manager.Redeem(ctx, resp.Token, solutions, "login")
|
||||
if r != nil && r.Success {
|
||||
success.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(barrier) // fire all goroutines at once
|
||||
wg.Wait()
|
||||
|
||||
if n := success.Load(); n != 1 {
|
||||
t.Fatalf("Expected exactly 1 successful Redeem, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyTokenConcurrentRace verifies that when N goroutines simultaneously
|
||||
// call VerifyToken with the same cap token, exactly one succeeds and the rest
|
||||
// fail. This guards against the GetAndDelete fix.
|
||||
func TestVerifyTokenConcurrentRace(t *testing.T) {
|
||||
const goroutines = 50
|
||||
|
||||
secret := []byte("race-test-secret-key-at-least-16-bytes")
|
||||
store := pkgcap.NewMemoryStore(1 * time.Minute)
|
||||
manager := NewManager(Config{
|
||||
Secret: secret,
|
||||
ChallengeCount: 1,
|
||||
ChallengeSize: 32,
|
||||
ChallengeDifficulty: 3,
|
||||
ChallengeTTL: 30 * time.Second,
|
||||
TokenTTL: 30 * time.Second,
|
||||
}, store)
|
||||
|
||||
ctx := context.Background()
|
||||
resp, _ := manager.Generate(ctx, "login")
|
||||
solutions := pkgcap.Solve(resp.Token, resp.Challenge.C, resp.Challenge.S, resp.Challenge.D)
|
||||
redeemResp, err := manager.Redeem(ctx, resp.Token, solutions, "login")
|
||||
if err != nil || !redeemResp.Success {
|
||||
t.Fatalf("Redeem failed: %v %+v", err, redeemResp)
|
||||
}
|
||||
capToken := redeemResp.Token
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
success atomic.Int32
|
||||
barrier = make(chan struct{})
|
||||
)
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
for range goroutines {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-barrier
|
||||
ok, _ := manager.VerifyToken(ctx, capToken, "login")
|
||||
r, _ := manager.Redeem(ctx, resp.Token, solutions, "login")
|
||||
if r != nil && r.Success {
|
||||
success.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(barrier)
|
||||
wg.Wait()
|
||||
|
||||
if got := success.Load(); got != 1 {
|
||||
t.Fatalf("successful Redeem count = %d, want %d", got, 1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyTokenConcurrentRace(t *testing.T) {
|
||||
const goroutines = 50
|
||||
|
||||
cleanup := installTestManagerSettings(t)
|
||||
defer cleanup()
|
||||
|
||||
secret := []byte("race-test-secret-key-at-least-16-bytes")
|
||||
store := pkgcap.NewMemoryStore(1 * time.Minute)
|
||||
manager := NewManager(secret, store)
|
||||
|
||||
ctx := context.Background()
|
||||
resp, err := manager.Generate(ctx, "login")
|
||||
if err != nil {
|
||||
t.Fatalf("Generate() error = %v", err)
|
||||
}
|
||||
solutions := pkgcap.Solve(resp.Token, resp.Challenge.C, resp.Challenge.S, resp.Challenge.D)
|
||||
redeemResp, err := manager.Redeem(ctx, resp.Token, solutions, "login")
|
||||
if err != nil || !redeemResp.Success {
|
||||
t.Fatalf("Redeem() error = %v, resp = %+v", err, redeemResp)
|
||||
}
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
success atomic.Int32
|
||||
barrier = make(chan struct{})
|
||||
)
|
||||
|
||||
for range goroutines {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-barrier
|
||||
ok, _ := manager.VerifyToken(ctx, redeemResp.Token, "login")
|
||||
if ok {
|
||||
success.Add(1)
|
||||
}
|
||||
@@ -166,7 +156,7 @@ func TestVerifyTokenConcurrentRace(t *testing.T) {
|
||||
close(barrier)
|
||||
wg.Wait()
|
||||
|
||||
if n := success.Load(); n != 1 {
|
||||
t.Fatalf("Expected exactly 1 successful VerifyToken, got %d", n)
|
||||
if got := success.Load(); got != 1 {
|
||||
t.Fatalf("successful VerifyToken count = %d, want %d", got, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,9 @@ import (
|
||||
)
|
||||
|
||||
// VerifyMiddleware returns a Gin middleware that checks and consumes the X-Cap-Token header.
|
||||
// enabledFunc is an optional callback allowing dynamic check of whether captcha protection is turned on.
|
||||
func VerifyMiddleware(mgr *Manager, scope string, enabledFunc func() bool) gin.HandlerFunc {
|
||||
func VerifyMiddleware(mgr *Manager, scope string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if enabledFunc != nil && !enabledFunc() {
|
||||
if !ProtectionEnabled(c.Request.Context()) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
@@ -34,4 +33,4 @@ func VerifyMiddleware(mgr *Manager, scope string, enabledFunc func() bool) gin.H
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,20 +3,21 @@
|
||||
|
||||
package cap
|
||||
|
||||
import ("bytes"
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/testhelper"
|
||||
pkgcap "github.com/Rain-kl/Wavelet/pkg/cap"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/common/response")
|
||||
"github.com/Rain-kl/Wavelet/internal/common/response"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/testhelper"
|
||||
pkgcap "github.com/Rain-kl/Wavelet/pkg/cap"
|
||||
)
|
||||
|
||||
func TestCapEndpointsAndMiddleware(t *testing.T) {
|
||||
sqliteDB, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||
@@ -32,14 +33,7 @@ func TestCapEndpointsAndMiddleware(t *testing.T) {
|
||||
capGroup.POST("/redeem", Redeem)
|
||||
}
|
||||
|
||||
// Login endpoint with CAPTCHA middleware
|
||||
r.POST("/api/v1/user/login", VerifyMiddleware(GetDefaultManager(), "login", func() bool {
|
||||
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyCapLoginEnabled)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return enabled
|
||||
}), func(c *gin.Context) {
|
||||
r.POST("/api/v1/user/login", VerifyMiddleware(GetDefaultManager(), "login"), func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response.OK("login success"))
|
||||
})
|
||||
|
||||
@@ -69,15 +63,15 @@ func TestCapEndpointsAndMiddleware(t *testing.T) {
|
||||
t.Fatalf("expected 200 OK when CAPTCHA is disabled, got %d. Body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 3. Enable CAPTCHA in DB
|
||||
// 3. Enable CAPTCHA in DB and invalidate runtime snapshot
|
||||
err := sqliteDB.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyCapLoginEnabled).Update("value", "true").Error
|
||||
if err != nil {
|
||||
t.Fatalf("failed to enable cap_login_enabled in DB: %v", err)
|
||||
}
|
||||
// Update cache
|
||||
var sysCfg model.SystemConfig
|
||||
sqliteDB.Where("key = ?", model.ConfigKeyCapLoginEnabled).First(&sysCfg)
|
||||
_ = db.HSetJSON(context.Background(), model.SystemConfigRedisHashKey, model.ConfigKeyCapLoginEnabled, &sysCfg)
|
||||
if err := model.InvalidateSystemConfigCache(context.Background(), model.ConfigKeyCapLoginEnabled); err != nil {
|
||||
t.Fatalf("InvalidateSystemConfigCache() error = %v", err)
|
||||
}
|
||||
InvalidateRuntimeSettings()
|
||||
|
||||
// 4. Test login with CAPTCHA enabled but no header (should be blocked)
|
||||
w = httptest.NewRecorder()
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultChallengeCount = 1
|
||||
defaultChallengeSize = 32
|
||||
defaultChallengeDifficulty = 4
|
||||
defaultChallengeTTL = 10 * time.Minute
|
||||
defaultTokenTTL = 20 * time.Minute
|
||||
)
|
||||
|
||||
// RuntimeSettings is the parsed CAPTCHA runtime configuration loaded from system_configs.
|
||||
type RuntimeSettings struct {
|
||||
LoginEnabled bool
|
||||
ChallengeCount int
|
||||
ChallengeSize int
|
||||
ChallengeDifficulty int
|
||||
ChallengeTTL time.Duration
|
||||
TokenTTL time.Duration
|
||||
}
|
||||
|
||||
var runtimeConfigKeys = []string{
|
||||
model.ConfigKeyCapLoginEnabled,
|
||||
model.ConfigKeyCapChallengeCount,
|
||||
model.ConfigKeyCapChallengeSize,
|
||||
model.ConfigKeyCapChallengeDifficulty,
|
||||
model.ConfigKeyCapChallengeTTL,
|
||||
model.ConfigKeyCapTokenTTL,
|
||||
}
|
||||
|
||||
var runtimeConfigKeySet = func() map[string]struct{} {
|
||||
set := make(map[string]struct{}, len(runtimeConfigKeys))
|
||||
for _, key := range runtimeConfigKeys {
|
||||
set[key] = struct{}{}
|
||||
}
|
||||
return set
|
||||
}()
|
||||
|
||||
type runtimeSettingsStore struct {
|
||||
snapshot atomic.Pointer[RuntimeSettings]
|
||||
loadGroup singleflight.Group
|
||||
listenerOnce sync.Once
|
||||
}
|
||||
|
||||
var settingsStore = &runtimeSettingsStore{}
|
||||
|
||||
// IsRuntimeConfigKey reports whether a system config key affects CAPTCHA runtime settings.
|
||||
func IsRuntimeConfigKey(key string) bool {
|
||||
_, ok := runtimeConfigKeySet[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// CurrentSettings returns the cached CAPTCHA runtime settings snapshot.
|
||||
func CurrentSettings(ctx context.Context) (RuntimeSettings, error) {
|
||||
return settingsStore.current(ctx)
|
||||
}
|
||||
|
||||
// ProtectionEnabled reports whether CAPTCHA verification is required for protected routes.
|
||||
func ProtectionEnabled(ctx context.Context) bool {
|
||||
settings, err := CurrentSettings(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return settings.LoginEnabled
|
||||
}
|
||||
|
||||
// InvalidateRuntimeSettings drops the in-process CAPTCHA settings snapshot.
|
||||
func InvalidateRuntimeSettings() {
|
||||
settingsStore.snapshot.Store(nil)
|
||||
}
|
||||
|
||||
// ResetRuntimeSettingsForTest clears the CAPTCHA runtime snapshot.
|
||||
func ResetRuntimeSettingsForTest() {
|
||||
InvalidateRuntimeSettings()
|
||||
}
|
||||
|
||||
// InstallTestRuntimeSettings installs a fixed snapshot for unit tests.
|
||||
func InstallTestRuntimeSettings(settings RuntimeSettings) func() {
|
||||
snapshot := settings
|
||||
settingsStore.snapshot.Store(&snapshot)
|
||||
return InvalidateRuntimeSettings
|
||||
}
|
||||
|
||||
func (s *runtimeSettingsStore) current(ctx context.Context) (RuntimeSettings, error) {
|
||||
s.ensureInvalidationListener()
|
||||
|
||||
if snapshot := s.snapshot.Load(); snapshot != nil {
|
||||
return *snapshot, nil
|
||||
}
|
||||
|
||||
loaded, err, _ := s.loadGroup.Do("cap-runtime-settings", func() (any, error) {
|
||||
if snapshot := s.snapshot.Load(); snapshot != nil {
|
||||
return *snapshot, nil
|
||||
}
|
||||
|
||||
settings, loadErr := loadRuntimeSettings(ctx)
|
||||
if loadErr != nil {
|
||||
return RuntimeSettings{}, loadErr
|
||||
}
|
||||
|
||||
s.snapshot.Store(&settings)
|
||||
return settings, nil
|
||||
})
|
||||
if err != nil {
|
||||
return RuntimeSettings{}, err
|
||||
}
|
||||
|
||||
settings, ok := loaded.(RuntimeSettings)
|
||||
if !ok {
|
||||
return RuntimeSettings{}, errors.New("cap runtime settings loader returned unexpected type")
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func loadRuntimeSettings(ctx context.Context) (RuntimeSettings, error) {
|
||||
configs, err := model.ListSystemConfigsByKeys(ctx, runtimeConfigKeys)
|
||||
if err != nil {
|
||||
return RuntimeSettings{}, err
|
||||
}
|
||||
return parseRuntimeSettings(configs), nil
|
||||
}
|
||||
|
||||
func parseRuntimeSettings(configs map[string]model.SystemConfig) RuntimeSettings {
|
||||
settings := RuntimeSettings{
|
||||
ChallengeCount: defaultChallengeCount,
|
||||
ChallengeSize: defaultChallengeSize,
|
||||
ChallengeDifficulty: defaultChallengeDifficulty,
|
||||
ChallengeTTL: defaultChallengeTTL,
|
||||
TokenTTL: defaultTokenTTL,
|
||||
}
|
||||
|
||||
if sc, ok := configs[model.ConfigKeyCapLoginEnabled]; ok {
|
||||
if enabled, err := strconv.ParseBool(sc.Value); err == nil {
|
||||
settings.LoginEnabled = enabled
|
||||
}
|
||||
}
|
||||
if sc, ok := configs[model.ConfigKeyCapChallengeCount]; ok {
|
||||
if count, err := strconv.Atoi(sc.Value); err == nil && count > 0 {
|
||||
settings.ChallengeCount = count
|
||||
}
|
||||
}
|
||||
if sc, ok := configs[model.ConfigKeyCapChallengeSize]; ok {
|
||||
if size, err := strconv.Atoi(sc.Value); err == nil && size > 0 {
|
||||
settings.ChallengeSize = size
|
||||
}
|
||||
}
|
||||
if sc, ok := configs[model.ConfigKeyCapChallengeDifficulty]; ok {
|
||||
if difficulty, err := strconv.Atoi(sc.Value); err == nil && difficulty > 0 {
|
||||
settings.ChallengeDifficulty = difficulty
|
||||
}
|
||||
}
|
||||
if sc, ok := configs[model.ConfigKeyCapChallengeTTL]; ok {
|
||||
if ttlSeconds, err := strconv.Atoi(sc.Value); err == nil && ttlSeconds > 0 {
|
||||
settings.ChallengeTTL = time.Duration(ttlSeconds) * time.Second
|
||||
}
|
||||
}
|
||||
if sc, ok := configs[model.ConfigKeyCapTokenTTL]; ok {
|
||||
if ttlSeconds, err := strconv.Atoi(sc.Value); err == nil && ttlSeconds > 0 {
|
||||
settings.TokenTTL = time.Duration(ttlSeconds) * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
return settings
|
||||
}
|
||||
|
||||
func (s *runtimeSettingsStore) ensureInvalidationListener() {
|
||||
s.listenerOnce.Do(startRuntimeSettingsInvalidationListener)
|
||||
}
|
||||
|
||||
func startRuntimeSettingsInvalidationListener() {
|
||||
if db.Redis == nil {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
pubsub := db.Redis.Subscribe(context.Background(), model.SystemConfigInvalidationChannel)
|
||||
defer func() {
|
||||
_ = pubsub.Close()
|
||||
}()
|
||||
|
||||
for msg := range pubsub.Channel() {
|
||||
var payload struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(msg.Payload), &payload); err != nil {
|
||||
InvalidateRuntimeSettings()
|
||||
continue
|
||||
}
|
||||
if payload.Key == "" || payload.Key == "*" || IsRuntimeConfigKey(payload.Key) {
|
||||
InvalidateRuntimeSettings()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/testhelper"
|
||||
)
|
||||
|
||||
func TestCurrentSettingsLoadsSnapshotOnce(t *testing.T) {
|
||||
_, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
ResetRuntimeSettingsForTest()
|
||||
model.ResetSystemConfigRAMCacheForTest()
|
||||
|
||||
first, err := CurrentSettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CurrentSettings() first error = %v", err)
|
||||
}
|
||||
if first.ChallengeCount != 1 {
|
||||
t.Fatalf("CurrentSettings().ChallengeCount = %d, want %d", first.ChallengeCount, 1)
|
||||
}
|
||||
|
||||
if err := db.DB(ctx).Model(&model.SystemConfig{}).
|
||||
Where("key = ?", model.ConfigKeyCapChallengeCount).
|
||||
Update("value", "4").Error; err != nil {
|
||||
t.Fatalf("Update(cap_challenge_count) error = %v", err)
|
||||
}
|
||||
if err := model.InvalidateSystemConfigCache(ctx, model.ConfigKeyCapChallengeCount); err != nil {
|
||||
t.Fatalf("InvalidateSystemConfigCache() error = %v", err)
|
||||
}
|
||||
InvalidateRuntimeSettings()
|
||||
|
||||
second, err := CurrentSettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CurrentSettings() second error = %v", err)
|
||||
}
|
||||
if second.ChallengeCount != 4 {
|
||||
t.Fatalf("CurrentSettings().ChallengeCount = %d, want %d", second.ChallengeCount, 4)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectionEnabledReflectsLoginSwitch(t *testing.T) {
|
||||
_, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
ResetRuntimeSettingsForTest()
|
||||
|
||||
if ProtectionEnabled(ctx) {
|
||||
t.Fatal("ProtectionEnabled() = true, want false from seed defaults")
|
||||
}
|
||||
|
||||
if err := db.DB(ctx).Model(&model.SystemConfig{}).
|
||||
Where("key = ?", model.ConfigKeyCapLoginEnabled).
|
||||
Update("value", "true").Error; err != nil {
|
||||
t.Fatalf("Update(cap_login_enabled) error = %v", err)
|
||||
}
|
||||
if err := model.InvalidateSystemConfigCache(ctx, model.ConfigKeyCapLoginEnabled); err != nil {
|
||||
t.Fatalf("InvalidateSystemConfigCache() error = %v", err)
|
||||
}
|
||||
InvalidateRuntimeSettings()
|
||||
|
||||
if !ProtectionEnabled(ctx) {
|
||||
t.Fatal("ProtectionEnabled() = false, want true after config update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRuntimeSettingsUsesDefaultsForMissingKeys(t *testing.T) {
|
||||
settings := parseRuntimeSettings(map[string]model.SystemConfig{})
|
||||
|
||||
if settings.ChallengeCount != defaultChallengeCount {
|
||||
t.Fatalf("ChallengeCount = %d, want %d", settings.ChallengeCount, defaultChallengeCount)
|
||||
}
|
||||
if settings.ChallengeTTL != defaultChallengeTTL {
|
||||
t.Fatalf("ChallengeTTL = %s, want %s", settings.ChallengeTTL, defaultChallengeTTL)
|
||||
}
|
||||
if settings.TokenTTL != defaultTokenTTL {
|
||||
t.Fatalf("TokenTTL = %s, want %s", settings.TokenTTL, defaultTokenTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRuntimeConfigKey(t *testing.T) {
|
||||
if !IsRuntimeConfigKey(model.ConfigKeyCapChallengeCount) {
|
||||
t.Fatalf("IsRuntimeConfigKey(%s) = false, want true", model.ConfigKeyCapChallengeCount)
|
||||
}
|
||||
if IsRuntimeConfigKey(model.ConfigKeySiteName) {
|
||||
t.Fatalf("IsRuntimeConfigKey(%s) = true, want false", model.ConfigKeySiteName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallTestRuntimeSettings(t *testing.T) {
|
||||
cleanup := InstallTestRuntimeSettings(RuntimeSettings{
|
||||
LoginEnabled: true,
|
||||
ChallengeCount: 2,
|
||||
TokenTTL: 30 * time.Minute,
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
settings, err := CurrentSettings(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("CurrentSettings() error = %v", err)
|
||||
}
|
||||
if !settings.LoginEnabled {
|
||||
t.Fatal("LoginEnabled = false, want true")
|
||||
}
|
||||
if settings.ChallengeCount != 2 {
|
||||
t.Fatalf("ChallengeCount = %d, want %d", settings.ChallengeCount, 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cap
|
||||
|
||||
import "github.com/Rain-kl/Wavelet/internal/testhelper"
|
||||
|
||||
func init() {
|
||||
testhelper.RegisterCleanup(ResetRuntimeSettingsForTest)
|
||||
}
|
||||
@@ -115,6 +115,47 @@ func (sc *SystemConfig) GetByKey(ctx context.Context, key string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListSystemConfigsByKeys loads multiple config keys in one database round trip.
|
||||
// Keys already present in the process-local RAM cache are returned without querying PostgreSQL.
|
||||
func ListSystemConfigsByKeys(ctx context.Context, keys []string) (map[string]SystemConfig, error) {
|
||||
if len(keys) == 0 {
|
||||
return map[string]SystemConfig{}, nil
|
||||
}
|
||||
|
||||
ensureSystemConfigCacheListener()
|
||||
|
||||
result := make(map[string]SystemConfig, len(keys))
|
||||
missing := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
if cached, ok := systemConfigRAMCache.GetIfPresent(key); ok {
|
||||
result[key] = cloneSystemConfig(cached)
|
||||
continue
|
||||
}
|
||||
missing = append(missing, key)
|
||||
}
|
||||
|
||||
if len(missing) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
database := db.DB(ctx)
|
||||
if database == nil {
|
||||
return nil, errors.New(errDatabaseNotInitialized)
|
||||
}
|
||||
|
||||
var configs []SystemConfig
|
||||
if err := database.Where("key IN ?", missing).Find(&configs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range configs {
|
||||
populateSystemConfigCache(ctx, configs[i])
|
||||
result[configs[i].Key] = cloneSystemConfig(configs[i])
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// InvalidateVisibleSystemConfigsCache clears the cached public config list.
|
||||
func InvalidateVisibleSystemConfigsCache(ctx context.Context) error {
|
||||
if db.Redis == nil {
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/testhelper"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -21,9 +20,10 @@ func TestCORSMiddleware(t *testing.T) {
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// Helper to clear config cache
|
||||
clearConfigCache := func() {
|
||||
_ = db.Redis.Del(context.Background(), db.PrefixedKey(model.SystemConfigRedisHashKey)).Err()
|
||||
if err := model.InvalidateAllSystemConfigCaches(context.Background()); err != nil {
|
||||
t.Fatalf("InvalidateAllSystemConfigCaches() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("missing server_address configuration returns no CORS headers", func(t *testing.T) {
|
||||
|
||||
@@ -5,14 +5,11 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
capApp "github.com/Rain-kl/Wavelet/internal/apps/cap"
|
||||
publicconfig "github.com/Rain-kl/Wavelet/internal/apps/config"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/upload"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/user"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -64,27 +61,9 @@ func registerOAuthRoutes(apiV1Router *gin.RouterGroup) {
|
||||
func registerUserRoutes(apiV1Router *gin.RouterGroup) {
|
||||
userRouter := apiV1Router.Group("/user")
|
||||
{
|
||||
userRouter.POST("/login", capApp.VerifyMiddleware(capApp.GetDefaultManager(), "login", func() bool {
|
||||
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyCapLoginEnabled)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return enabled
|
||||
}), user.Login)
|
||||
userRouter.POST("/register", capApp.VerifyMiddleware(capApp.GetDefaultManager(), "register", func() bool {
|
||||
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyCapLoginEnabled)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return enabled
|
||||
}), user.Register)
|
||||
userRouter.POST("/send-email-code", capApp.VerifyMiddleware(capApp.GetDefaultManager(), "send_email_code", func() bool {
|
||||
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyCapLoginEnabled)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return enabled
|
||||
}), user.SendEmailCode)
|
||||
userRouter.POST("/login", capApp.VerifyMiddleware(capApp.GetDefaultManager(), "login"), user.Login)
|
||||
userRouter.POST("/register", capApp.VerifyMiddleware(capApp.GetDefaultManager(), "register"), user.Register)
|
||||
userRouter.POST("/send-email-code", capApp.VerifyMiddleware(capApp.GetDefaultManager(), "send_email_code"), user.SendEmailCode)
|
||||
userRouter.GET("/logout", user.Logout)
|
||||
userRouter.GET("/self", oauth.LoginRequired(), oauth.UserInfo)
|
||||
userRouter.POST("/change-password", oauth.LoginRequired(), user.ChangePassword)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package testhelper
|
||||
|
||||
// RegisterCleanup registers an extra cleanup hook invoked by SetupTestEnvironment.
|
||||
func RegisterCleanup(fn func()) {
|
||||
extraCleanups = append(extraCleanups, fn)
|
||||
}
|
||||
|
||||
var extraCleanups []func()
|
||||
|
||||
func runExtraCleanups() {
|
||||
for _, fn := range extraCleanups {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,7 @@ func SetupTestEnvironment(t *testing.T) (*gorm.DB, *miniredis.Miniredis, func())
|
||||
|
||||
// Cleanup function
|
||||
cleanup := func() {
|
||||
runExtraCleanups()
|
||||
model.ResetSystemConfigRAMCacheForTest()
|
||||
_ = redisClient.Close()
|
||||
mr.Close()
|
||||
|
||||
Reference in New Issue
Block a user