限制系统邮件记录仅超管可见并校验唯一超级管理员
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-15 21:00:31 +08:00
parent 65ed505237
commit 3795c34218
17 changed files with 398 additions and 28 deletions

View File

@ -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

View File

@ -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
}

26
backend/db/super_admin.go Normal file
View File

@ -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
}

View File

@ -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

View File

@ -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)

View File

@ -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 {

View File

@ -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)

View File

@ -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

View File

@ -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;
}
}
}

View File

@ -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,

View File

@ -19,7 +19,11 @@
format="YYYY-MM-DD HH:mm"
aria-label="日志时间范围"
/>
<el-input v-if="!isRuntime && canFilterActor" v-model="actorId" clearable placeholder="操作者编号" aria-label="操作者编号" @keyup.enter="search" />
<el-select v-if="canFilterSystemMail" v-model="mailScope" class="mail-scope" aria-label="收发信记录范围">
<el-option label="全部收发信" value="all" />
<el-option label="系统收发信" value="system" />
</el-select>
<el-input v-if="!isRuntime && canFilterActor && !systemMailSelected" v-model="actorId" clearable placeholder="操作者编号" aria-label="操作者编号" @keyup.enter="search" />
<div class="log-filter-actions">
<el-button :disabled="loading" @click="recent">最近 24 小时</el-button>
<el-button type="primary" :loading="loading" @click="search">查询</el-button>
@ -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()
</script>
<style scoped src="./LogSettings.css"></style>
<style scoped src="./LogSettings.css"></style>

View File

@ -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 {

View File

@ -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("低权限账号读取到了系统邮件")
}
}
}
}

View File

@ -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("非法账号状态仍完成了初始化")
}
}

View File

@ -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("检查意外修改了账号")
}
})
}
}

View File

@ -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 = []

View File

@ -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',