mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
455e2f8be5
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.
535 lines
16 KiB
Go
535 lines
16 KiB
Go
// Copyright 2026 Arctel.net
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package domain_test
|
|
|
|
import (
|
|
"Wavelet/core"
|
|
"Wavelet/core/contracts"
|
|
"Wavelet/pkg/idgen"
|
|
"Wavelet/plugins/domain/admin"
|
|
"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"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
|
|
db "Wavelet/plugins/infra/database"
|
|
)
|
|
|
|
func setupTestDB(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
_ = idgen.Init(1)
|
|
dbPath := filepath.Join(t.TempDir(), "domain_test.db")
|
|
testDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, testDB.AutoMigrate(
|
|
&user.User{},
|
|
&user.AccessToken{},
|
|
&auth.AuthSource{},
|
|
&auth.ExternalAccount{},
|
|
&message_gateway.MessageChannel{},
|
|
&message_gateway.MessageBinding{},
|
|
&message_gateway.MessagePairingCode{},
|
|
&admin.SystemConfig{},
|
|
&message_gateway.PushChannel{},
|
|
&message_gateway.PushEvent{},
|
|
&message_gateway.PushHistory{},
|
|
))
|
|
|
|
db.SetDB(testDB)
|
|
return testDB
|
|
}
|
|
|
|
type mockOAuthProvider struct {
|
|
name string
|
|
}
|
|
|
|
func (m *mockOAuthProvider) Name() string {
|
|
return m.name
|
|
}
|
|
|
|
func (m *mockOAuthProvider) GetAuthURL(state string) string {
|
|
return "https://auth.example.com/auth?state=" + state
|
|
}
|
|
|
|
func (m *mockOAuthProvider) ExchangeCode(ctx context.Context, code string) (*contracts.OAuthUserInfoDTO, error) {
|
|
return &contracts.OAuthUserInfoDTO{
|
|
ID: 1001,
|
|
Username: "mock_user",
|
|
Email: "mock@example.com",
|
|
Active: true,
|
|
}, nil
|
|
}
|
|
|
|
func TestAuthPlugin(t *testing.T) {
|
|
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))
|
|
|
|
p := auth.New()
|
|
assert.Equal(t, "auth", p.Name())
|
|
assert.Equal(t, "auth", p.Manifest().Name)
|
|
require.NoError(t, p.Apply(ctx))
|
|
|
|
// 1. Verify migrations registered
|
|
entry, ok := ctx.Migrations().Get("auth")
|
|
require.True(t, ok)
|
|
assert.Equal(t, "auth", entry.PluginID)
|
|
entries, err := fs.ReadDir(entry.FS, entry.Dir)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, entries)
|
|
|
|
// 2. Verify AuthService
|
|
authSvc, err := core.Inject[contracts.AuthService](ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, authSvc)
|
|
assert.NotNil(t, authSvc.RequireAuthMiddleware())
|
|
assert.NotNil(t, authSvc.RequireAdminMiddleware())
|
|
|
|
// 3. Verify AuthRegistry
|
|
authReg, err := core.Inject[contracts.AuthRegistry](ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, authReg)
|
|
|
|
mockProv := &mockOAuthProvider{name: "github"}
|
|
authReg.RegisterOAuthProvider("github", mockProv)
|
|
retrieved, ok := authReg.GetOAuthProvider("github")
|
|
require.True(t, ok)
|
|
assert.Equal(t, "github", retrieved.Name())
|
|
assert.Contains(t, authReg.ListOAuthProviders(), "github")
|
|
|
|
// 4. Verify Routes
|
|
routes := ctx.Router().Routes()
|
|
var hasSources, hasLogin, hasUserInfo bool
|
|
for _, r := range routes {
|
|
if r.Path == "/api/v1/oauth/sources" {
|
|
hasSources = true
|
|
}
|
|
if r.Path == "/api/v1/oauth/login" {
|
|
hasLogin = true
|
|
}
|
|
if r.Path == "/api/v1/user-info" {
|
|
hasUserInfo = true
|
|
}
|
|
}
|
|
assert.True(t, hasSources)
|
|
assert.True(t, hasLogin)
|
|
assert.True(t, hasUserInfo)
|
|
|
|
// 5. Verify Settings
|
|
schema, ok := ctx.Settings().Get("auth.session_age")
|
|
require.True(t, ok)
|
|
assert.Equal(t, 86400*7, schema.Default)
|
|
}
|
|
|
|
func TestUserPlugin(t *testing.T) {
|
|
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, auth.New().Apply(ctx))
|
|
|
|
p := user.New()
|
|
assert.Equal(t, "user", p.Name())
|
|
assert.Equal(t, "user", p.Manifest().Name)
|
|
require.NoError(t, p.Apply(ctx))
|
|
|
|
// 1. Verify migrations
|
|
entry, ok := ctx.Migrations().Get("user")
|
|
require.True(t, ok)
|
|
assert.Equal(t, "user", entry.PluginID)
|
|
|
|
// 2. Verify UserService
|
|
userSvc, err := core.Inject[contracts.UserService](ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, userSvc)
|
|
|
|
testCtx := context.Background()
|
|
|
|
// 3. Create user
|
|
created, err := userSvc.CreateUser(testCtx, contracts.CreateUserRequest{
|
|
Username: "bob",
|
|
Password: "SecurePassword123!",
|
|
Nickname: "Bob Builder",
|
|
Email: "bob@example.com",
|
|
IsAdmin: false,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, created)
|
|
assert.Equal(t, "bob", created.Username)
|
|
assert.Equal(t, "Bob Builder", created.Nickname)
|
|
assert.Equal(t, "bob@example.com", created.Email)
|
|
assert.False(t, created.IsAdmin)
|
|
|
|
// 4. Query user
|
|
byID, err := userSvc.GetUserByID(testCtx, created.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "bob", byID.Username)
|
|
|
|
byUsername, err := userSvc.GetUserByUsername(testCtx, "bob")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, created.ID, byUsername.ID)
|
|
|
|
byEmail, err := userSvc.GetUserByEmail(testCtx, "bob@example.com")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, created.ID, byEmail.ID)
|
|
|
|
// 5. Password verification and update
|
|
assert.True(t, userSvc.VerifyPassword(testCtx, created.ID, "SecurePassword123!"))
|
|
assert.False(t, userSvc.VerifyPassword(testCtx, created.ID, "WrongPass"))
|
|
|
|
require.NoError(t, userSvc.UpdatePassword(testCtx, created.ID, "SecurePassword123!", "NewSecurePassword456!"))
|
|
assert.True(t, userSvc.VerifyPassword(testCtx, created.ID, "NewSecurePassword456!"))
|
|
|
|
// 6. Update Profile
|
|
newBio := "I build things"
|
|
newPhone := "13800138000"
|
|
updated, err := userSvc.UpdateProfile(testCtx, created.ID, contracts.UpdateUserProfileRequest{
|
|
Bio: &newBio,
|
|
Phone: &newPhone,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, newBio, updated.Bio)
|
|
assert.Equal(t, newPhone, updated.Phone)
|
|
|
|
// 7. Update Last Login
|
|
require.NoError(t, userSvc.UpdateLastLogin(testCtx, created.ID, "127.0.0.1"))
|
|
|
|
// 8. Admin operations: SetUserActive, SetUserAdmin, ListUsers
|
|
require.NoError(t, userSvc.SetUserAdmin(testCtx, created.ID, true))
|
|
reloaded, err := userSvc.GetUserByID(testCtx, created.ID)
|
|
require.NoError(t, err)
|
|
assert.True(t, reloaded.IsAdmin)
|
|
|
|
require.NoError(t, userSvc.SetUserActive(testCtx, created.ID, false))
|
|
reloadedBanned, err := userSvc.GetUserByID(testCtx, created.ID)
|
|
require.NoError(t, err)
|
|
assert.False(t, reloadedBanned.IsActive)
|
|
|
|
list, total, err := userSvc.ListUsers(testCtx, 1, 10, "bob")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(1), total)
|
|
assert.Len(t, list, 1)
|
|
assert.Equal(t, "bob", list[0].Username)
|
|
|
|
// 9. Tasks
|
|
taskDef, ok := ctx.Tasks().Get("user:send_email_code")
|
|
require.True(t, ok)
|
|
assert.Equal(t, 3, taskDef.Retry)
|
|
|
|
// 10. Settings
|
|
sReg, ok := ctx.Settings().Get("user.registration_enabled")
|
|
require.True(t, ok)
|
|
assert.Equal(t, true, sReg.Default)
|
|
}
|
|
|
|
func TestMessageGatewayPlugin(t *testing.T) {
|
|
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))
|
|
|
|
p := message_gateway.New()
|
|
assert.Equal(t, "message_gateway", p.Name())
|
|
assert.Equal(t, "message_gateway", p.Manifest().Name)
|
|
require.NoError(t, p.Apply(ctx))
|
|
|
|
// 1. Migrations
|
|
entry, ok := ctx.Migrations().Get("message_gateway")
|
|
require.True(t, ok)
|
|
assert.Equal(t, "message_gateway", entry.PluginID)
|
|
|
|
// 2. Routes
|
|
routes := ctx.Router().Routes()
|
|
var hasChannels, hasBindings bool
|
|
for _, r := range routes {
|
|
if r.Path == "/api/v1/message-gateway/channels" {
|
|
hasChannels = true
|
|
}
|
|
if r.Path == "/api/v1/message-gateway/bindings" {
|
|
hasBindings = true
|
|
}
|
|
}
|
|
assert.True(t, hasChannels)
|
|
assert.True(t, hasBindings)
|
|
|
|
// 3. Tasks & Schedules
|
|
taskDef, ok := ctx.Tasks().Get("message_gateway:push_notification")
|
|
require.True(t, ok)
|
|
assert.Equal(t, 3, taskDef.Retry)
|
|
|
|
schedDef, ok := ctx.Schedules().Get("message_gateway:cleanup_pairing_codes")
|
|
require.True(t, ok)
|
|
assert.Equal(t, "*/10 * * * *", schedDef.Spec)
|
|
|
|
// 4. EventBus Trigger
|
|
var receivedEvent message_gateway.PushNotificationEvent
|
|
var eventFired bool
|
|
ctx.Events().On("notification:push", func(c context.Context, e message_gateway.PushNotificationEvent) error {
|
|
eventFired = true
|
|
receivedEvent = e
|
|
return nil
|
|
})
|
|
|
|
err := ctx.Events().Emit(context.Background(), "notification:push", message_gateway.PushNotificationEvent{
|
|
UserID: 99,
|
|
Channel: "telegram",
|
|
Title: "System Alert",
|
|
Content: "Disk 85% full",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.True(t, eventFired)
|
|
assert.Equal(t, uint64(99), receivedEvent.UserID)
|
|
assert.Equal(t, "telegram", receivedEvent.Channel)
|
|
assert.Equal(t, "System Alert", receivedEvent.Title)
|
|
|
|
// 5. Settings
|
|
schema, ok := ctx.Settings().Get("message_gateway.pairing_code_expiry_minutes")
|
|
require.True(t, ok)
|
|
assert.Equal(t, 15, schema.Default)
|
|
}
|
|
|
|
func TestRiskControlPlugin(t *testing.T) {
|
|
ctx := core.NewContext(context.Background())
|
|
ctx.Config().SetSource(core.NewMapSource(nil))
|
|
require.NoError(t, ctx.Config().Resolve())
|
|
p := risk_control.New()
|
|
assert.Equal(t, "risk_control", p.Name())
|
|
assert.Equal(t, "risk_control", p.Manifest().Name)
|
|
require.NoError(t, p.Apply(ctx))
|
|
|
|
// 1. Middleware registered on Router
|
|
mws := ctx.Router().Middlewares()
|
|
assert.NotEmpty(t, mws)
|
|
|
|
// 2. Settings
|
|
schema, ok := ctx.Settings().Get("risk_control.ip_rate_limit_per_minute")
|
|
require.True(t, ok)
|
|
assert.Equal(t, 60, schema.Default)
|
|
|
|
// 3. Disposal cleanup
|
|
require.NoError(t, ctx.Dispose())
|
|
}
|
|
|
|
func TestAdminPlugin(t *testing.T) {
|
|
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))
|
|
|
|
p := admin.New()
|
|
assert.Equal(t, "admin", p.Name())
|
|
assert.Equal(t, "admin", p.Manifest().Name)
|
|
require.NoError(t, p.Apply(ctx))
|
|
|
|
// 1. Admin Routes
|
|
routes := ctx.Router().Routes()
|
|
var hasStatus, hasDBOverview, hasUsers, hasTasks, hasConfigs bool
|
|
for _, r := range routes {
|
|
if r.Path == "/api/v1/admin/status" {
|
|
hasStatus = true
|
|
}
|
|
if r.Path == "/api/v1/admin/db-manage/overview" {
|
|
hasDBOverview = true
|
|
}
|
|
if r.Path == "/api/v1/admin/users" {
|
|
hasUsers = true
|
|
}
|
|
if r.Path == "/api/v1/admin/tasks/types" {
|
|
hasTasks = true
|
|
}
|
|
if r.Path == "/api/v1/admin/system-configs" {
|
|
hasConfigs = true
|
|
}
|
|
}
|
|
assert.True(t, hasStatus)
|
|
assert.True(t, hasDBOverview)
|
|
assert.True(t, hasUsers)
|
|
assert.True(t, hasTasks)
|
|
assert.True(t, hasConfigs)
|
|
|
|
// 2. Task
|
|
_, ok := ctx.Tasks().Get("logs:db_switch")
|
|
require.True(t, ok)
|
|
|
|
// 3. Settings
|
|
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) {
|
|
mr, err := miniredis.Run()
|
|
require.NoError(t, err)
|
|
defer mr.Close()
|
|
|
|
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
|
defer func() { _ = rdb.Close() }()
|
|
|
|
ctx := core.NewContext(context.Background())
|
|
ctx.Config().SetSource(core.NewMapSource(map[string]any{
|
|
"redis": map[string]any{
|
|
"enabled": true,
|
|
"addrs": []string{mr.Addr()},
|
|
},
|
|
}))
|
|
require.NoError(t, ctx.Config().Resolve())
|
|
testDB := setupTestDB(t)
|
|
|
|
// Apply Infra plugins
|
|
require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx))
|
|
require.NoError(t, cache.New(cache.WithRedis(rdb)).Apply(ctx))
|
|
require.NoError(t, logger.New().Apply(ctx))
|
|
require.NoError(t, storage.New().Apply(ctx))
|
|
|
|
// Apply Domain plugins
|
|
require.NoError(t, auth.New().Apply(ctx))
|
|
require.NoError(t, user.New().Apply(ctx))
|
|
require.NoError(t, message_gateway.New().Apply(ctx))
|
|
require.NoError(t, risk_control.New().Apply(ctx))
|
|
require.NoError(t, admin.New().Apply(ctx))
|
|
|
|
// Verify cross-plugin service injection via Using3
|
|
var resolved bool
|
|
err = core.Using3(ctx, func(authSvc contracts.AuthService, userSvc contracts.UserService, authReg contracts.AuthRegistry) {
|
|
resolved = true
|
|
assert.NotNil(t, authSvc)
|
|
assert.NotNil(t, userSvc)
|
|
assert.NotNil(t, authReg)
|
|
})
|
|
require.NoError(t, err)
|
|
assert.True(t, resolved)
|
|
|
|
// Verify all migration entries
|
|
allMigrations := ctx.Migrations().Entries()
|
|
assert.GreaterOrEqual(t, len(allMigrations), 3)
|
|
|
|
// Verify total routes registered
|
|
allRoutes := ctx.Router().Routes()
|
|
assert.GreaterOrEqual(t, len(allRoutes), 20)
|
|
|
|
// Verify total tasks registered
|
|
allTasks := ctx.Tasks().Tasks()
|
|
assert.GreaterOrEqual(t, len(allTasks), 4)
|
|
for _, task := range allTasks {
|
|
dto := task.ToDTO()
|
|
assert.NotEmpty(t, dto.Type, "task %s should have type", task.Pattern)
|
|
assert.NotEmpty(t, dto.AsynqTask, "task %s should have asynq_task", task.Pattern)
|
|
assert.NotEmpty(t, dto.Name, "task %s should have name", task.Pattern)
|
|
}
|
|
|
|
// Verify total schedules registered
|
|
allSchedules := ctx.Schedules().Schedules()
|
|
assert.GreaterOrEqual(t, len(allSchedules), 1)
|
|
|
|
// 每个调度指向的任务类型都必须已注册 Handler,否则触发时会投递到无人处理的
|
|
// 任务类型,预期的清理逻辑静默失效。
|
|
for _, sched := range allSchedules {
|
|
_, ok := ctx.Tasks().Get(sched.TaskType)
|
|
assert.Truef(t, ok, "schedule %q dispatches to task %q, which is never registered",
|
|
sched.Spec, sched.TaskType)
|
|
}
|
|
|
|
// Verify total settings schemas registered
|
|
allSettings := ctx.Settings().Schemas()
|
|
assert.GreaterOrEqual(t, len(allSettings), 7)
|
|
|
|
// Clean shutdown
|
|
require.NoError(t, ctx.Dispose())
|
|
}
|