This commit is contained in:
parent
65ed505237
commit
3795c34218
@ -48,6 +48,20 @@ func GetLogs(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request)
|
|||||||
bounded, cancel := context.WithTimeout(req.Context(), 10*time.Second)
|
bounded, cancel := context.WithTimeout(req.Context(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
query := logstore.Query{Kind: values.Get("kind"), ActorID: values.Get("actorId"), Limit: limit + 1, Offset: offset}
|
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 {
|
if ctx.EffectiveRole() != access.RoleSuperAdmin {
|
||||||
query.ScopeActorID = strconv.Itoa(ctx.UserID)
|
query.ScopeActorID = strconv.Itoa(ctx.UserID)
|
||||||
query.IncludeOrdinaryUsers = ctx.EffectiveRole() == access.RoleAdmin
|
query.IncludeOrdinaryUsers = ctx.EffectiveRole() == access.RoleAdmin
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import (
|
|||||||
"git.pcux.cn/xuans/Smail/config"
|
"git.pcux.cn/xuans/Smail/config"
|
||||||
"git.pcux.cn/xuans/Smail/db/migration"
|
"git.pcux.cn/xuans/Smail/db/migration"
|
||||||
"git.pcux.cn/xuans/Smail/models"
|
"git.pcux.cn/xuans/Smail/models"
|
||||||
"git.pcux.cn/xuans/Smail/services/access"
|
|
||||||
requestcontext "git.pcux.cn/xuans/Smail/utils/context"
|
requestcontext "git.pcux.cn/xuans/Smail/utils/context"
|
||||||
"git.pcux.cn/xuans/Smail/utils/errors"
|
"git.pcux.cn/xuans/Smail/utils/errors"
|
||||||
_ "github.com/go-sql-driver/mysql"
|
_ "github.com/go-sql-driver/mysql"
|
||||||
@ -174,17 +173,8 @@ func fixHistoryData() error {
|
|||||||
return errors.Wrap(err)
|
return errors.Wrap(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只有一个账号,且不是管理员账号,将账号提权为管理员
|
// 历史邮件只能归入唯一超级管理员,不能把仅存的普通账号自动提权。
|
||||||
if len(user) == 1 && user[0].IsAdmin == 0 {
|
if len(user) != 1 || ValidateSuperAdmin(Instance) != nil {
|
||||||
u := user[0]
|
|
||||||
u.ApplyRole(access.RoleSuperAdmin)
|
|
||||||
_, err = Instance.Update(&u)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(user) != 1 {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
26
backend/db/super_admin.go
Normal file
26
backend/db/super_admin.go
Normal 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
|
||||||
|
}
|
||||||
@ -172,6 +172,9 @@ func Init(serverVersion string) error {
|
|||||||
if err := db.Init(serverVersion); err != nil {
|
if err := db.Init(serverVersion); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := db.ValidateSuperAdmin(db.Instance); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
dialect, err := migration.ParseDialect(cfg.DbType)
|
dialect, err := migration.ParseDialect(cfg.DbType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@ -38,6 +38,7 @@ type Query struct {
|
|||||||
// ScopeActorID 不接受客户端直接赋值;非空时由服务端强制限制可见账号。
|
// ScopeActorID 不接受客户端直接赋值;非空时由服务端强制限制可见账号。
|
||||||
ScopeActorID string
|
ScopeActorID string
|
||||||
IncludeOrdinaryUsers bool
|
IncludeOrdinaryUsers bool
|
||||||
|
SystemOnly bool
|
||||||
ID string
|
ID string
|
||||||
Kind string
|
Kind string
|
||||||
ActorID 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 = ?"
|
statement := "SELECT " + columns + " FROM " + table + " WHERE created_at >= ? AND kind = ?"
|
||||||
args := []any{from.UnixNano(), query.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 != "" {
|
if query.ScopeActorID != "" {
|
||||||
statement += " AND (actor_id = ?"
|
statement += " AND (actor_id = ?"
|
||||||
args = append(args, query.ScopeActorID)
|
args = append(args, query.ScopeActorID)
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import (
|
|||||||
"git.pcux.cn/xuans/Smail/db"
|
"git.pcux.cn/xuans/Smail/db"
|
||||||
"git.pcux.cn/xuans/Smail/db/migration"
|
"git.pcux.cn/xuans/Smail/db/migration"
|
||||||
"git.pcux.cn/xuans/Smail/models"
|
"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/services/auth"
|
||||||
"git.pcux.cn/xuans/Smail/utils/array"
|
"git.pcux.cn/xuans/Smail/utils/array"
|
||||||
"git.pcux.cn/xuans/Smail/utils/context"
|
"git.pcux.cn/xuans/Smail/utils/context"
|
||||||
@ -147,10 +148,13 @@ func GetAdminPassword(ctx *context.Context) (string, error) {
|
|||||||
return "", databaseError("数据库尚未初始化,请先完成数据库设置", nil)
|
return "", databaseError("数据库尚未初始化,请先完成数据库设置", nil)
|
||||||
}
|
}
|
||||||
users := []*models.User{}
|
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)
|
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 users[0].Account, nil
|
||||||
}
|
}
|
||||||
return "", nil
|
return "", nil
|
||||||
@ -173,21 +177,40 @@ func SetAdminPassword(ctx *context.Context, account, pwd string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err)
|
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 {
|
if err != nil {
|
||||||
return errors.Wrap(err)
|
return errors.Wrap(err)
|
||||||
}
|
}
|
||||||
if count != 0 {
|
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 := tx.Insert(&user); err != nil {
|
||||||
if err := auth.SetPassword(&user, pwd); err != nil {
|
|
||||||
return errors.Wrap(err)
|
return errors.Wrap(err)
|
||||||
}
|
}
|
||||||
if _, err := db.Instance.Insert(&user); err != nil {
|
return tx.Commit()
|
||||||
return errors.Wrap(err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateAdminPassword(pwd string) error {
|
func validateAdminPassword(pwd string) error {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package setup
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"git.pcux.cn/xuans/Smail/config"
|
"git.pcux.cn/xuans/Smail/config"
|
||||||
|
"git.pcux.cn/xuans/Smail/db"
|
||||||
"git.pcux.cn/xuans/Smail/signal"
|
"git.pcux.cn/xuans/Smail/signal"
|
||||||
"git.pcux.cn/xuans/Smail/utils/errors"
|
"git.pcux.cn/xuans/Smail/utils/errors"
|
||||||
"sync"
|
"sync"
|
||||||
@ -13,6 +14,9 @@ var finishMu sync.Mutex
|
|||||||
func Finish() error {
|
func Finish() error {
|
||||||
finishMu.Lock()
|
finishMu.Lock()
|
||||||
defer finishMu.Unlock()
|
defer finishMu.Unlock()
|
||||||
|
if err := db.ValidateSuperAdmin(db.Instance); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
cfg, err := config.ReadConfig()
|
cfg, err := config.ReadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err)
|
return errors.Wrap(err)
|
||||||
|
|||||||
@ -2,9 +2,13 @@ package setup
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.pcux.cn/xuans/Smail/config"
|
"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) {
|
func TestFinishPersistsServicePorts(t *testing.T) {
|
||||||
@ -120,6 +124,22 @@ func prepareFinishConfigTest(t *testing.T) {
|
|||||||
previousManager := config.CurrentManager()
|
previousManager := config.CurrentManager()
|
||||||
previousSetupPort := config.RuntimeSetupPort()
|
previousSetupPort := config.RuntimeSetupPort()
|
||||||
previousInitState := config.IsInit
|
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.ROOT_PATH = t.TempDir() + string(os.PathSeparator)
|
||||||
config.Instance = nil
|
config.Instance = nil
|
||||||
|
|||||||
@ -102,6 +102,11 @@
|
|||||||
max-width: 190px;
|
max-width: 190px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.log-filters .mail-scope {
|
||||||
|
flex: 0 1 145px;
|
||||||
|
min-width: 130px;
|
||||||
|
}
|
||||||
|
|
||||||
.log-filter-actions {
|
.log-filter-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -14,6 +14,9 @@ export function useLogs() {
|
|||||||
const offset = ref(0)
|
const offset = ref(0)
|
||||||
const hasMore = ref(false)
|
const hasMore = ref(false)
|
||||||
const actorId = ref('')
|
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 recentRange = () => {
|
||||||
const end = new Date()
|
const end = new Date()
|
||||||
return [new Date(end.getTime() - 24 * 60 * 60 * 1000), end]
|
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 actionLabel = value => ({ update: '保存配置', rollback: '回滚配置', migration: '迁移配置' }[value] || value || '未记录操作')
|
||||||
const displayObject = item => item.payload?.object || item.payload?.stage || '未记录对象'
|
const displayObject = item => item.payload?.object || item.payload?.stage || '未记录对象'
|
||||||
const displayOutcome = item => item.payload?.outcome || '历史记录未提供结果说明'
|
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 details = computed(() => detail.value?.payload || {})
|
||||||
const runtimeRows = computed(() => lines.value.map((line, index) => parseRuntimeLine(line, offset.value + index + 1)))
|
const runtimeRows = computed(() => lines.value.map((line, index) => parseRuntimeLine(line, offset.value + index + 1)))
|
||||||
const runtimeSummary = computed(() => summarizeRuntime(runtimeRows.value))
|
const runtimeSummary = computed(() => summarizeRuntime(runtimeRows.value))
|
||||||
@ -93,7 +96,8 @@ export function useLogs() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
appliedFilters = { kind: category.value, from: from.toISOString(), to: to.toISOString() }
|
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
|
offset.value = 0
|
||||||
previousOffsets.length = 0
|
previousOffsets.length = 0
|
||||||
detail.value = null
|
detail.value = null
|
||||||
@ -120,6 +124,7 @@ export function useLogs() {
|
|||||||
runtimeDetail.value = null
|
runtimeDetail.value = null
|
||||||
search()
|
search()
|
||||||
})
|
})
|
||||||
|
watch(mailScope, search)
|
||||||
onMounted(search)
|
onMounted(search)
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
++requestId
|
++requestId
|
||||||
@ -129,6 +134,7 @@ export function useLogs() {
|
|||||||
return {
|
return {
|
||||||
category, categories, loading, error, items, lines, offset,
|
category, categories, loading, error, items, lines, offset,
|
||||||
canFilterActor,
|
canFilterActor,
|
||||||
|
mailScope, canFilterSystemMail, systemMailSelected,
|
||||||
hasMore, actorId, dateRange, detail, details, isRuntime,
|
hasMore, actorId, dateRange, detail, details, isRuntime,
|
||||||
runtimeDetail, runtimeRows, runtimeSummary, visibleCount, pageRange,
|
runtimeDetail, runtimeRows, runtimeSummary, visibleCount, pageRange,
|
||||||
currentCategory, resultType, runtimeRowClass, timeParts,
|
currentCategory, resultType, runtimeRowClass, timeParts,
|
||||||
|
|||||||
@ -19,7 +19,11 @@
|
|||||||
format="YYYY-MM-DD HH:mm"
|
format="YYYY-MM-DD HH:mm"
|
||||||
aria-label="日志时间范围"
|
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">
|
<div class="log-filter-actions">
|
||||||
<el-button :disabled="loading" @click="recent">最近 24 小时</el-button>
|
<el-button :disabled="loading" @click="recent">最近 24 小时</el-button>
|
||||||
<el-button type="primary" :loading="loading" @click="search">查询</el-button>
|
<el-button type="primary" :loading="loading" @click="search">查询</el-button>
|
||||||
@ -174,6 +178,7 @@ import { useLogs } from './LogSettings.js'
|
|||||||
const {
|
const {
|
||||||
category, categories, loading, error, items, offset,
|
category, categories, loading, error, items, offset,
|
||||||
canFilterActor,
|
canFilterActor,
|
||||||
|
mailScope, canFilterSystemMail, systemMailSelected,
|
||||||
hasMore, actorId, dateRange, detail, details, isRuntime,
|
hasMore, actorId, dateRange, detail, details, isRuntime,
|
||||||
runtimeDetail, runtimeRows, runtimeSummary, pageRange,
|
runtimeDetail, runtimeRows, runtimeSummary, pageRange,
|
||||||
currentCategory, resultType, runtimeRowClass, timeParts,
|
currentCategory, resultType, runtimeRowClass, timeParts,
|
||||||
|
|||||||
@ -2,11 +2,41 @@ package logstore
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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) {
|
func TestLogScopeFiltersBeforePagination(t *testing.T) {
|
||||||
s := testStore(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 {
|
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 {
|
||||||
|
|||||||
@ -66,11 +66,14 @@ func TestLogsAPIRejectsUnscopedAccessAndValidatesQueries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, role := range []access.Role{access.RoleUser, access.RoleAdmin} {
|
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 {
|
if got := call(role, "kind=runtime&listFiles=true"); got.ErrorNo != response.NoAccessPrivileges {
|
||||||
t.Fatalf("低权限用户能读取全局文件列表:%s", role)
|
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 {
|
if got := call(access.RoleSuperAdmin, query); got.ErrorNo != response.ParamsError {
|
||||||
t.Fatalf("invalid query accepted: %s => %d", query, got.ErrorNo)
|
t.Fatalf("invalid query accepted: %s => %d", query, got.ErrorNo)
|
||||||
}
|
}
|
||||||
@ -122,4 +125,16 @@ func TestLogsAPIRejectsUnscopedAccessAndValidatesQueries(t *testing.T) {
|
|||||||
t.Fatalf("指定时段:%+v", history)
|
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("低权限账号读取到了系统邮件")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
132
test/backend/setup_super_admin_test.go
Normal file
132
test/backend/setup_super_admin_test.go
Normal 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("非法账号状态仍完成了初始化")
|
||||||
|
}
|
||||||
|
}
|
||||||
63
test/backend/super_admin_test.go
Normal file
63
test/backend/super_admin_test.go
Normal 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("检查意外修改了账号")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,6 +19,29 @@ function fixture(get, role = 'super_admin') {
|
|||||||
return { page, dispose: () => dispose() }
|
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 () => {
|
test('普通用户不发送操作者筛选,管理员保留范围内筛选', async () => {
|
||||||
for (const role of ['user', 'admin']) {
|
for (const role of ['user', 'admin']) {
|
||||||
const requests = []
|
const requests = []
|
||||||
|
|||||||
@ -49,6 +49,9 @@ if ((suite === 'all' || suite === 'backend') && result === 0) {
|
|||||||
const args = extra[0] === '--' ? extra.slice(1) : extra
|
const args = extra[0] === '--' ? extra.slice(1) : extra
|
||||||
// 独立回归源码集中放在 test/backend,通过 overlay 保留同包测试语义。
|
// 独立回归源码集中放在 test/backend,通过 overlay 保留同包测试语义。
|
||||||
const replacements = {
|
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/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/controllers/role_scope_test.go': 'test/backend/role_scope_test.go',
|
||||||
'backend/services/logstore/log_scope_test.go': 'test/backend/log_scope_test.go',
|
'backend/services/logstore/log_scope_test.go': 'test/backend/log_scope_test.go',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user