diff --git a/backend/controllers/logs.go b/backend/controllers/logs.go index f8a20fe..ee07c63 100644 --- a/backend/controllers/logs.go +++ b/backend/controllers/logs.go @@ -48,6 +48,20 @@ func GetLogs(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) bounded, cancel := context.WithTimeout(req.Context(), 10*time.Second) defer cancel() query := logstore.Query{Kind: values.Get("kind"), ActorID: values.Get("actorId"), Limit: limit + 1, Offset: offset} + if values.Has("systemOnly") { + value := values.Get("systemOnly") + if query.Kind != "mail" || (value != "true" && value != "false") { + writeConfigError(w, config.ErrInvalidAuditQuery) + return + } + if value == "true" { + if ctx.EffectiveRole() != access.RoleSuperAdmin { + writeConfigAccessError(w, &access.Denial{Code: access.CodeAccessDenied}) + return + } + query.SystemOnly = true + } + } if ctx.EffectiveRole() != access.RoleSuperAdmin { query.ScopeActorID = strconv.Itoa(ctx.UserID) query.IncludeOrdinaryUsers = ctx.EffectiveRole() == access.RoleAdmin diff --git a/backend/db/init.go b/backend/db/init.go index 0f1e930..265f697 100644 --- a/backend/db/init.go +++ b/backend/db/init.go @@ -10,7 +10,6 @@ import ( "git.pcux.cn/xuans/Smail/config" "git.pcux.cn/xuans/Smail/db/migration" "git.pcux.cn/xuans/Smail/models" - "git.pcux.cn/xuans/Smail/services/access" requestcontext "git.pcux.cn/xuans/Smail/utils/context" "git.pcux.cn/xuans/Smail/utils/errors" _ "github.com/go-sql-driver/mysql" @@ -174,17 +173,8 @@ func fixHistoryData() error { return errors.Wrap(err) } - // 只有一个账号,且不是管理员账号,将账号提权为管理员 - if len(user) == 1 && user[0].IsAdmin == 0 { - u := user[0] - u.ApplyRole(access.RoleSuperAdmin) - _, err = Instance.Update(&u) - if err != nil { - return errors.Wrap(err) - } - } - - if len(user) != 1 { + // 历史邮件只能归入唯一超级管理员,不能把仅存的普通账号自动提权。 + if len(user) != 1 || ValidateSuperAdmin(Instance) != nil { return nil } diff --git a/backend/db/super_admin.go b/backend/db/super_admin.go new file mode 100644 index 0000000..26be7dc --- /dev/null +++ b/backend/db/super_admin.go @@ -0,0 +1,26 @@ +package db + +import ( + "errors" + "fmt" + + "git.pcux.cn/xuans/Smail/models" + "xorm.io/xorm" +) + +var ErrSuperAdminInvariant = errors.New("必须有且仅有一个启用的超级管理员,请在部署端核查账号数据") + +// ValidateSuperAdmin 在初始化完成和业务启动前检查唯一管理员,不自动提权或降级任何账号。 +func ValidateSuperAdmin(engine *xorm.Engine) error { + if engine == nil { + return fmt.Errorf("%w:数据库尚未初始化", ErrSuperAdminInvariant) + } + var users []models.User + if err := engine.Cols("id", "disabled").Where(models.SuperAdminPredicate).Limit(2).Find(&users); err != nil { + return fmt.Errorf("读取超级管理员状态失败:%w", err) + } + if len(users) != 1 || users[0].Disabled != 0 { + return ErrSuperAdminInvariant + } + return nil +} diff --git a/backend/res_init/init.go b/backend/res_init/init.go index c062352..c8a4005 100644 --- a/backend/res_init/init.go +++ b/backend/res_init/init.go @@ -172,6 +172,9 @@ func Init(serverVersion string) error { if err := db.Init(serverVersion); err != nil { return err } + if err := db.ValidateSuperAdmin(db.Instance); err != nil { + return err + } dialect, err := migration.ParseDialect(cfg.DbType) if err != nil { return err diff --git a/backend/services/logstore/store.go b/backend/services/logstore/store.go index ed504e1..9675284 100644 --- a/backend/services/logstore/store.go +++ b/backend/services/logstore/store.go @@ -38,6 +38,7 @@ type Query struct { // ScopeActorID 不接受客户端直接赋值;非空时由服务端强制限制可见账号。 ScopeActorID string IncludeOrdinaryUsers bool + SystemOnly bool ID string Kind string ActorID string @@ -180,6 +181,13 @@ func (s *Store) Query(ctx context.Context, query Query) ([]Entry, error) { } statement := "SELECT " + columns + " FROM " + table + " WHERE created_at >= ? AND kind = ?" args := []any{from.UnixNano(), query.Kind} + if query.SystemOnly { + if query.Kind != "mail" { + return nil, ErrInvalid + } + // 兼容早期系统记录的空操作者;仍与服务端账号范围条件取交集。 + statement += " AND (actor_id = '0' OR actor_id = '')" + } if query.ScopeActorID != "" { statement += " AND (actor_id = ?" args = append(args, query.ScopeActorID) diff --git a/backend/services/setup/db.go b/backend/services/setup/db.go index 8feeeec..2855810 100644 --- a/backend/services/setup/db.go +++ b/backend/services/setup/db.go @@ -18,6 +18,7 @@ import ( "git.pcux.cn/xuans/Smail/db" "git.pcux.cn/xuans/Smail/db/migration" "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/access" "git.pcux.cn/xuans/Smail/services/auth" "git.pcux.cn/xuans/Smail/utils/array" "git.pcux.cn/xuans/Smail/utils/context" @@ -147,10 +148,13 @@ func GetAdminPassword(ctx *context.Context) (string, error) { return "", databaseError("数据库尚未初始化,请先完成数据库设置", nil) } users := []*models.User{} - if err := db.Instance.Find(&users); err != nil { + if err := db.Instance.Where(models.SuperAdminPredicate).Limit(2).Find(&users); err != nil { return "", errors.Wrap(err) } - if len(users) > 0 { + if len(users) > 1 { + return "", db.ErrSuperAdminInvariant + } + if len(users) == 1 { return users[0].Account, nil } return "", nil @@ -173,21 +177,40 @@ func SetAdminPassword(ctx *context.Context, account, pwd string) error { if err != nil { return errors.Wrap(err) } - count, err := db.Instance.Count(new(models.User)) + user := models.User{Account: account, Name: "admin", SessionVersion: 1, SecurityVersion: 1} + user.ApplyRole(access.RoleSuperAdmin) + if err := auth.SetPassword(&user, pwd); err != nil { + return errors.Wrap(err) + } + tx := db.Instance.NewSession() + defer tx.Close() + if err := tx.Begin(); err != nil { + return errors.Wrap(err) + } + defer tx.Rollback() + // 锁定必然存在的首条迁移记录,跨进程串行化初始化;三种数据库均支持此写锁。 + // 先获得写锁再读取用户数,避免两个初始化进程同时看到空库。 + if _, err := tx.Exec("UPDATE schema_migration SET execution_ms = execution_ms WHERE version = 1"); err != nil { + return errors.Wrap(err) + } + lockRows, err := tx.QueryString("SELECT version FROM schema_migration WHERE version = 1") + if err != nil { + return errors.Wrap(err) + } + if len(lockRows) != 1 { + return databaseError("数据库迁移记录不完整,不能初始化超级管理员", nil) + } + count, err := tx.Count(new(models.User)) if err != nil { return errors.Wrap(err) } if count != 0 { - return errors.New("administrator already exists") + return databaseError("账号已初始化,不能再次创建超级管理员", nil) } - user := models.User{Account: account, Name: "admin", IsAdmin: 1, SessionVersion: 1} - if err := auth.SetPassword(&user, pwd); err != nil { + if _, err := tx.Insert(&user); err != nil { return errors.Wrap(err) } - if _, err := db.Instance.Insert(&user); err != nil { - return errors.Wrap(err) - } - return nil + return tx.Commit() } func validateAdminPassword(pwd string) error { diff --git a/backend/services/setup/finish.go b/backend/services/setup/finish.go index 5b6cdf6..021b891 100644 --- a/backend/services/setup/finish.go +++ b/backend/services/setup/finish.go @@ -2,6 +2,7 @@ package setup import ( "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/db" "git.pcux.cn/xuans/Smail/signal" "git.pcux.cn/xuans/Smail/utils/errors" "sync" @@ -13,6 +14,9 @@ var finishMu sync.Mutex func Finish() error { finishMu.Lock() defer finishMu.Unlock() + if err := db.ValidateSuperAdmin(db.Instance); err != nil { + return err + } cfg, err := config.ReadConfig() if err != nil { return errors.Wrap(err) diff --git a/backend/services/setup/finish_test.go b/backend/services/setup/finish_test.go index a555709..10c31c0 100644 --- a/backend/services/setup/finish_test.go +++ b/backend/services/setup/finish_test.go @@ -2,9 +2,13 @@ package setup import ( "os" + "path/filepath" "testing" "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/db" + "git.pcux.cn/xuans/Smail/models" + "xorm.io/xorm" ) func TestFinishPersistsServicePorts(t *testing.T) { @@ -120,6 +124,22 @@ func prepareFinishConfigTest(t *testing.T) { previousManager := config.CurrentManager() previousSetupPort := config.RuntimeSetupPort() previousInitState := config.IsInit + previousDB := db.Instance + engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "finish.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + db.Instance = previousDB + _ = engine.Close() + }) + if err := engine.Sync2(new(models.User)); err != nil { + t.Fatal(err) + } + if _, err := engine.Insert(&models.User{Account: "root", Name: "root", Password: "unused", Role: "super_admin", IsAdmin: 1}); err != nil { + t.Fatal(err) + } + db.Instance = engine config.ROOT_PATH = t.TempDir() + string(os.PathSeparator) config.Instance = nil diff --git a/frontend/src/views/components/LogSettings/LogSettings.css b/frontend/src/views/components/LogSettings/LogSettings.css index 71c5cf6..960aa4f 100644 --- a/frontend/src/views/components/LogSettings/LogSettings.css +++ b/frontend/src/views/components/LogSettings/LogSettings.css @@ -102,6 +102,11 @@ max-width: 190px; } +.log-filters .mail-scope { + flex: 0 1 145px; + min-width: 130px; +} + .log-filter-actions { display: flex; align-items: center; @@ -371,4 +376,4 @@ .log-categories :deep(.el-radio-button__inner) { font-size: 12px; } -} \ No newline at end of file +} diff --git a/frontend/src/views/components/LogSettings/LogSettings.js b/frontend/src/views/components/LogSettings/LogSettings.js index fda900b..5ee993e 100644 --- a/frontend/src/views/components/LogSettings/LogSettings.js +++ b/frontend/src/views/components/LogSettings/LogSettings.js @@ -14,6 +14,9 @@ export function useLogs() { const offset = ref(0) const hasMore = ref(false) const actorId = ref('') + const mailScope = ref('all') + const canFilterSystemMail = computed(() => store.role === 'super_admin' && category.value === 'mail') + const systemMailSelected = computed(() => canFilterSystemMail.value && mailScope.value === 'system') const recentRange = () => { const end = new Date() return [new Date(end.getTime() - 24 * 60 * 60 * 1000), end] @@ -38,7 +41,7 @@ export function useLogs() { const actionLabel = value => ({ update: '保存配置', rollback: '回滚配置', migration: '迁移配置' }[value] || value || '未记录操作') const displayObject = item => item.payload?.object || item.payload?.stage || '未记录对象' const displayOutcome = item => item.payload?.outcome || '历史记录未提供结果说明' - const displayActor = item => item.payload?.actorName || (item.actorId && item.actorId !== '0' ? item.actorId : '系统') + const displayActor = item => !item.actorId || item.actorId === '0' ? '系统' : (item.payload?.actorName || item.actorId) const details = computed(() => detail.value?.payload || {}) const runtimeRows = computed(() => lines.value.map((line, index) => parseRuntimeLine(line, offset.value + index + 1))) const runtimeSummary = computed(() => summarizeRuntime(runtimeRows.value)) @@ -93,7 +96,8 @@ export function useLogs() { return } appliedFilters = { kind: category.value, from: from.toISOString(), to: to.toISOString() } - if (canFilterActor.value && !isRuntime.value && actorId.value.trim()) appliedFilters.actorId = actorId.value.trim() + if (systemMailSelected.value) appliedFilters.systemOnly = true + else if (canFilterActor.value && !isRuntime.value && actorId.value.trim()) appliedFilters.actorId = actorId.value.trim() offset.value = 0 previousOffsets.length = 0 detail.value = null @@ -120,6 +124,7 @@ export function useLogs() { runtimeDetail.value = null search() }) + watch(mailScope, search) onMounted(search) onBeforeUnmount(() => { ++requestId @@ -129,6 +134,7 @@ export function useLogs() { return { category, categories, loading, error, items, lines, offset, canFilterActor, + mailScope, canFilterSystemMail, systemMailSelected, hasMore, actorId, dateRange, detail, details, isRuntime, runtimeDetail, runtimeRows, runtimeSummary, visibleCount, pageRange, currentCategory, resultType, runtimeRowClass, timeParts, diff --git a/frontend/src/views/components/LogSettings/LogSettings.vue b/frontend/src/views/components/LogSettings/LogSettings.vue index 0d76921..4d6b3f4 100644 --- a/frontend/src/views/components/LogSettings/LogSettings.vue +++ b/frontend/src/views/components/LogSettings/LogSettings.vue @@ -19,7 +19,11 @@ format="YYYY-MM-DD HH:mm" aria-label="日志时间范围" /> - + + + + +
最近 24 小时 查询 @@ -174,6 +178,7 @@ import { useLogs } from './LogSettings.js' const { category, categories, loading, error, items, offset, canFilterActor, + mailScope, canFilterSystemMail, systemMailSelected, hasMore, actorId, dateRange, detail, details, isRuntime, runtimeDetail, runtimeRows, runtimeSummary, pageRange, currentCategory, resultType, runtimeRowClass, timeParts, @@ -181,4 +186,4 @@ const { } = useLogs() - \ No newline at end of file + diff --git a/test/backend/log_scope_test.go b/test/backend/log_scope_test.go index dd8c10d..31f68b4 100644 --- a/test/backend/log_scope_test.go +++ b/test/backend/log_scope_test.go @@ -2,11 +2,41 @@ package logstore import ( "context" + "errors" "fmt" "testing" "time" ) +func TestSystemMailScopeIntersectsPermissionsAndPagination(t *testing.T) { + s := testStore(t) + now := time.Now().UTC() + for index, actor := range []string{"0", "4", "", "0"} { + if err := s.Write(context.Background(), Entry{ID: fmt.Sprint(index), Kind: "mail", ActorID: actor, CreatedAt: now.Add(-time.Duration(index) * time.Minute), Action: "投递邮件", Result: "success", Payload: []byte(`{}`)}); err != nil { + t.Fatal(err) + } + } + for page, actor := range []string{"0", "", "0"} { + items, err := s.Query(context.Background(), Query{Kind: "mail", SystemOnly: true, Limit: 1, Offset: page}) + if err != nil || len(items) != 1 || items[0].ActorID != actor { + t.Fatalf("系统邮件分页错误:%+v %v", items, err) + } + } + for _, includeUsers := range []bool{false, true} { + items, err := s.Query(context.Background(), Query{Kind: "mail", SystemOnly: true, ScopeActorID: "4", IncludeOrdinaryUsers: includeUsers}) + if err != nil || len(items) != 0 { + t.Fatal("系统筛选扩大了权限范围") + } + } + items, err := s.Query(context.Background(), Query{Kind: "mail", SystemOnly: true, From: now.Add(-time.Minute), To: now}) + if err != nil || len(items) != 1 { + t.Fatal("系统筛选没有遵守时间窗口") + } + if _, err := s.Query(context.Background(), Query{Kind: "operation", SystemOnly: true}); !errors.Is(err, ErrInvalid) { + t.Fatal("非邮件分类接受了系统邮件筛选") + } +} + func TestLogScopeFiltersBeforePagination(t *testing.T) { s := testStore(t) if _, err := s.db.Exec(`INSERT INTO "user" (id, account, name, password, role, is_admin) VALUES (1,'root','Root','hash','super_admin',1),(2,'manager','Manager','hash','admin',1),(3,'other-manager','Other','hash','admin',1),(4,'alice','Alice','hash','user',0),(5,'legacy','Legacy','hash','legacy',0),(6,'legacy-manager','Legacy admin','hash','legacy',1),(7,'user-admin-flag','User admin flag','hash','user',1)`); err != nil { diff --git a/test/backend/logs_api_test.go b/test/backend/logs_api_test.go index aaca8df..a078445 100644 --- a/test/backend/logs_api_test.go +++ b/test/backend/logs_api_test.go @@ -66,11 +66,14 @@ func TestLogsAPIRejectsUnscopedAccessAndValidatesQueries(t *testing.T) { } } for _, role := range []access.Role{access.RoleUser, access.RoleAdmin} { + if got := call(role, "kind=mail&systemOnly=true"); got.ErrorNo != response.NoAccessPrivileges { + t.Fatalf("低权限账号能够查询系统邮件:%s", role) + } if got := call(role, "kind=runtime&listFiles=true"); got.ErrorNo != response.NoAccessPrivileges { t.Fatalf("低权限用户能读取全局文件列表:%s", role) } } - for _, query := range []string{"kind=config", "kind=unknown", "kind=mail&limit=501", "kind=mail&offset=-1", "kind=mail&from=invalid", "kind=mail&from=2026-09-08T00:00:00Z&to=2026-09-01T00:00:00Z"} { + for _, query := range []string{"kind=operation&systemOnly=true", "kind=mail&systemOnly=1", "kind=config", "kind=unknown", "kind=mail&limit=501", "kind=mail&offset=-1", "kind=mail&from=invalid", "kind=mail&from=2026-09-08T00:00:00Z&to=2026-09-01T00:00:00Z"} { if got := call(access.RoleSuperAdmin, query); got.ErrorNo != response.ParamsError { t.Fatalf("invalid query accepted: %s => %d", query, got.ErrorNo) } @@ -122,4 +125,16 @@ func TestLogsAPIRejectsUnscopedAccessAndValidatesQueries(t *testing.T) { t.Fatalf("指定时段:%+v", history) } } + filtered := call(access.RoleSuperAdmin, "kind=mail&systemOnly=true") + if filtered.ErrorNo != 0 || len(filtered.Data.(map[string]any)["items"].([]any)) != 10 { + t.Fatal("超级管理员无法筛选系统邮件") + } + for _, role := range []access.Role{access.RoleAdmin, access.RoleUser} { + for _, query := range []string{"kind=mail", "kind=mail&actorId=0", "kind=mail&systemOnly=false"} { + got := call(role, query) + if got.ErrorNo != 0 || len(got.Data.(map[string]any)["items"].([]any)) != 0 { + t.Fatal("低权限账号读取到了系统邮件") + } + } + } } diff --git a/test/backend/setup_super_admin_test.go b/test/backend/setup_super_admin_test.go new file mode 100644 index 0000000..d72a538 --- /dev/null +++ b/test/backend/setup_super_admin_test.go @@ -0,0 +1,132 @@ +package setup + +import ( + "errors" + "path/filepath" + "sync" + "testing" + + "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/db" + "git.pcux.cn/xuans/Smail/models" + appcontext "git.pcux.cn/xuans/Smail/utils/context" +) + +func prepareUniqueAdminDatabase(t *testing.T) { + t.Helper() + oldConfig, oldSnapshot := config.Instance, config.CurrentConfig() + oldManager, oldDB := config.CurrentManager(), db.Instance + config.Instance = &config.Config{DbType: config.DBTypeSQLite, DbDSN: filepath.Join(t.TempDir(), "setup.db")} + config.PublishConfigSnapshot(config.Instance) + config.SetRuntimeManager(nil) + db.Instance = nil + t.Setenv("SMAIL_ENABLE_LEGACY_SYNC2", "0") + t.Cleanup(func() { + if db.Instance != nil && db.Instance != oldDB { + _ = db.Instance.Close() + } + db.Instance = oldDB + config.Instance = oldConfig + config.PublishConfigSnapshot(oldSnapshot) + config.SetRuntimeManager(oldManager) + }) + if err := db.Init(""); err != nil { + t.Fatal(err) + } +} + +func TestConcurrentAdministratorInitialization(t *testing.T) { + prepareUniqueAdminDatabase(t) + var group sync.WaitGroup + results := make(chan error, 4) + for _, account := range []string{"first", "second", "third", "fourth"} { + group.Add(1) + go func(account string) { + defer group.Done() + results <- SetAdminPassword(&appcontext.Context{}, account, "StrongP12") + }(account) + } + group.Wait() + close(results) + successes := 0 + for err := range results { + if err == nil { + successes++ + } + } + if successes != 1 { + t.Fatalf("初始化成功次数:%d", successes) + } + if err := db.ValidateSuperAdmin(db.Instance); err != nil { + t.Fatal(err) + } +} + +func TestSetupRejectsMissingMigrationLock(t *testing.T) { + prepareUniqueAdminDatabase(t) + // 仅破坏本测试的临时迁移记录,不读取或清空运行数据库。 + if _, err := db.Instance.Exec("DELETE FROM schema_migration WHERE version = 1"); err != nil { + t.Fatal(err) + } + if err := SetAdminPassword(&appcontext.Context{}, "root", "StrongP12"); err == nil { + t.Fatal("缺少锁记录仍创建了账号") + } + count, err := db.Instance.Count(new(models.User)) + if err != nil || count != 0 { + t.Fatalf("失败初始化写入了账号:%d %v", count, err) + } +} + +func TestSetupDoesNotPromoteSoleOrdinaryAccount(t *testing.T) { + prepareUniqueAdminDatabase(t) + user := models.User{Account: "ordinary", Name: "ordinary", Password: "unused", Role: "user"} + if _, err := db.Instance.Insert(&user); err != nil { + t.Fatal(err) + } + if err := db.Instance.Close(); err != nil { + t.Fatal(err) + } + db.Instance = nil + if err := db.Init(""); err != nil { + t.Fatal(err) + } + account, err := GetAdminPassword(&appcontext.Context{}) + if err != nil || account != "" { + t.Fatalf("把普通用户当作超级管理员:%q %v", account, err) + } + if err := db.ValidateSuperAdmin(db.Instance); !errors.Is(err, db.ErrSuperAdminInvariant) { + t.Fatal("普通用户被自动提权") + } + if err := SetAdminPassword(&appcontext.Context{}, "root", "StrongP12"); err == nil { + t.Fatal("非空库重新初始化") + } + for _, account := range []string{"root", "duplicate"} { + if _, err := db.Instance.Insert(&models.User{Account: account, Name: account, Password: "unused", Role: "super_admin"}); err != nil { + t.Fatal(err) + } + got, err := GetAdminPassword(&appcontext.Context{}) + if account == "root" && (err != nil || got != "root") { + t.Fatalf("超级管理员查询错误:%q %v", got, err) + } + if account == "duplicate" && !errors.Is(err, db.ErrSuperAdminInvariant) { + t.Fatal("重复超级管理员没有被拒绝") + } + } +} + +func TestFinishRejectsDuplicateSuperAdminBeforeWritingConfiguration(t *testing.T) { + prepareFinishConfigTest(t) + if err := config.WriteConfig(&config.Config{SetupState: config.SetupStateBootstrap}); err != nil { + t.Fatal(err) + } + if _, err := db.Instance.Insert(&models.User{Account: "duplicate", Name: "test", Password: "unused", Role: "super_admin"}); err != nil { + t.Fatal(err) + } + if err := Finish(); !errors.Is(err, db.ErrSuperAdminInvariant) { + t.Fatal("初始化完成时未验证唯一超级管理员") + } + cfg, err := config.ReadConfig() + if err != nil || cfg.IsInit || cfg.SetupState != config.SetupStateBootstrap { + t.Fatal("非法账号状态仍完成了初始化") + } +} diff --git a/test/backend/super_admin_test.go b/test/backend/super_admin_test.go new file mode 100644 index 0000000..d558e55 --- /dev/null +++ b/test/backend/super_admin_test.go @@ -0,0 +1,63 @@ +package db + +import ( + "errors" + "path/filepath" + "reflect" + "testing" + + "git.pcux.cn/xuans/Smail/models" + "xorm.io/xorm" +) + +func TestSuperAdminInvariantDoesNotMutateAccounts(t *testing.T) { + if !errors.Is(ValidateSuperAdmin(nil), ErrSuperAdminInvariant) { + t.Fatal("未初始化数据库没有被拒绝") + } + for _, tc := range []struct { + name string + users []models.User + valid bool + }{ + {"empty", nil, false}, + {"ordinary", []models.User{{Role: "user"}}, false}, + {"explicit-user", []models.User{{Role: "user", IsAdmin: 1}}, false}, + {"single", []models.User{{Role: "super_admin"}}, true}, + {"disabled", []models.User{{Role: "super_admin", Disabled: 1}}, false}, + {"duplicate", []models.User{{Role: "super_admin"}, {Role: "super_admin", Disabled: 1}}, false}, + {"legacy", []models.User{{Role: "legacy", IsAdmin: 1}}, true}, + {"mixed-duplicate", []models.User{{Role: "super_admin"}, {Role: "legacy", IsAdmin: 1}}, false}, + {"multiple-admins", []models.User{{Role: "super_admin"}, {Role: "admin", IsAdmin: 1}, {Role: "admin", IsAdmin: 1}, {Role: "user"}}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "accounts.db")) + if err != nil { + t.Fatal(err) + } + defer engine.Close() + if err := engine.Sync2(new(models.User)); err != nil { + t.Fatal(err) + } + for index, user := range tc.users { + user.Account, user.Name, user.Password = string(rune('a'+index)), "test", "unused" + if _, err := engine.Insert(&user); err != nil { + t.Fatal(err) + } + } + var before, after []models.User + if err := engine.OrderBy("id").Find(&before); err != nil { + t.Fatal(err) + } + err = ValidateSuperAdmin(engine) + if (err == nil) != tc.valid || (!tc.valid && !errors.Is(err, ErrSuperAdminInvariant)) { + t.Fatalf("唯一性检查不符合预期:%v", err) + } + if err := engine.OrderBy("id").Find(&after); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, after) { + t.Fatal("检查意外修改了账号") + } + }) + } +} diff --git a/test/frontend/logs.test.mjs b/test/frontend/logs.test.mjs index 631ec2c..65e2f89 100644 --- a/test/frontend/logs.test.mjs +++ b/test/frontend/logs.test.mjs @@ -19,6 +19,29 @@ function fixture(get, role = 'super_admin') { return { page, dispose: () => dispose() } } +test('系统收发信筛选仅超管可用,切换时不携带冲突的操作者编号', async () => { + for (const role of ['super_admin', 'admin', 'user']) { + const requests = [] + const { page } = fixture(async (_, { params }) => { + requests.push(params) + return { errorNo: 0, data: { items: [] } } + }, role) + page.category.value = 'mail' + page.actorId.value = '42' + page.mailScope.value = 'system' + await page.search() + assert.equal(page.canFilterSystemMail.value, role === 'super_admin') + assert.equal(requests[0].systemOnly, role === 'super_admin' ? true : undefined) + assert.equal(requests[0].actorId, role === 'admin' ? '42' : undefined) + page.mailScope.value = 'all' + await page.search() + assert.equal(requests[1].systemOnly, undefined) + assert.equal(requests[1].actorId, role === 'user' ? undefined : '42') + assert.equal(page.displayActor({ actorId: '0', payload: { actorName: 'xuans' } }), '系统') + } + assert.match(read('LogSettings.vue'), /v-if="canFilterSystemMail"/) +}) + test('普通用户不发送操作者筛选,管理员保留范围内筛选', async () => { for (const role of ['user', 'admin']) { const requests = [] diff --git a/test/run.mjs b/test/run.mjs index 8e60e85..31f6e0f 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -49,6 +49,9 @@ if ((suite === 'all' || suite === 'backend') && result === 0) { const args = extra[0] === '--' ? extra.slice(1) : extra // 独立回归源码集中放在 test/backend,通过 overlay 保留同包测试语义。 const replacements = { + 'backend/db/super_admin_test.go': 'test/backend/super_admin_test.go', + 'backend/services/setup/setup_findings_test.go': 'test/review/setup_findings_test.go', + 'backend/services/setup/super_admin_test.go': 'test/backend/setup_super_admin_test.go', 'backend/config/plugin_role_scope_test.go': 'test/backend/plugin_role_scope_test.go', 'backend/controllers/role_scope_test.go': 'test/backend/role_scope_test.go', 'backend/services/logstore/log_scope_test.go': 'test/backend/log_scope_test.go',