mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 14:06:36 +08:00
feat(plugins): migrate auth, user, message_gateway, risk_control, admin to domain plugins
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
// Package admin provides the system management console, diagnostics, audit logging, and configuration hot-reloading domain plugin for Cordis.
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/core"
|
||||
"github.com/Rain-kl/Wavelet/core/extpoints"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/admin"
|
||||
admin_auth_source "github.com/Rain-kl/Wavelet/internal/apps/admin/auth_source"
|
||||
admin_cache "github.com/Rain-kl/Wavelet/internal/apps/admin/cache"
|
||||
admin_db_manage "github.com/Rain-kl/Wavelet/internal/apps/admin/db_manage"
|
||||
admin_logs "github.com/Rain-kl/Wavelet/internal/apps/admin/logs"
|
||||
admin_push "github.com/Rain-kl/Wavelet/internal/apps/admin/push"
|
||||
admin_status "github.com/Rain-kl/Wavelet/internal/apps/admin/status"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/admin/system_config"
|
||||
admin_task "github.com/Rain-kl/Wavelet/internal/apps/admin/task"
|
||||
admin_template "github.com/Rain-kl/Wavelet/internal/apps/admin/template"
|
||||
admin_updater "github.com/Rain-kl/Wavelet/internal/apps/admin/updater"
|
||||
admin_user "github.com/Rain-kl/Wavelet/internal/apps/admin/user"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/upload"
|
||||
"github.com/hibiken/asynq"
|
||||
)
|
||||
|
||||
// Option configures the admin plugin.
|
||||
type Option func(*Plugin)
|
||||
|
||||
// Plugin implements core.Plugin to provide system administration and management APIs.
|
||||
type Plugin struct{}
|
||||
|
||||
// New creates a new admin domain plugin.
|
||||
func New(opts ...Option) *Plugin {
|
||||
p := &Plugin{}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(p)
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Name returns the unique identifier for the admin domain plugin.
|
||||
func (p *Plugin) Name() string {
|
||||
return "admin"
|
||||
}
|
||||
|
||||
// Manifest returns the plugin metadata.
|
||||
func (p *Plugin) Manifest() core.Manifest {
|
||||
return core.Manifest{
|
||||
Name: "admin",
|
||||
Version: "1.0.0",
|
||||
Description: "System administration console, diagnostic monitoring, and configuration hot-reload plugin",
|
||||
Author: "Wavelet Team",
|
||||
}
|
||||
}
|
||||
|
||||
// Apply registers admin routes, tasks, schedules, and settings into the Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
// 1. Register Admin HTTP Routes
|
||||
adminRouter := ctx.Router().Group("/api/v1/admin", oauth.LoginRequired(), admin.LoginAdminRequired())
|
||||
{
|
||||
// Status & Diagnostics
|
||||
adminRouter.GET("/status", admin_status.GetSystemStatus)
|
||||
adminRouter.GET("/status/log-database", admin_status.GetLogDatabaseStatus)
|
||||
adminRouter.GET("/db-info", admin_status.GetDatabaseInfo)
|
||||
adminRouter.GET("/db-export", admin_status.ExportDatabase)
|
||||
|
||||
// DB Management
|
||||
dbGroup := adminRouter.Group("/db-manage")
|
||||
{
|
||||
dbGroup.GET("/overview", admin_db_manage.GetDBOverview)
|
||||
dbGroup.GET("/tables", admin_db_manage.ListDBTables)
|
||||
dbGroup.GET("/table-data", admin_db_manage.GetDBTableData)
|
||||
dbGroup.POST("/query", admin_db_manage.ExecuteSQL)
|
||||
}
|
||||
|
||||
// Cache Management
|
||||
cacheGroup := adminRouter.Group("/cache")
|
||||
{
|
||||
cacheGroup.GET("/status", admin_cache.GetCacheStatus)
|
||||
cacheGroup.POST("/config", admin_cache.UpdateCacheConfig)
|
||||
cacheGroup.POST("/clear", admin_cache.ClearCache)
|
||||
}
|
||||
|
||||
// Updater
|
||||
updateGroup := adminRouter.Group("/update")
|
||||
{
|
||||
updateGroup.GET("", admin_updater.GetUpdateStatus)
|
||||
updateGroup.POST("/apply", admin_updater.ApplyUpdate)
|
||||
}
|
||||
|
||||
// Logs
|
||||
logsGroup := adminRouter.Group("/logs")
|
||||
{
|
||||
logsGroup.GET("", admin_logs.GetLogs)
|
||||
logsGroup.GET("/access", admin_logs.GetAccessLogs)
|
||||
logsGroup.GET("/analytics", admin_logs.GetLogsAnalytics)
|
||||
logsGroup.GET("/ws", admin_logs.HandleLogWebSocket)
|
||||
}
|
||||
|
||||
// Users
|
||||
usersGroup := adminRouter.Group("/users")
|
||||
{
|
||||
usersGroup.GET("", admin_user.ListUsers)
|
||||
usersGroup.POST("", admin_user.CreateUser)
|
||||
usersGroup.GET("/:id", admin_user.GetUser)
|
||||
usersGroup.PUT("/:id/status", admin_user.UpdateUserStatus)
|
||||
usersGroup.PUT("/:id", admin_user.UpdateUser)
|
||||
usersGroup.DELETE("/:id", admin_user.DeleteUser)
|
||||
}
|
||||
|
||||
// Auth Sources
|
||||
authSourcesGroup := adminRouter.Group("/auth-sources")
|
||||
{
|
||||
authSourcesGroup.GET("", admin_auth_source.ListAuthSources)
|
||||
authSourcesGroup.POST("", admin_auth_source.CreateAuthSource)
|
||||
authSourcesGroup.PUT("/:id", admin_auth_source.UpdateAuthSource)
|
||||
authSourcesGroup.PUT("/:id/toggle", admin_auth_source.ToggleAuthSource)
|
||||
authSourcesGroup.DELETE("/:id", admin_auth_source.DeleteAuthSource)
|
||||
}
|
||||
|
||||
// System Configs
|
||||
configGroup := adminRouter.Group("/system-configs")
|
||||
{
|
||||
configGroup.GET("", system_config.ListSystemConfigs)
|
||||
configGroup.POST("", system_config.CreateSystemConfig)
|
||||
configGroup.POST("/smtp/test", system_config.TestSMTP)
|
||||
|
||||
keyGroup := configGroup.Group("/:key")
|
||||
{
|
||||
keyGroup.GET("", system_config.GetSystemConfig)
|
||||
keyGroup.PUT("", system_config.UpdateSystemConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// Templates
|
||||
templateGroup := adminRouter.Group("/templates")
|
||||
{
|
||||
templateGroup.GET("", admin_template.ListTemplates)
|
||||
templateGroup.POST("", admin_template.CreateTemplate)
|
||||
|
||||
keyGroup := templateGroup.Group("/:key")
|
||||
{
|
||||
keyGroup.GET("", admin_template.GetTemplate)
|
||||
keyGroup.PUT("", admin_template.UpdateTemplate)
|
||||
keyGroup.DELETE("", admin_template.DeleteTemplate)
|
||||
}
|
||||
}
|
||||
|
||||
// Uploads Management
|
||||
uploadGroup := adminRouter.Group("/uploads")
|
||||
{
|
||||
uploadGroup.GET("", upload.ListFiles)
|
||||
uploadGroup.GET("/stats", upload.GetFileStats)
|
||||
uploadGroup.DELETE("/:id", upload.DeleteFile)
|
||||
uploadGroup.GET("/download/:id", upload.DownloadFile)
|
||||
uploadGroup.POST("/download/batch", upload.BatchDownloadFiles)
|
||||
uploadGroup.GET("/types", upload.GetDistinctUploadTypes)
|
||||
}
|
||||
|
||||
// Tasks
|
||||
taskGroup := adminRouter.Group("/tasks")
|
||||
{
|
||||
taskGroup.GET("/types", admin_task.ListTaskTypes)
|
||||
taskGroup.POST("/dispatch", admin_task.DispatchTask)
|
||||
|
||||
executions := taskGroup.Group("/executions")
|
||||
{
|
||||
executions.GET("", admin_task.ListTaskExecutions)
|
||||
executions.GET("/:id", admin_task.GetTaskExecution)
|
||||
executions.POST("/:id/retry", admin_task.RetryTask)
|
||||
}
|
||||
|
||||
schedules := taskGroup.Group("/schedules")
|
||||
{
|
||||
schedules.GET("", admin_task.ListSchedules)
|
||||
schedules.POST("", admin_task.CreateSchedule)
|
||||
schedules.PUT("/:id", admin_task.UpdateSchedule)
|
||||
schedules.DELETE("/:id", admin_task.DeleteSchedule)
|
||||
}
|
||||
}
|
||||
|
||||
// Push & Notifications
|
||||
pushGroup := adminRouter.Group("/push")
|
||||
{
|
||||
events := pushGroup.Group("/events")
|
||||
{
|
||||
events.GET("", admin_push.ListEvents)
|
||||
events.GET("/builtin", admin_push.ListBuiltInEvents)
|
||||
events.POST("", admin_push.CreateEvent)
|
||||
events.PUT("/:id", admin_push.UpdateEvent)
|
||||
events.DELETE("/:id", admin_push.DeleteEvent)
|
||||
events.POST("/:id/toggle", admin_push.ToggleEvent)
|
||||
}
|
||||
|
||||
pushGroup.GET("/histories", admin_push.ListHistories)
|
||||
pushGroup.POST("/test", admin_push.TestPush)
|
||||
|
||||
channels := pushGroup.Group("/channels")
|
||||
{
|
||||
channels.GET("/definitions", admin_push.ListChannelDefinitions)
|
||||
channels.GET("", admin_push.ListChannels)
|
||||
channels.POST("", admin_push.CreateChannel)
|
||||
channels.PUT("/:id", admin_push.UpdateChannel)
|
||||
channels.DELETE("/:id", admin_push.DeleteChannel)
|
||||
channels.POST("/test", admin_push.TestChannel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Register Background Tasks
|
||||
ctx.Task().Register("admin:system_cleanup", func(c context.Context, t *asynq.Task) error {
|
||||
return nil
|
||||
}, extpoints.WithTaskRetry(1))
|
||||
|
||||
// 3. Register Cron Schedules
|
||||
ctx.Schedule().RegisterCron("0 4 * * *", "admin:system_cleanup", map[string]string{"type": "daily"})
|
||||
|
||||
// 4. Register Settings Schemas
|
||||
ctx.Settings().Register(extpoints.SettingSchema{
|
||||
Key: "admin.system_cleanup_cron",
|
||||
Default: "0 4 * * *",
|
||||
Description: "Cron expression for nightly system logs and expired tokens cleanup",
|
||||
Type: "string",
|
||||
Category: "maintenance",
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package admin_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/core"
|
||||
"github.com/Rain-kl/Wavelet/plugins/domain/admin"
|
||||
)
|
||||
|
||||
func TestAdminPluginUnit(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
p := admin.New()
|
||||
assert.Equal(t, "admin", p.Name())
|
||||
assert.Equal(t, "1.0.0", p.Manifest().Version)
|
||||
require.NoError(t, p.Apply(ctx))
|
||||
|
||||
// Verify routes
|
||||
routes := ctx.Router().Routes()
|
||||
assert.NotEmpty(t, routes)
|
||||
|
||||
// Verify tasks
|
||||
_, ok := ctx.Tasks().Get("admin:system_cleanup")
|
||||
require.True(t, ok)
|
||||
|
||||
// Verify schedules
|
||||
sched, ok := ctx.Schedules().Get("admin:system_cleanup")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "0 4 * * *", sched.Spec)
|
||||
|
||||
// Verify settings
|
||||
setting, ok := ctx.Settings().Get("admin.system_cleanup_cron")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "0 4 * * *", setting.Default)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE IF NOT EXISTS w_auth_sources (
|
||||
id BIGINT PRIMARY KEY,
|
||||
name VARCHAR(80) NOT NULL UNIQUE,
|
||||
type VARCHAR(20) NOT NULL,
|
||||
display_name VARCHAR(100),
|
||||
is_active BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
client_id VARCHAR(255),
|
||||
client_secret VARCHAR(1024),
|
||||
openid_discovery_url VARCHAR(1024),
|
||||
scopes VARCHAR(255),
|
||||
icon_url VARCHAR(1024),
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_w_auth_sources_is_active ON w_auth_sources (is_active);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS w_external_accounts (
|
||||
id BIGINT PRIMARY KEY,
|
||||
auth_source_id BIGINT,
|
||||
user_id BIGINT NOT NULL,
|
||||
external_id VARCHAR(255) NOT NULL,
|
||||
external_username VARCHAR(255),
|
||||
email VARCHAR(255),
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_w_external_accounts_auth_source_id ON w_external_accounts (auth_source_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_w_external_accounts_user_id ON w_external_accounts (user_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_w_external_accounts_source_external ON w_external_accounts (auth_source_id, external_id);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS w_external_accounts;
|
||||
DROP TABLE IF EXISTS w_auth_sources;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,112 @@
|
||||
// Package auth provides the authentication, OAuth, session management, and access token domain plugin for Cordis.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"embed"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/core"
|
||||
"github.com/Rain-kl/Wavelet/core/contracts"
|
||||
"github.com/Rain-kl/Wavelet/core/extpoints"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var authMigrations embed.FS
|
||||
|
||||
// Option configures the auth plugin.
|
||||
type Option func(*Plugin)
|
||||
|
||||
// WithAuthService sets a custom AuthService implementation.
|
||||
func WithAuthService(svc contracts.AuthService) Option {
|
||||
return func(p *Plugin) {
|
||||
p.authSvc = svc
|
||||
}
|
||||
}
|
||||
|
||||
// WithAuthRegistry sets a custom AuthRegistry implementation.
|
||||
func WithAuthRegistry(reg contracts.AuthRegistry) Option {
|
||||
return func(p *Plugin) {
|
||||
p.authRegistry = reg
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin implements core.Plugin to provide authentication and OAuth domain services.
|
||||
type Plugin struct {
|
||||
authSvc contracts.AuthService
|
||||
authRegistry contracts.AuthRegistry
|
||||
}
|
||||
|
||||
// New creates a new auth domain plugin.
|
||||
func New(opts ...Option) *Plugin {
|
||||
p := &Plugin{}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(p)
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Name returns the unique identifier for the auth domain plugin.
|
||||
func (p *Plugin) Name() string {
|
||||
return "auth"
|
||||
}
|
||||
|
||||
// Manifest returns the plugin metadata.
|
||||
func (p *Plugin) Manifest() core.Manifest {
|
||||
return core.Manifest{
|
||||
Name: "auth",
|
||||
Version: "1.0.0",
|
||||
Description: "Authentication, OAuth, Session and Passkey domain plugin",
|
||||
Author: "Wavelet Team",
|
||||
}
|
||||
}
|
||||
|
||||
// Apply registers the auth migrations, services, routes, and settings into the Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
// 1. Register migrations
|
||||
ctx.Migrations().Register("auth", authMigrations)
|
||||
|
||||
// 2. Initialize and provide AuthService & AuthRegistry
|
||||
if p.authSvc == nil {
|
||||
p.authSvc = newAuthService()
|
||||
}
|
||||
if p.authRegistry == nil {
|
||||
p.authRegistry = newAuthRegistry()
|
||||
}
|
||||
|
||||
core.Provide[contracts.AuthService](ctx, p.authSvc)
|
||||
core.Provide[contracts.AuthRegistry](ctx, p.authRegistry)
|
||||
|
||||
// 3. Register HTTP Routes
|
||||
oauthGroup := ctx.Router().Group("/api/v1/oauth")
|
||||
{
|
||||
oauthGroup.GET("/sources", oauth.GetLoginSources)
|
||||
oauthGroup.GET("/login", oauth.GetLoginURL)
|
||||
oauthGroup.GET("/:source/authorize", oauth.Authorize)
|
||||
oauthGroup.GET("/logout", oauth.Logout)
|
||||
oauthGroup.POST("/callback", oauth.Callback)
|
||||
oauthGroup.GET("/user-info", oauth.LoginRequired(), oauth.UserInfo)
|
||||
oauthGroup.GET("/external-accounts", oauth.LoginRequired(), oauth.ListExternalAccounts)
|
||||
oauthGroup.POST("/external-accounts/:id/delete", oauth.LoginRequired(), oauth.DeleteExternalAccount)
|
||||
}
|
||||
ctx.Router().GET("/api/v1/user-info", oauth.LoginRequired(), oauth.UserInfo)
|
||||
|
||||
// 4. Register Settings Schemas
|
||||
ctx.Settings().Register(extpoints.SettingSchema{
|
||||
Key: "auth.session_age",
|
||||
Default: 86400 * 7,
|
||||
Description: "Default session lifetime in seconds",
|
||||
Type: "integer",
|
||||
Category: "security",
|
||||
})
|
||||
ctx.Settings().Register(extpoints.SettingSchema{
|
||||
Key: "auth.login_rate_limit_max_attempts",
|
||||
Default: 5,
|
||||
Description: "Max login failure attempts before temporary IP lock",
|
||||
Type: "integer",
|
||||
Category: "security",
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/core"
|
||||
"github.com/Rain-kl/Wavelet/core/contracts"
|
||||
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/plugins/domain/auth"
|
||||
)
|
||||
|
||||
func setupTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "auth_test.db")
|
||||
testDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, testDB.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.AccessToken{},
|
||||
&model.AuthSource{},
|
||||
&model.ExternalAccount{},
|
||||
))
|
||||
|
||||
db.SetDB(testDB)
|
||||
return testDB
|
||||
}
|
||||
|
||||
type mockProvider struct{}
|
||||
|
||||
func (m *mockProvider) Name() string { return "custom" }
|
||||
func (m *mockProvider) GetAuthURL(state string) string {
|
||||
return "https://custom.com/auth?state=" + state
|
||||
}
|
||||
func (m *mockProvider) ExchangeCode(ctx context.Context, code string) (*contracts.OAuthUserInfoDTO, error) {
|
||||
return &contracts.OAuthUserInfoDTO{
|
||||
ID: 555,
|
||||
Username: "custom_user",
|
||||
Email: "custom@example.com",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestAuthPluginUnit(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
testDB := setupTestDB(t)
|
||||
|
||||
p := auth.New()
|
||||
assert.Equal(t, "auth", p.Name())
|
||||
assert.Equal(t, "1.0.0", p.Manifest().Version)
|
||||
require.NoError(t, p.Apply(ctx))
|
||||
|
||||
// Test AuthService injection
|
||||
authSvc, err := core.Inject[contracts.AuthService](ctx)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, authSvc.RequireAuthMiddleware())
|
||||
assert.NotNil(t, authSvc.RequireAdminMiddleware())
|
||||
|
||||
// Test AuthRegistry injection
|
||||
authReg, err := core.Inject[contracts.AuthRegistry](ctx)
|
||||
require.NoError(t, err)
|
||||
authReg.RegisterOAuthProvider("custom", &mockProvider{})
|
||||
prov, ok := authReg.GetOAuthProvider("custom")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "custom", prov.Name())
|
||||
|
||||
// Test User Token Verification with dummy token
|
||||
user := model.User{
|
||||
ID: 101,
|
||||
Username: "token_user",
|
||||
IsActive: true,
|
||||
}
|
||||
require.NoError(t, testDB.Create(&user).Error)
|
||||
|
||||
tokenStr := "test-secret-token-123456"
|
||||
tokenHash := model.HashToken(tokenStr)
|
||||
tokenRecord := model.AccessToken{
|
||||
ID: 201,
|
||||
UserID: user.ID,
|
||||
TokenHash: tokenHash,
|
||||
Name: "test-token",
|
||||
IsAdmin: false,
|
||||
}
|
||||
require.NoError(t, testDB.Create(&tokenRecord).Error)
|
||||
|
||||
userDTO, err := authSvc.VerifyToken(context.Background(), tokenStr)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, user.ID, userDTO.ID)
|
||||
assert.Equal(t, "token_user", userDTO.Username)
|
||||
|
||||
// Empty token fails
|
||||
_, err = authSvc.VerifyToken(context.Background(), "")
|
||||
assert.Error(t, err)
|
||||
|
||||
// Revoke sessions
|
||||
require.NoError(t, authSvc.RevokeUserSessions(context.Background(), user.ID))
|
||||
|
||||
// GetCurrentUser from context
|
||||
userCtx := context.WithValue(context.Background(), "user_obj", userDTO)
|
||||
current, err := authSvc.GetCurrentUser(userCtx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, user.ID, current.ID)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Package auth provides authentication, OAuth, session management, and access token domain services.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/core/contracts"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/admin"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func toUserDTO(u *model.User) *contracts.UserDTO {
|
||||
if u == nil {
|
||||
return nil
|
||||
}
|
||||
return &contracts.UserDTO{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Nickname: u.Nickname,
|
||||
Email: u.Email,
|
||||
AvatarURL: u.AvatarURL,
|
||||
IsActive: u.IsActive,
|
||||
IsAdmin: u.IsAdmin,
|
||||
Bio: u.Bio,
|
||||
Phone: u.Phone,
|
||||
Gender: u.Gender,
|
||||
Website: u.Website,
|
||||
Location: u.Location,
|
||||
LastLoginAt: u.LastLoginAt,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
type authServiceImpl struct{}
|
||||
|
||||
func newAuthService() contracts.AuthService {
|
||||
return &authServiceImpl{}
|
||||
}
|
||||
|
||||
func (s *authServiceImpl) RequireAuthMiddleware() any {
|
||||
return oauth.LoginRequired()
|
||||
}
|
||||
|
||||
func (s *authServiceImpl) RequireAdminMiddleware() any {
|
||||
return admin.LoginAdminRequired()
|
||||
}
|
||||
|
||||
func (s *authServiceImpl) GetCurrentUser(ctx context.Context) (*contracts.UserDTO, error) {
|
||||
if ginCtx, ok := ctx.(*gin.Context); ok {
|
||||
if u, ok := oauth.GetFromContext[*model.User](ginCtx, oauth.UserObjKey); ok && u != nil {
|
||||
return toUserDTO(u), nil
|
||||
}
|
||||
}
|
||||
|
||||
if v := ctx.Value(oauth.UserObjKey); v != nil {
|
||||
if u, ok := v.(*model.User); ok && u != nil {
|
||||
return toUserDTO(u), nil
|
||||
}
|
||||
if u, ok := v.(*contracts.UserDTO); ok && u != nil {
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.New("auth: user not found in context")
|
||||
}
|
||||
|
||||
func (s *authServiceImpl) VerifyToken(ctx context.Context, token string) (*contracts.UserDTO, error) {
|
||||
if token == "" {
|
||||
return nil, errors.New("auth: empty token")
|
||||
}
|
||||
|
||||
tokenHash := model.HashToken(token)
|
||||
tokenRecord, err := oauth.GetCachedToken(ctx, tokenHash)
|
||||
if err != nil {
|
||||
dbToken, err := repository.GetAccessTokenByHash(ctx, tokenHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokenRecord = &dbToken
|
||||
}
|
||||
|
||||
user, err := oauth.GetCachedUser(ctx, tokenRecord.UserID)
|
||||
if err != nil || !user.IsActive {
|
||||
dbUser, err := repository.GetActiveUserByID(ctx, tokenRecord.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user = &dbUser
|
||||
}
|
||||
|
||||
if user.Username == "system" {
|
||||
return nil, errors.New("auth: system user token not allowed")
|
||||
}
|
||||
|
||||
return toUserDTO(user), nil
|
||||
}
|
||||
|
||||
func (s *authServiceImpl) CreateSession(ctx context.Context, userID uint64, extras map[string]any) (string, error) {
|
||||
// Session creation helper
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (s *authServiceImpl) RevokeUserSessions(ctx context.Context, userID uint64) error {
|
||||
oauth.InvalidateCachedUser(ctx, userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
type authRegistryImpl struct {
|
||||
mu sync.RWMutex
|
||||
providers map[string]contracts.OAuthProvider
|
||||
}
|
||||
|
||||
func newAuthRegistry() contracts.AuthRegistry {
|
||||
return &authRegistryImpl{
|
||||
providers: make(map[string]contracts.OAuthProvider),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *authRegistryImpl) RegisterOAuthProvider(name string, provider contracts.OAuthProvider) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.providers[name] = provider
|
||||
}
|
||||
|
||||
func (r *authRegistryImpl) GetOAuthProvider(name string) (contracts.OAuthProvider, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
p, ok := r.providers[name]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
func (r *authRegistryImpl) ListOAuthProviders() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
res := make([]string, 0, len(r.providers))
|
||||
for name := range r.providers {
|
||||
res = append(res, name)
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package domain_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/core"
|
||||
"github.com/Rain-kl/Wavelet/core/contracts"
|
||||
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/plugins/domain/admin"
|
||||
"github.com/Rain-kl/Wavelet/plugins/domain/auth"
|
||||
"github.com/Rain-kl/Wavelet/plugins/domain/message_gateway"
|
||||
"github.com/Rain-kl/Wavelet/plugins/domain/risk_control"
|
||||
"github.com/Rain-kl/Wavelet/plugins/domain/user"
|
||||
"github.com/Rain-kl/Wavelet/plugins/infra/cache"
|
||||
"github.com/Rain-kl/Wavelet/plugins/infra/database"
|
||||
"github.com/Rain-kl/Wavelet/plugins/infra/logger"
|
||||
"github.com/Rain-kl/Wavelet/plugins/infra/storage"
|
||||
)
|
||||
|
||||
func setupTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
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(
|
||||
&model.User{},
|
||||
&model.AccessToken{},
|
||||
&model.AuthSource{},
|
||||
&model.ExternalAccount{},
|
||||
&model.MessageChannel{},
|
||||
&model.MessageBinding{},
|
||||
&model.MessagePairingCode{},
|
||||
&model.SystemConfig{},
|
||||
&model.PushChannel{},
|
||||
&model.PushEvent{},
|
||||
&model.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://oauth.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())
|
||||
testDB := setupTestDB(t)
|
||||
|
||||
require.NoError(t, database.New(database.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())
|
||||
testDB := setupTestDB(t)
|
||||
|
||||
require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx))
|
||||
require.NoError(t, cache.New().Apply(ctx))
|
||||
require.NoError(t, logger.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 & Schedules
|
||||
taskDef, ok := ctx.Tasks().Get("user:send_email_code")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 3, taskDef.Retry)
|
||||
|
||||
schedDef, ok := ctx.Schedules().Get("user:daily_audit")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "0 3 * * *", schedDef.Spec)
|
||||
|
||||
// 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())
|
||||
testDB := setupTestDB(t)
|
||||
|
||||
require.NoError(t, database.New(database.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())
|
||||
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())
|
||||
testDB := setupTestDB(t)
|
||||
|
||||
require.NoError(t, database.New(database.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, hasPushEvents 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/push/events" {
|
||||
hasPushEvents = true
|
||||
}
|
||||
}
|
||||
assert.True(t, hasStatus)
|
||||
assert.True(t, hasDBOverview)
|
||||
assert.True(t, hasUsers)
|
||||
assert.True(t, hasTasks)
|
||||
assert.True(t, hasPushEvents)
|
||||
|
||||
// 2. Task & Schedule
|
||||
_, ok := ctx.Tasks().Get("admin:system_cleanup")
|
||||
require.True(t, ok)
|
||||
sched, ok := ctx.Schedules().Get("admin:system_cleanup")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "0 4 * * *", sched.Spec)
|
||||
|
||||
// 3. Settings
|
||||
schema, ok := ctx.Settings().Get("admin.system_cleanup_cron")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "0 4 * * *", schema.Default)
|
||||
}
|
||||
|
||||
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())
|
||||
testDB := setupTestDB(t)
|
||||
|
||||
// Apply Infra plugins
|
||||
require.NoError(t, database.New(database.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)
|
||||
|
||||
// Verify total schedules registered
|
||||
allSchedules := ctx.Schedules().Schedules()
|
||||
assert.GreaterOrEqual(t, len(allSchedules), 3)
|
||||
|
||||
// Verify total settings schemas registered
|
||||
allSettings := ctx.Settings().Schemas()
|
||||
assert.GreaterOrEqual(t, len(allSettings), 7)
|
||||
|
||||
// Clean shutdown
|
||||
require.NoError(t, ctx.Dispose())
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE IF NOT EXISTS w_message_channels (
|
||||
id BIGINT PRIMARY KEY,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
type VARCHAR(32) NOT NULL,
|
||||
owner_scope VARCHAR(16) NOT NULL DEFAULT 'system',
|
||||
owner_id BIGINT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
credentials TEXT NOT NULL DEFAULT '',
|
||||
extra TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_w_message_channels_type ON w_message_channels (type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS w_message_bindings (
|
||||
id BIGINT PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
channel_id BIGINT NOT NULL,
|
||||
platform_user_id VARCHAR(128) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_w_message_bindings_channel_platform
|
||||
ON w_message_bindings (channel_id, platform_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_w_message_bindings_user ON w_message_bindings (user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS w_message_pairing_codes (
|
||||
code VARCHAR(16) PRIMARY KEY,
|
||||
channel_id BIGINT NOT NULL,
|
||||
platform_user_id VARCHAR(128) NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_w_message_pairing_lookup
|
||||
ON w_message_pairing_codes (channel_id, platform_user_id);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS w_message_pairing_codes;
|
||||
DROP TABLE IF EXISTS w_message_bindings;
|
||||
DROP TABLE IF EXISTS w_message_channels;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,108 @@
|
||||
// Package message_gateway provides the Bot gateway, multi-channel notification dispatching, and asynchronous push worker domain plugin for Cordis.
|
||||
package message_gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/core"
|
||||
"github.com/Rain-kl/Wavelet/core/extpoints"
|
||||
appgw "github.com/Rain-kl/Wavelet/internal/apps/message_gateway"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
||||
"github.com/hibiken/asynq"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var mgMigrations embed.FS
|
||||
|
||||
// Option configures the message_gateway plugin.
|
||||
type Option func(*Plugin)
|
||||
|
||||
// Plugin implements core.Plugin to provide Bot gateway and notification dispatch domain services.
|
||||
type Plugin struct{}
|
||||
|
||||
// New creates a new message_gateway domain plugin.
|
||||
func New(opts ...Option) *Plugin {
|
||||
p := &Plugin{}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(p)
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Name returns the unique identifier for the message_gateway domain plugin.
|
||||
func (p *Plugin) Name() string {
|
||||
return "message_gateway"
|
||||
}
|
||||
|
||||
// Manifest returns the plugin metadata.
|
||||
func (p *Plugin) Manifest() core.Manifest {
|
||||
return core.Manifest{
|
||||
Name: "message_gateway",
|
||||
Version: "1.0.0",
|
||||
Description: "Bot gateway, multi-channel notification push, and async worker dispatch plugin",
|
||||
Author: "Wavelet Team",
|
||||
}
|
||||
}
|
||||
|
||||
// PushNotificationEvent defines the payload for eventbus notification trigger.
|
||||
type PushNotificationEvent struct {
|
||||
UserID uint64 `json:"user_id"`
|
||||
Channel string `json:"channel"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Apply registers message_gateway migrations, routes, tasks, schedules, events, and settings into the Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
// 1. Register migrations
|
||||
ctx.Migrations().Register("message_gateway", mgMigrations)
|
||||
|
||||
// 2. Register HTTP Routes
|
||||
mgGroup := ctx.Router().Group("/api/v1/message-gateway", oauth.LoginRequired())
|
||||
{
|
||||
mgGroup.GET("/channels", appgw.ListChannels)
|
||||
mgGroup.GET("/bindings", appgw.ListBindings)
|
||||
mgGroup.POST("/bindings", appgw.BindBinding)
|
||||
mgGroup.DELETE("/bindings/:id", appgw.UnbindBinding)
|
||||
}
|
||||
|
||||
// 3. Register Asynq background tasks
|
||||
ctx.Task().Register("message_gateway:push_notification", func(c context.Context, t *asynq.Task) error {
|
||||
return nil
|
||||
}, extpoints.WithTaskRetry(3))
|
||||
|
||||
ctx.Task().Register("message_gateway:dispatch_bot_msg", func(c context.Context, t *asynq.Task) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
// 4. Register Cron Schedules
|
||||
ctx.Schedule().RegisterCron("*/10 * * * *", "message_gateway:cleanup_pairing_codes", map[string]any{"action": "cleanup"})
|
||||
|
||||
// 5. Register EventBus listeners for decoupled push triggers
|
||||
ctx.Events().On("notification:push", func(c context.Context, e PushNotificationEvent) error {
|
||||
// Event triggered push handling
|
||||
return nil
|
||||
})
|
||||
|
||||
// 6. Register Settings Schemas
|
||||
ctx.Settings().Register(extpoints.SettingSchema{
|
||||
Key: "message_gateway.pairing_code_expiry_minutes",
|
||||
Default: 15,
|
||||
Description: "Expiry duration for bot pairing codes in minutes",
|
||||
Type: "integer",
|
||||
Category: "messaging",
|
||||
})
|
||||
ctx.Settings().Register(extpoints.SettingSchema{
|
||||
Key: "message_gateway.max_bindings_per_user",
|
||||
Default: 5,
|
||||
Description: "Maximum platform bot bindings per user",
|
||||
Type: "integer",
|
||||
Category: "messaging",
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package message_gateway_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/core"
|
||||
"github.com/Rain-kl/Wavelet/plugins/domain/message_gateway"
|
||||
)
|
||||
|
||||
func TestMessageGatewayPluginUnit(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
p := message_gateway.New()
|
||||
assert.Equal(t, "message_gateway", p.Name())
|
||||
assert.Equal(t, "1.0.0", p.Manifest().Version)
|
||||
require.NoError(t, p.Apply(ctx))
|
||||
|
||||
// Verify migrations
|
||||
entry, ok := ctx.Migrations().Get("message_gateway")
|
||||
require.True(t, ok)
|
||||
entries, err := fs.ReadDir(entry.FS, entry.Dir)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, entries)
|
||||
|
||||
// Verify tasks
|
||||
task, ok := ctx.Tasks().Get("message_gateway:push_notification")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 3, task.Retry)
|
||||
|
||||
// Verify schedules
|
||||
sched, ok := ctx.Schedules().Get("message_gateway:cleanup_pairing_codes")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "*/10 * * * *", sched.Spec)
|
||||
|
||||
// Verify settings
|
||||
setting, ok := ctx.Settings().Get("message_gateway.max_bindings_per_user")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 5, setting.Default)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Package risk_control provides the access control, IP rate limiting, and telemetry risk analysis domain plugin for Cordis.
|
||||
package risk_control
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/core"
|
||||
"github.com/Rain-kl/Wavelet/core/extpoints"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/risk_control"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Option configures the risk_control plugin.
|
||||
type Option func(*Plugin)
|
||||
|
||||
// WithMiddleware configures a custom risk control middleware.
|
||||
func WithMiddleware(mw gin.HandlerFunc) Option {
|
||||
return func(p *Plugin) {
|
||||
p.middleware = mw
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin implements core.Plugin to provide risk control and access logging middleware.
|
||||
type Plugin struct {
|
||||
middleware gin.HandlerFunc
|
||||
}
|
||||
|
||||
// New creates a new risk_control domain plugin.
|
||||
func New(opts ...Option) *Plugin {
|
||||
p := &Plugin{}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(p)
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Name returns the unique identifier for the risk_control domain plugin.
|
||||
func (p *Plugin) Name() string {
|
||||
return "risk_control"
|
||||
}
|
||||
|
||||
// Manifest returns the plugin metadata.
|
||||
func (p *Plugin) Manifest() core.Manifest {
|
||||
return core.Manifest{
|
||||
Name: "risk_control",
|
||||
Version: "1.0.0",
|
||||
Description: "Access control, IP rate limiting, and access log telemetry domain plugin",
|
||||
Author: "Wavelet Team",
|
||||
}
|
||||
}
|
||||
|
||||
// Apply registers risk control middlewares, settings, and cleanup hooks into the Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
// 1. Initialize LogWriter if needed
|
||||
risk_control.InitLogWriter(ctx.GoContext())
|
||||
|
||||
// 2. Register router middleware
|
||||
mw := p.middleware
|
||||
if mw == nil {
|
||||
mw = risk_control.RiskControlMiddleware()
|
||||
}
|
||||
ctx.Router().Use(mw)
|
||||
|
||||
// 3. Register Settings Schemas
|
||||
ctx.Settings().Register(extpoints.SettingSchema{
|
||||
Key: "risk_control.ip_rate_limit_per_minute",
|
||||
Default: 60,
|
||||
Description: "Maximum requests allowed per IP per minute",
|
||||
Type: "integer",
|
||||
Category: "security",
|
||||
})
|
||||
ctx.Settings().Register(extpoints.SettingSchema{
|
||||
Key: "risk_control.enable_access_log",
|
||||
Default: true,
|
||||
Description: "Enable structured access log auditing and backpressure queueing",
|
||||
Type: "boolean",
|
||||
Category: "security",
|
||||
})
|
||||
|
||||
// 4. Register lifecycle disposal cleanup
|
||||
ctx.OnDispose(func() error {
|
||||
return risk_control.StopLogWriter(context.Background())
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package risk_control_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/core"
|
||||
"github.com/Rain-kl/Wavelet/plugins/domain/risk_control"
|
||||
)
|
||||
|
||||
func TestRiskControlPluginUnit(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
var customMWCalled bool
|
||||
customMW := func(c *gin.Context) {
|
||||
customMWCalled = true
|
||||
c.Next()
|
||||
}
|
||||
|
||||
p := risk_control.New(risk_control.WithMiddleware(customMW))
|
||||
assert.Equal(t, "risk_control", p.Name())
|
||||
assert.Equal(t, "1.0.0", p.Manifest().Version)
|
||||
require.NoError(t, p.Apply(ctx))
|
||||
|
||||
// Verify middlewares registered
|
||||
mws := ctx.Router().Middlewares()
|
||||
assert.NotEmpty(t, mws)
|
||||
|
||||
// Verify settings
|
||||
setting, ok := ctx.Settings().Get("risk_control.enable_access_log")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, true, setting.Default)
|
||||
|
||||
require.NoError(t, ctx.Dispose())
|
||||
assert.False(t, customMWCalled) // not dispatched via gin engine here
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE IF NOT EXISTS w_users (
|
||||
id BIGINT PRIMARY KEY,
|
||||
username VARCHAR(64) NOT NULL UNIQUE,
|
||||
password VARCHAR(255),
|
||||
nickname VARCHAR(255),
|
||||
email VARCHAR(255),
|
||||
avatar_url VARCHAR(255),
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
is_admin BOOLEAN DEFAULT FALSE,
|
||||
bio VARCHAR(500),
|
||||
phone VARCHAR(32),
|
||||
gender VARCHAR(16),
|
||||
website VARCHAR(255),
|
||||
location VARCHAR(255),
|
||||
last_login_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_w_users_email ON w_users (email);
|
||||
CREATE INDEX IF NOT EXISTS idx_w_users_is_active ON w_users (is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_w_users_last_login_at ON w_users (last_login_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_w_users_created_at ON w_users (created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS w_access_tokens (
|
||||
id BIGINT PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
token_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
description VARCHAR(255),
|
||||
is_admin BOOLEAN DEFAULT FALSE,
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_w_access_tokens_user_id ON w_access_tokens (user_id);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS w_access_tokens;
|
||||
DROP TABLE IF EXISTS w_users;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,130 @@
|
||||
// Package user provides the user profile, credential management, role management, and access token domain plugin for Cordis.
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/core"
|
||||
"github.com/Rain-kl/Wavelet/core/contracts"
|
||||
"github.com/Rain-kl/Wavelet/core/extpoints"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/user"
|
||||
"github.com/hibiken/asynq"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var userMigrations embed.FS
|
||||
|
||||
// Option configures the user plugin.
|
||||
type Option func(*Plugin)
|
||||
|
||||
// WithUserService sets a custom UserService implementation.
|
||||
func WithUserService(svc contracts.UserService) Option {
|
||||
return func(p *Plugin) {
|
||||
p.userSvc = svc
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin implements core.Plugin to provide user account and credential domain services.
|
||||
type Plugin struct {
|
||||
userSvc contracts.UserService
|
||||
}
|
||||
|
||||
// New creates a new user domain plugin.
|
||||
func New(opts ...Option) *Plugin {
|
||||
p := &Plugin{}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(p)
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Name returns the unique identifier for the user domain plugin.
|
||||
func (p *Plugin) Name() string {
|
||||
return "user"
|
||||
}
|
||||
|
||||
// Manifest returns the plugin metadata.
|
||||
func (p *Plugin) Manifest() core.Manifest {
|
||||
return core.Manifest{
|
||||
Name: "user",
|
||||
Version: "1.0.0",
|
||||
Description: "User profiles, credentials, role management, and access token domain plugin",
|
||||
Author: "Wavelet Team",
|
||||
}
|
||||
}
|
||||
|
||||
// Apply registers user migrations, services, routes, tasks, schedules, and settings into the Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
// 1. Register migrations
|
||||
ctx.Migrations().Register("user", userMigrations)
|
||||
|
||||
// 2. Initialize and provide UserService
|
||||
if p.userSvc == nil {
|
||||
p.userSvc = newUserService()
|
||||
}
|
||||
core.Provide[contracts.UserService](ctx, p.userSvc)
|
||||
|
||||
// 3. Register HTTP Routes
|
||||
userGroup := ctx.Router().Group("/api/v1/user")
|
||||
{
|
||||
userGroup.POST("/login", user.Login)
|
||||
userGroup.POST("/register", user.Register)
|
||||
userGroup.GET("/logout", user.Logout)
|
||||
userGroup.POST("/send-email-code", user.SendEmailCode)
|
||||
userGroup.POST("/change-password", oauth.LoginRequired(), user.ChangePassword)
|
||||
userGroup.PUT("/profile", oauth.LoginRequired(), user.UpdateProfile)
|
||||
|
||||
// Access Tokens
|
||||
tokensGroup := userGroup.Group("/access-tokens", oauth.LoginRequired(), oauth.DisallowTokenAuth())
|
||||
{
|
||||
tokensGroup.GET("", user.ListAccessTokens)
|
||||
tokensGroup.POST("", user.CreateAccessToken)
|
||||
tokensGroup.DELETE("/:id", user.DeleteAccessToken)
|
||||
tokensGroup.POST("/:id/rotate", user.RotateAccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Register Asynq background tasks
|
||||
ctx.Task().Register("user:send_email_code", func(c context.Context, t *asynq.Task) error {
|
||||
// Asynq background task handler
|
||||
return nil
|
||||
}, extpoints.WithTaskRetry(3))
|
||||
|
||||
ctx.Task().Register("user:cleanup_inactive", func(c context.Context, t *asynq.Task) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
// 5. Register Cron Schedules
|
||||
ctx.Schedule().RegisterCron("0 3 * * *", "user:daily_audit", map[string]string{"type": "audit"})
|
||||
|
||||
// 6. Register Settings Schemas
|
||||
ctx.Settings().Register(extpoints.SettingSchema{
|
||||
Key: "user.registration_enabled",
|
||||
Default: true,
|
||||
Description: "Whether new user registration is enabled",
|
||||
Type: "boolean",
|
||||
Category: "general",
|
||||
Public: true,
|
||||
})
|
||||
ctx.Settings().Register(extpoints.SettingSchema{
|
||||
Key: "user.password_login_enabled",
|
||||
Default: true,
|
||||
Description: "Whether password login is enabled",
|
||||
Type: "boolean",
|
||||
Category: "general",
|
||||
Public: true,
|
||||
})
|
||||
ctx.Settings().Register(extpoints.SettingSchema{
|
||||
Key: "user.min_password_length",
|
||||
Default: 8,
|
||||
Description: "Minimum password length required for user accounts",
|
||||
Type: "integer",
|
||||
Category: "security",
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package user_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/core"
|
||||
"github.com/Rain-kl/Wavelet/core/contracts"
|
||||
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/plugins/domain/user"
|
||||
)
|
||||
|
||||
func setupTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "user_test.db")
|
||||
testDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, testDB.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.AccessToken{},
|
||||
))
|
||||
|
||||
db.SetDB(testDB)
|
||||
return testDB
|
||||
}
|
||||
|
||||
func TestUserPluginUnit(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
_ = setupTestDB(t)
|
||||
|
||||
p := user.New()
|
||||
assert.Equal(t, "user", p.Name())
|
||||
assert.Equal(t, "1.0.0", p.Manifest().Version)
|
||||
require.NoError(t, p.Apply(ctx))
|
||||
|
||||
userSvc, err := core.Inject[contracts.UserService](ctx)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, userSvc)
|
||||
|
||||
testCtx := context.Background()
|
||||
|
||||
// 1. Create User
|
||||
u, err := userSvc.CreateUser(testCtx, contracts.CreateUserRequest{
|
||||
Username: "charlie",
|
||||
Password: "Password789!",
|
||||
Email: "charlie@example.com",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "charlie", u.Username)
|
||||
|
||||
// 2. Empty username error
|
||||
_, err = userSvc.CreateUser(testCtx, contracts.CreateUserRequest{})
|
||||
assert.Error(t, err)
|
||||
|
||||
// 3. Verify Password
|
||||
assert.True(t, userSvc.VerifyPassword(testCtx, u.ID, "Password789!"))
|
||||
assert.False(t, userSvc.VerifyPassword(testCtx, u.ID, "Wrong"))
|
||||
|
||||
// 4. Update Password with wrong old password
|
||||
err = userSvc.UpdatePassword(testCtx, u.ID, "WrongOld", "NewPass999!")
|
||||
assert.Error(t, err)
|
||||
|
||||
// Update Password success
|
||||
err = userSvc.UpdatePassword(testCtx, u.ID, "Password789!", "NewPass999!")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, userSvc.VerifyPassword(testCtx, u.ID, "NewPass999!"))
|
||||
|
||||
// 5. Update Profile
|
||||
nickname := "Charlie Brown"
|
||||
email := "charlie.new@example.com"
|
||||
gender := "male"
|
||||
website := "https://charlie.me"
|
||||
loc := "SF"
|
||||
updated, err := userSvc.UpdateProfile(testCtx, u.ID, contracts.UpdateUserProfileRequest{
|
||||
Nickname: &nickname,
|
||||
Email: &email,
|
||||
Gender: &gender,
|
||||
Website: &website,
|
||||
Location: &loc,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Charlie Brown", updated.Nickname)
|
||||
assert.Equal(t, "charlie.new@example.com", updated.Email)
|
||||
assert.Equal(t, "male", updated.Gender)
|
||||
assert.Equal(t, "https://charlie.me", updated.Website)
|
||||
assert.Equal(t, "SF", updated.Location)
|
||||
|
||||
// 6. List and Status
|
||||
require.NoError(t, userSvc.SetUserAdmin(testCtx, u.ID, true))
|
||||
require.NoError(t, userSvc.SetUserActive(testCtx, u.ID, true))
|
||||
|
||||
list, total, err := userSvc.ListUsers(testCtx, 1, 10, "")
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, total, int64(1))
|
||||
assert.NotEmpty(t, list)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// Package user provides user profiles, credentials, role management, and access token domain services.
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/core/contracts"
|
||||
"github.com/Rain-kl/Wavelet/internal/infra/persistence"
|
||||
"github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||
pkgu "github.com/Rain-kl/Wavelet/pkg/util"
|
||||
)
|
||||
|
||||
func toUserDTO(u *model.User) *contracts.UserDTO {
|
||||
if u == nil {
|
||||
return nil
|
||||
}
|
||||
return &contracts.UserDTO{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Nickname: u.Nickname,
|
||||
Email: u.Email,
|
||||
AvatarURL: u.AvatarURL,
|
||||
IsActive: u.IsActive,
|
||||
IsAdmin: u.IsAdmin,
|
||||
Bio: u.Bio,
|
||||
Phone: u.Phone,
|
||||
Gender: u.Gender,
|
||||
Website: u.Website,
|
||||
Location: u.Location,
|
||||
LastLoginAt: u.LastLoginAt,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
type userServiceImpl struct{}
|
||||
|
||||
func newUserService() contracts.UserService {
|
||||
return &userServiceImpl{}
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) GetUserByID(ctx context.Context, id uint64) (*contracts.UserDTO, error) {
|
||||
u, err := repository.GetUserByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toUserDTO(&u), nil
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) GetUserByUsername(ctx context.Context, username string) (*contracts.UserDTO, error) {
|
||||
u, err := repository.GetUserByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toUserDTO(&u), nil
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) GetUserByEmail(ctx context.Context, email string) (*contracts.UserDTO, error) {
|
||||
var u model.User
|
||||
if err := db.DB(ctx).Where("email = ?", email).First(&u).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toUserDTO(&u), nil
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) CreateUser(ctx context.Context, req contracts.CreateUserRequest) (*contracts.UserDTO, error) {
|
||||
if req.Username == "" {
|
||||
return nil, errors.New("user: username cannot be empty")
|
||||
}
|
||||
|
||||
user := model.User{
|
||||
ID: idgen.NextUint64ID(),
|
||||
Username: req.Username,
|
||||
Nickname: req.Nickname,
|
||||
Email: req.Email,
|
||||
IsActive: true,
|
||||
IsAdmin: req.IsAdmin,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
LastLoginAt: time.Now(),
|
||||
}
|
||||
|
||||
if user.Nickname == "" {
|
||||
user.Nickname = req.Username
|
||||
}
|
||||
|
||||
if req.Password != "" {
|
||||
if err := user.SetEncryptedPassword(req.Password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := repository.CreateUser(ctx, &user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toUserDTO(&user), nil
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) UpdateProfile(ctx context.Context, id uint64, req contracts.UpdateUserProfileRequest) (*contracts.UserDTO, error) {
|
||||
updates := make(map[string]any)
|
||||
if req.Nickname != nil {
|
||||
updates["nickname"] = *req.Nickname
|
||||
}
|
||||
if req.Email != nil {
|
||||
updates["email"] = *req.Email
|
||||
}
|
||||
if req.AvatarURL != nil {
|
||||
updates["avatar_url"] = *req.AvatarURL
|
||||
}
|
||||
if req.Bio != nil {
|
||||
updates["bio"] = *req.Bio
|
||||
}
|
||||
if req.Phone != nil {
|
||||
updates["phone"] = *req.Phone
|
||||
}
|
||||
if req.Gender != nil {
|
||||
updates["gender"] = *req.Gender
|
||||
}
|
||||
if req.Website != nil {
|
||||
updates["website"] = *req.Website
|
||||
}
|
||||
if req.Location != nil {
|
||||
updates["location"] = *req.Location
|
||||
}
|
||||
updates["updated_at"] = time.Now()
|
||||
|
||||
if err := db.DB(ctx).Model(&model.User{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.GetUserByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) UpdatePassword(ctx context.Context, id uint64, oldPassword, newPassword string) error {
|
||||
var user model.User
|
||||
if err := db.DB(ctx).Where("id = ?", id).First(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !user.CheckPassword(oldPassword) {
|
||||
return errors.New("user: incorrect old password")
|
||||
}
|
||||
|
||||
if err := user.SetEncryptedPassword(newPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.DB(ctx).Model(&model.User{}).Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"password": user.Password,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) VerifyPassword(ctx context.Context, id uint64, password string) bool {
|
||||
var user model.User
|
||||
if err := db.DB(ctx).Where("id = ?", id).First(&user).Error; err != nil {
|
||||
pkgu.DummyCheckPassword(password)
|
||||
return false
|
||||
}
|
||||
return user.CheckPassword(password)
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) UpdateLastLogin(ctx context.Context, id uint64, ip string) error {
|
||||
return db.DB(ctx).Model(&model.User{}).Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"last_login_at": time.Now(),
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) ListUsers(ctx context.Context, page, pageSize int, keyword string) ([]*contracts.UserDTO, int64, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
filter := repository.AdminUserListFilter{
|
||||
Username: keyword,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}
|
||||
|
||||
total, users, err := repository.ListAdminUsers(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
dtos := make([]*contracts.UserDTO, 0, len(users))
|
||||
for i := range users {
|
||||
dtos = append(dtos, toUserDTO(&users[i]))
|
||||
}
|
||||
|
||||
return dtos, total, nil
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) SetUserActive(ctx context.Context, id uint64, active bool) error {
|
||||
return repository.UpdateUserActive(ctx, id, active)
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) SetUserAdmin(ctx context.Context, id uint64, admin bool) error {
|
||||
return db.DB(ctx).Model(&model.User{}).Where("id = ?", id).Update("is_admin", admin).Error
|
||||
}
|
||||
Reference in New Issue
Block a user