diff --git a/backend/controllers/logs.go b/backend/controllers/logs.go index 0923690..b4d3d36 100644 --- a/backend/controllers/logs.go +++ b/backend/controllers/logs.go @@ -2,6 +2,7 @@ package controllers import ( "context" + "errors" "net/http" "strconv" "time" @@ -12,14 +13,21 @@ import ( "git.pcux.cn/xuans/Smail/services/logstore" "git.pcux.cn/xuans/Smail/services/runtimelog" appcontext "git.pcux.cn/xuans/Smail/utils/context" + log "github.com/sirupsen/logrus" ) func logQueryError(w http.ResponseWriter, err error) { + if errors.Is(err, logstore.ErrInvalid) || errors.Is(err, runtimelog.ErrInvalid) { + writeConfigError(w, config.ErrInvalidAuditQuery) + return + } + log.WithError(err).Warn("日志查询失败") response.NewErrorResponse(response.ServerError, "日志查询失败,请检查日志存储状态", "LOG_QUERY_FAILED").FPrint(w) } // GetLogs 只读日志入口,授权在服务端检查,不依赖导航隐藏。 func GetLogs(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) { + w.Header().Set("Cache-Control", "no-store") if err := authorizeCapability(ctx, access.CapabilityAuditRead, "", false); err != nil { writeConfigAccessError(w, err) return @@ -42,7 +50,7 @@ func GetLogs(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) if values.Get("kind") == "runtime" { writer := runtimelog.Current() if writer == nil { - logQueryError(w, runtimelog.ErrInvalid) + logQueryError(w, logstore.ErrUnavailable) return } if values.Get("file") == "" { diff --git a/backend/services/access/access_test.go b/backend/services/access/access_test.go index 95ab6de..69dc15e 100644 --- a/backend/services/access/access_test.go +++ b/backend/services/access/access_test.go @@ -94,7 +94,7 @@ func TestAdminPluginRestartScope(t *testing.T) { func TestManagementEndpointCatalog(t *testing.T) { endpoints := ManagementEndpoints() - if len(endpoints) != 34 { + if len(endpoints) != 35 { t.Fatalf("unexpected endpoint count: %d", len(endpoints)) } seen := map[string]struct{}{} @@ -112,6 +112,10 @@ func TestManagementEndpointCatalog(t *testing.T) { if !ok || endpoint.Capability != CapabilityPluginWriteOwn { t.Fatal("plugin settings endpoint was not matched") } + endpoint, ok = LookupEndpoint("GET", "/api/v1/logs") + if !ok || endpoint.Capability != CapabilityAuditRead { + t.Fatal("log endpoint was not matched") + } endpoint, ok = LookupEndpoint("POST", "/api/v1/config/smtp/validate") if !ok || endpoint.Capability != CapabilityConfigWriteGlobal { t.Fatal("configuration validation endpoint was not matched") diff --git a/backend/services/access/catalog.go b/backend/services/access/catalog.go index 7f33ebd..f5c69ee 100644 --- a/backend/services/access/catalog.go +++ b/backend/services/access/catalog.go @@ -27,6 +27,7 @@ var managementEndpoints = []Endpoint{ {Method: "GET", Path: "/api/v1/config/:section", Capability: CapabilityConfigRead}, {Method: "PUT", Path: "/api/v1/config/:section", Capability: CapabilityConfigWriteGlobal}, {Method: "GET", Path: "/api/v1/audits/config", Capability: CapabilityAuditRead}, + {Method: "GET", Path: "/api/v1/logs", Capability: CapabilityAuditRead}, {Method: "GET", Path: "/api/v1/audits/config/:id", Capability: CapabilityAuditRead}, {Method: "POST", Path: "/api/v1/audits/config", Capability: CapabilityAuditExport}, {Method: "POST", Path: "/api/v1/audits/config/export", Capability: CapabilityAuditExport}, diff --git a/backend/services/runtimelog/archive.go b/backend/services/runtimelog/archive.go index b274e42..3f33a45 100644 --- a/backend/services/runtimelog/archive.go +++ b/backend/services/runtimelog/archive.go @@ -81,19 +81,24 @@ func (w *Writer) Maintain(ctx context.Context, now time.Time) error { if now.IsZero() { return ErrInvalid } - w.mu.Lock() - defer w.mu.Unlock() + w.archiveMu.Lock() + defer w.archiveMu.Unlock() if w.closed { return nil } - // 关闭当前文件后维护,避免 Windows 无法替换句柄;下一条日志会重新打开。 - if w.file != nil { + cutoff := logpolicy.Cutoff(now) + w.mu.Lock() + openDay, _ := time.Parse("2006-01-02", w.day) + // 仅关闭本轮要处理的历史句柄,当天写入不与历史文件扫描共用长时间锁。 + if w.file != nil && (!openDay.After(cutoff) || !openDay.Add(24*time.Hour).After(now.Add(-logpolicy.CompressionAge))) { err := errors.Join(w.file.Sync(), w.file.Close()) w.file = nil if err != nil { + w.mu.Unlock() return err } } + w.mu.Unlock() directory, err := w.root.Open(".") if err != nil { return err @@ -103,7 +108,6 @@ func (w *Writer) Maintain(ctx context.Context, now time.Time) error { if err != nil || closeErr != nil { return errors.Join(err, closeErr) } - cutoff := logpolicy.Cutoff(now) var failures []error for _, entry := range entries { if err := ctx.Err(); err != nil { diff --git a/backend/services/runtimelog/query.go b/backend/services/runtimelog/query.go index c06bbc3..22b15c8 100644 --- a/backend/services/runtimelog/query.go +++ b/backend/services/runtimelog/query.go @@ -22,8 +22,8 @@ type Page struct { } func (w *Writer) Files() ([]File, error) { - w.mu.Lock() - defer w.mu.Unlock() + w.archiveMu.Lock() + defer w.archiveMu.Unlock() if w.closed { return nil, ErrInvalid } @@ -57,8 +57,8 @@ func (w *Writer) Files() ([]File, error) { // Read 仅允许固定名称的受管日志,不接受任意路径;压缩内容在服务端流式读取。 func (w *Writer) Read(ctx context.Context, name string, offset, limit int) (Page, error) { - w.mu.Lock() - defer w.mu.Unlock() + w.archiveMu.Lock() + defer w.archiveMu.Unlock() if _, ok := fileDay(name); !ok || offset < 0 || offset > 1000000 || limit < 1 || limit > 500 || w.closed { return Page{}, ErrInvalid } diff --git a/backend/services/runtimelog/writer.go b/backend/services/runtimelog/writer.go index 766206c..b602027 100644 --- a/backend/services/runtimelog/writer.go +++ b/backend/services/runtimelog/writer.go @@ -24,14 +24,15 @@ var ErrInvalid = errors.New("运行日志文件或查询参数无效") var active atomic.Pointer[Writer] type Writer struct { - mu sync.Mutex - root *os.Root - file *os.File - day string - now func() time.Time - cancel context.CancelFunc - done chan struct{} - closed bool + archiveMu sync.Mutex + mu sync.Mutex + root *os.Root + file *os.File + day string + now func() time.Time + cancel context.CancelFunc + done chan struct{} + closed bool } func Current() *Writer { return active.Load() } @@ -95,11 +96,6 @@ func (w *Writer) Fire(entry *log.Entry) error { } func (w *Writer) WriteAt(at time.Time, message string) error { - w.mu.Lock() - defer w.mu.Unlock() - if w.closed { - return nil - } if at.IsZero() { return ErrInvalid } @@ -111,6 +107,18 @@ func (w *Writer) WriteAt(at time.Time, message string) error { message = string([]rune(message)[:min(len([]rune(message)), (maxLineBytes-128)/4)]) + "…" } day := at.UTC().Format("2006-01-02") + w.mu.Lock() + // 当天句柄已打开时直接追加,不等待历史归档的读取或压缩。 + if !w.closed && (w.file == nil || w.day != day) { + w.mu.Unlock() + w.archiveMu.Lock() + defer w.archiveMu.Unlock() + w.mu.Lock() + } + defer w.mu.Unlock() + if w.closed { + return nil + } if w.file == nil || w.day != day { if w.file != nil { if err := errors.Join(w.file.Sync(), w.file.Close()); err != nil { @@ -158,6 +166,8 @@ func (w *Writer) Close() error { w.cancel() <-w.done } + w.archiveMu.Lock() + defer w.archiveMu.Unlock() w.mu.Lock() defer w.mu.Unlock() if w.closed { diff --git a/deploy/start-dev.ps1 b/deploy/start-dev.ps1 index f8a2a52..e2ac9b1 100644 --- a/deploy/start-dev.ps1 +++ b/deploy/start-dev.ps1 @@ -6,7 +6,8 @@ $ErrorActionPreference = 'Stop' $root = Split-Path -Parent $PSScriptRoot $runtime = Join-Path $root 'yunx-test' -$logs = Join-Path $runtime 'log' +$logs = Join-Path $root 'test-log\service-launch' +# 控制台与热更新输出属于模拟部署测试记录,业务运行日志由程序按天管理。 New-Item -ItemType Directory -Path $logs -Force | Out-Null $stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff' $previousRoot = $env:SMAIL_ROOT @@ -78,7 +79,7 @@ try { Start-Sleep -Seconds 3 $process.Refresh() if ($process.HasExited) { throw "服务启动失败;标准输出:$stdout;错误日志:$stderr" } - Write-Output "$Service 已启动,进程编号:$($process.Id);日志目录:yunx-test/log" + Write-Output "$Service 已启动,进程编号:$($process.Id);运行日志:yunx-test/log;启动诊断:test-log/service-launch" Write-Output "运行文件:$binary;标准输出:$stdout;错误日志:$stderr" } finally { $env:SMAIL_ROOT = $previousRoot diff --git a/deploy/start-production.ps1 b/deploy/start-production.ps1 index d8c58de..06fdd84 100644 --- a/deploy/start-production.ps1 +++ b/deploy/start-production.ps1 @@ -23,12 +23,14 @@ foreach ($name in @('SMAIL_ROOT', 'SMAIL_HTTP_PORT_OVERRIDE', 'SMAIL_HTTP_TLS_MO try { $env:SMAIL_ROOT = $runtime $stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff' + $logs = Join-Path $root 'test-log\service-launch' + New-Item -ItemType Directory -Path $logs -Force | Out-Null $process = Start-Process -FilePath $binary -WorkingDirectory $runtime -WindowStyle Hidden -PassThru ` - -RedirectStandardOutput (Join-Path $runtime "log\production-$stamp.stdout.log") ` - -RedirectStandardError (Join-Path $runtime "log\production-$stamp.stderr.log") + -RedirectStandardOutput (Join-Path $logs "production-$stamp.stdout.log") ` + -RedirectStandardError (Join-Path $logs "production-$stamp.stderr.log") Start-Sleep -Seconds 3 $process.Refresh() - if ($process.HasExited) { throw '生产模拟启动失败,请检查 yunx-test/log;不会自动豁免管理员恢复通道保护' } + if ($process.HasExited) { throw '生产模拟启动失败,请检查 test-log/service-launch;不会自动豁免管理员恢复通道保护' } Write-Output "生产模拟已启动:$($process.Id),使用嵌入前端和 yunx-test 配置,管理员恢复通道保持保护。" } finally { foreach ($name in $previous.Keys) { [Environment]::SetEnvironmentVariable($name, $previous[$name], 'Process') } diff --git a/frontend/src/views/components/HomeHeader/HomeHeader.js b/frontend/src/views/components/HomeHeader/HomeHeader.js index 703fe61..df97ff1 100644 --- a/frontend/src/views/components/HomeHeader/HomeHeader.js +++ b/frontend/src/views/components/HomeHeader/HomeHeader.js @@ -55,8 +55,12 @@ export function usePage() { { key: 'users', label: lang.user_management, hint: '账号与权限管理', icon: User, component: UserManagement, capability: 'user.manage' }, { key: 'plugins', label: lang.plugin_settings, hint: '扩展功能管理', icon: Grid, component: PluginSettings, capability: 'plugin.read' }, { key: 'logs', label: '日志', hint: '操作、收发信与运行记录', icon: Document, component: LogSettings, capability: 'audit.read' }, - ].filter(item => globalStatus.hasCapability(item.capability) || - (item.key === 'plugins' && globalStatus.hasCapability('config.read')))) + ].filter(item => { + // 全局日志包含其他账号与运行信息,不能把本人审计能力当作全局权限。 + if (item.key === 'logs' && globalStatus.role !== 'super_admin') return false + return globalStatus.hasCapability(item.capability) || + (item.key === 'plugins' && globalStatus.hasCapability('config.read')) + })) const activeSettings = computed(() => settingsMenu.value.find(item => item.key === globalStatus.settingsActiveTab)) const settingsPages = new Map() diff --git a/frontend/src/views/components/LogSettings/LogSettings.js b/frontend/src/views/components/LogSettings/LogSettings.js index 975c0bf..dd1eef8 100644 --- a/frontend/src/views/components/LogSettings/LogSettings.js +++ b/frontend/src/views/components/LogSettings/LogSettings.js @@ -17,6 +17,7 @@ export function useLogs() { const pageSize = 50 let requestId = 0 let aborter + const previousOffsets = [] const categories = [ { label: '配置审计', value: 'config' }, @@ -28,13 +29,13 @@ export function useLogs() { const formatTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' const resultLabel = value => ({ success: '成功', failure: '失败', unknown: '结果未知', accepted: '已接收' }[value] || value) const actionLabel = value => ({ update: '保存配置', rollback: '回滚配置', migration: '迁移配置' }[value] || value) - const displayActor = item => item.payload?.actorName || item.actorId || '系统' + const displayActor = item => item.payload?.actorName || (item.actorId && item.actorId !== '0' ? item.actorId : '系统') const details = computed(() => detail.value?.payload || {}) const changes = computed(() => details.value.changes || []) function requireData(result) { - if (result?.errorNo) throw new Error(result.errorMsg || '日志查询失败') - return result?.data || {} + if (result?.errorNo !== 0 || !result.data) throw new Error(result?.errorMsg || '日志查询失败') + return result.data } async function load() { @@ -53,6 +54,9 @@ export function useLogs() { files.value = listing.files || [] if (!files.value.some(file => file.name === selectedFile.value)) { selectedFile.value = files.value[0]?.name || '' + offset.value = 0 + params.offset = 0 + previousOffsets.length = 0 } if (!selectedFile.value) { lines.value = [] @@ -86,15 +90,19 @@ export function useLogs() { function search() { offset.value = 0 - load() + previousOffsets.length = 0 + return load() } function next() { + if (loading.value || !hasMore.value) return + previousOffsets.push(offset.value) offset.value += isRuntime.value ? lines.value.length : pageSize - load() + return load() } function previous() { - offset.value = Math.max(0, offset.value - pageSize) - load() + if (loading.value || offset.value === 0) return + offset.value = previousOffsets.pop() ?? 0 + return load() } watch(category, () => { detail.value = null diff --git a/test/backend/logs_api_test.go b/test/backend/logs_api_test.go new file mode 100644 index 0000000..c325fe5 --- /dev/null +++ b/test/backend/logs_api_test.go @@ -0,0 +1,77 @@ +package controllers + +import ( + "context" + "database/sql" + "encoding/json" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "git.pcux.cn/xuans/Smail/db/migration" + "git.pcux.cn/xuans/Smail/dto/response" + "git.pcux.cn/xuans/Smail/services/access" + "git.pcux.cn/xuans/Smail/services/logstore" + appcontext "git.pcux.cn/xuans/Smail/utils/context" + _ "modernc.org/sqlite" +) + +func TestLogsAPIRejectsUnscopedAccessAndValidatesQueries(t *testing.T) { + database, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "logs.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + // 与正式 SQLite 连接池一致,避免测试自行引入多写连接锁冲突。 + database.SetMaxOpenConns(1) + items := migration.DefaultMigrations() + for _, statement := range items[len(items)-1].StatementsFor(migration.DialectSQLite) { + if _, err := database.Exec(statement); err != nil { + t.Fatal(err) + } + } + store, err := logstore.New(database, migration.DialectSQLite) + if err != nil { + t.Fatal(err) + } + runtime := logstore.Start(store) + defer runtime.Stop() + if err := store.Write(context.Background(), logstore.Entry{Kind: "operation", CreatedAt: time.Now(), ActorID: "42", Action: "probe", Result: "success", Payload: []byte(`{}`)}); err != nil { + t.Fatal(err) + } + call := func(role access.Role, query string) response.Response { + t.Helper() + request := httptest.NewRequest("GET", "/api/v1/logs?"+query, nil) + recorder := httptest.NewRecorder() + GetLogs(&appcontext.Context{Role: role, UserID: 42}, recorder, request) + if recorder.Header().Get("Cache-Control") != "no-store" { + t.Fatal("sensitive log response can be cached") + } + var result response.Response + if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + return result + } + for _, role := range []access.Role{access.RoleUser, access.RoleAdmin} { + for _, kind := range []string{"operation", "config", "mail", "runtime"} { + if got := call(role, "kind="+kind+"&actorId=42"); got.ErrorNo != response.NoAccessPrivileges { + t.Fatalf("unscoped role %s accessed %s: %d", role, kind, got.ErrorNo) + } + } + } + for _, query := range []string{"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) + } + } + result := call(access.RoleSuperAdmin, "kind=operation&actorId=42&limit=1") + if result.ErrorNo != 0 { + t.Fatalf("administrator query failed: %d", result.ErrorNo) + } + data := result.Data.(map[string]any) + if len(data["items"].([]any)) != 1 || data["retentionDays"] != float64(30) { + t.Fatal("log query lost records or retention policy") + } +} diff --git a/test/backend/runtime_logs_test.go b/test/backend/runtime_logs_test.go index 7c0e4a4..41bd0d4 100644 --- a/test/backend/runtime_logs_test.go +++ b/test/backend/runtime_logs_test.go @@ -93,3 +93,29 @@ func TestRuntimeLogCorruptionAndUnknownFilesArePreserved(t *testing.T) { } } } + +func TestRuntimeLogCurrentWritesDoNotWaitForArchiveRead(t *testing.T) { + w, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer w.Close() + now := time.Now() + if err := w.WriteAt(now, "first"); err != nil { + t.Fatal(err) + } + w.archiveMu.Lock() + done := make(chan error, 1) + go func() { done <- w.WriteAt(now, "second") }() + select { + case err := <-done: + w.archiveMu.Unlock() + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + w.archiveMu.Unlock() + <-done + t.Fatal("current log write waited for archive lock") + } +} diff --git a/test/frontend/logs.test.mjs b/test/frontend/logs.test.mjs new file mode 100644 index 0000000..8ca1651 --- /dev/null +++ b/test/frontend/logs.test.mjs @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import { compileFunction } from 'node:vm' +import { createRequire } from 'node:module' +import { computed, ref } from './vue.mjs' + +const root = new URL('../../frontend/src/views/components/LogSettings/', import.meta.url) +const read = name => readFileSync(new URL(name, root), 'utf8') +const create = compileFunction( + read('LogSettings.js').replace(/^import .*$/gm, '').replace('export function', 'function') + '\nreturn useLogs()', + ['computed', 'onBeforeUnmount', 'onMounted', 'ref', 'watch', 'http'] +) + +function fixture(get) { + let dispose + const page = create(computed, callback => { dispose = callback }, () => {}, ref, () => {}, { get }) + return { page, dispose: () => dispose() } +} + +test('日志页面模板和样式能编译,详情不使用原始 HTML 渲染', () => { + const require = createRequire(new URL('../../frontend/package.json', import.meta.url)) + const { parse, compileScript, compileStyle } = require('@vue/compiler-sfc') + const { descriptor, errors } = parse(read('LogSettings.vue')) + assert.deepEqual(errors, []) + assert.doesNotThrow(() => compileScript(descriptor, { id: 'logs', inlineTemplate: true })) + assert.deepEqual(compileStyle({ source: read('LogSettings.css'), id: 'logs', scoped: true }).errors, []) + assert.doesNotMatch(read('LogSettings.vue'), /v-html/) +}) + +test('日志查询传递时间与操作者筛选,并将业务失败显示为错误', async () => { + const requests = [] + const { page } = fixture(async (_, options) => { + requests.push(options.params) + return requests.length === 1 + ? { errorNo: 0, data: { items: [{ id: 'safe' }], hasMore: false } } + : { errorNo: 500, errorMsg: '日志暂不可用' } + }) + page.actorId.value = ' 42 ' + page.dateRange.value = ['2026-09-01T00:00:00Z', '2026-09-08T00:00:00Z'] + await page.search() + assert.equal(requests[0].actorId, '42') + assert.equal(requests[0].from, '2026-09-01T00:00:00.000Z') + assert.equal(page.items.value.length, 1) + await page.search() + assert.equal(page.error.value, '日志暂不可用') + assert.equal(page.items.value.length, 0) + assert.equal(page.displayActor({ actorId: '0' }), '系统') +}) + +test('压缩日志短页正确返回上一页,文件过期后重置偏移', async () => { + let filename = 'runtime-2026-08-15.log.gz' + const offsets = [] + const { page } = fixture(async (_, { params }) => { + if (!params.file) return { errorNo: 0, data: { files: [{ name: filename, compressed: true }] } } + offsets.push(params.offset) + return { errorNo: 0, data: { lines: Array(params.offset === 0 ? 8 : 12).fill('日志'), hasMore: true } } + }) + page.category.value = 'runtime' + await page.search() + await page.next() + await page.next() + await page.previous() + assert.deepEqual(offsets, [0, 8, 20, 8]) + filename = 'runtime-2026-08-16.log.gz' + await page.next() + assert.equal(page.offset.value, 0) + assert.equal(offsets.at(-1), 0) +}) + +test('慢响应及卸载后的响应不得覆盖最新日志', async () => { + let complete + let count = 0 + const { page, dispose } = fixture(async () => { + if (++count === 1) return new Promise(resolve => { complete = resolve }) + return { errorNo: 0, data: { items: [{ id: 'new' }] } } + }) + const first = page.search() + await page.search() + complete({ errorNo: 0, data: { items: [{ id: 'old' }] } }) + await first + assert.equal(page.items.value[0].id, 'new') + const last = page.search() + dispose() + await last + assert.equal(page.items.value[0].id, 'new') +}) diff --git a/test/frontend/output-paths.test.mjs b/test/frontend/output-paths.test.mjs index 8a76e43..3b86137 100644 --- a/test/frontend/output-paths.test.mjs +++ b/test/frontend/output-paths.test.mjs @@ -48,7 +48,7 @@ test('开发启动必须固定运行配置根目录并集中保存日志', async assert.match(content, /\$workingDirectory = \$runtime/) for (const name of ['spam_block.exe', 'pmail_telegram_push.exe', 'wechat_push.exe']) assert.ok(content.includes(name)) assert.match(content, /Join-Path \$runtime 'smail\.exe'/) - assert.match(content, /Join-Path \$runtime 'log'/) + assert.match(content, /Join-Path \$root 'test-log\\service-launch'/) assert.match(content, /-WindowStyle Hidden/) assert.doesNotMatch(content, /SkipBuild/) assert.match(content, /--dev-disable-admin-reset/) diff --git a/test/frontend/settings-menu.test.mjs b/test/frontend/settings-menu.test.mjs index 3f9f207..c9e2e68 100644 --- a/test/frontend/settings-menu.test.mjs +++ b/test/frontend/settings-menu.test.mjs @@ -35,7 +35,7 @@ test('二级菜单只发送可见且尚未选中的选项,不提前修改父 test('一级菜单保留能力过滤和当前分类标题联动', () => { const source = read('HomeHeader/HomeHeader.js') const names = [ - 'EpMenu', 'Lock', 'Connection', 'Setting', 'FolderOpened', 'Filter', 'User', 'Grid', + 'EpMenu', 'Lock', 'Connection', 'Setting', 'FolderOpened', 'Filter', 'User', 'Grid', 'Document', 'SettingsPageStatus', 'BrandLogo' ] const createPage = compileFunction( @@ -64,6 +64,13 @@ test('一级菜单保留能力过滤和当前分类标题联动', () => { assert.equal(page.settingsMenu.value.some(item => item.key === 'plugins'), false) capabilities.add('plugin.read') assert.equal(page.settingsMenu.value.some(item => item.key === 'plugins'), true) + capabilities.add('audit.read') + for (const role of ['user', 'admin']) { + store.role = role + assert.equal(page.settingsMenu.value.some(item => item.key === 'logs'), false) + } + store.role = 'super_admin' + assert.equal(page.settingsMenu.value.at(-1).key, 'logs') }) test('三级菜单只发送当前分组的可见子项,选中状态仍由父页面确认', () => { diff --git a/test/run.mjs b/test/run.mjs index 7b1a7b7..db034b1 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -49,6 +49,7 @@ if ((suite === 'all' || suite === 'backend') && result === 0) { const args = extra[0] === '--' ? extra.slice(1) : extra // 独立回归源码集中放在 test/backend,通过 overlay 保留同包测试语义。 const replacements = { + 'backend/controllers/logs_api_test.go': 'test/backend/logs_api_test.go', 'backend/services/logstore/log_store_test.go': 'test/backend/log_store_test.go', 'backend/services/runtimelog/runtime_logs_test.go': 'test/backend/runtime_logs_test.go', 'backend/db/migration/log_migration_test.go': 'test/backend/log_migration_test.go',