diff --git a/e2e/README.md b/e2e/README.md index 789bce6..02256dc 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -2,13 +2,14 @@ ## 现有用例 -| 文件 | 说明 | 默认是否执行 | -| --------------------------- | ------------------------------------------------------------------------ | ----------------- | -| `smoke.spec.ts` | A0 冒烟:登录、模块切换、403 / 404 | 是 | -| `a1-identity.spec.ts` | A1-QA-01 账号生命周期(①–⑥) | 否,需 `A1_E2E=1` | -| `a2-roles.spec.ts` | A2-QA-01 角色管理 / 权限三态 / SoD / 即时生效 | 否,需 `A2_E2E=1` | -| `a3-client.spec.ts` | A3-QA-01 客户与行业六页可访问 / 数据加载 | 否,需 `A3_E2E=1` | -| `a4-dicts-settings.spec.ts` | A4-QA-01 数据字典 / 三层合并 / 编码只读 / 系统配置 / 阈值确认 / 变更记录 | 否,需 `A4_E2E=1` | +| 文件 | 说明 | 默认是否执行 | +| --------------------------- | ------------------------------------------------------------------------- | ----------------- | +| `smoke.spec.ts` | A0 冒烟:登录、模块切换、403 / 404 | 是 | +| `a1-identity.spec.ts` | A1-QA-01 账号生命周期(①–⑥) | 否,需 `A1_E2E=1` | +| `a2-roles.spec.ts` | A2-QA-01 角色管理 / 权限三态 / SoD / 即时生效 | 否,需 `A2_E2E=1` | +| `a3-client.spec.ts` | A3-QA-01 客户与行业六页可访问 / 数据加载 | 否,需 `A3_E2E=1` | +| `a4-dicts-settings.spec.ts` | A4-QA-01 数据字典 / 三层合并 / 编码只读 / 系统配置 / 阈值确认 / 变更记录 | 否,需 `A4_E2E=1` | +| `a5-audit-logs.spec.ts` | A5-QA-01 操作日志列表 / 筛选 / 详情 diff / 导出闭环 / 权限三态 / 运行日志 | 否,需 `A5_E2E=1` | ## A1 账号生命周期用例 @@ -109,3 +110,27 @@ A4_E2E=1 pnpm exec playwright test e2e/a4-dicts-settings.spec.ts --reporter=list - Semi Select 选项可访问名含图标 aria-label(tick),精确匹配不到,使用 `.semi-select-option` 文本点击。 - `Modal.confirm` 的确认 / 取消按钮 `aria-label` 固定为 `confirm` / `cancel`,不要按 `okText` 文本定位。 - `RadioGroup` 隐藏 input 被 addon 拦截,点可见文本或 `.semi-radio-addon-buttonRadio`。 + +## A5 审计与日志用例(A5-QA-01) + +覆盖 5 条: + +1. `/system/audit-logs` 列表可见:h1「操作日志」、副标题、查看留痕提示条(`audit.log.view`)、表格有数据、导出按钮(超管)。 +2. 筛选:动作分类「权限与角色」→ URL `?category=permission` 且结果变化;「重置」恢复默认。 +3. 详情:点击「修改角色权限」行 → `/system/audit-logs/{id}`;变更对比含「权限点」;元信息含 trace_id / IP / 请求 / 耗时;切换「原始 JSON」可看到完整记录(occurred_at / actor / action / target / before / after);只读说明存在。 +4. 导出闭环:打开 560 抽屉 → 创建导出任务 → 轮询到「导出完成」→ 导出历史出现「已完成」任务 → API 断言 `download_token` 存在且签名下载返回 200 CSV → 关闭抽屉并重置筛选后,列表出现 `audit.export` 记录(导出自身留痕)。 +5. 权限三态:无 `audit:read` 账号访问 `/system/audit-logs` → `/403`;仅有 `audit:read`(自定义角色,无 `audit:export`)→ 页面显示「只读 · 无 audit:export 权限」且不渲染导出按钮。 +6. 运行日志:`audit.logs_platform_url` 置空 → 空态 + 「前往系统配置」;写入示例地址后刷新 → 外链与平台入口卡存在;结束后还原配置。 + +### 前置与运行 + +1. API 3000 + Web 5173(`pnpm dev`,真实 PG;已执行 `pnpm db:init`)。 +2. 用例自建临时账号 `qa5.audit.e2e`(超管)、`qa5.audit.viewer`(仅 `audit:read`)、`qa5.audit.ro`(素材运营,无 `audit:read`)与自定义角色 `a5e2e_audit_viewer`;结束后删除账号 / 角色并还原 `audit.logs_platform_url`。 +3. ⚠️ 审计表 append-only:用例产生的登录 / 查看 / 导出 / 下载审计记录**不删除**,属预期留痕。 + +```bash +A5_E2E=1 pnpm exec playwright test e2e/a5-audit-logs.spec.ts --reporter=list +``` + +- 可选覆盖:`A5_E2E_BASE_URL`(缺省 `http://127.0.0.1:5173`)、`A5_E2E_API_BASE_URL`(缺省 `http://127.0.0.1:3000/api/v1`)。 +- 用例不点击外网链接;运行日志只断言外链 href 存在。 diff --git a/e2e/a5-audit-logs.spec.ts b/e2e/a5-audit-logs.spec.ts new file mode 100644 index 0000000..7dfbc93 --- /dev/null +++ b/e2e/a5-audit-logs.spec.ts @@ -0,0 +1,484 @@ +/// +/** + * A5-QA-01 · 审计与日志端到端用例(真实 API + 真实 PG + 真实浏览器) + * + * 覆盖: + * ① 超管登录 → /system/audit-logs 列表可见(查看留痕提示条 / 日志列表); + * ② 筛选:动作分类「权限与角色」+ 近 7 天 → 结果变化;重置恢复; + * ③ 打开详情:变更对比 / 原始 JSON / trace_id / IP / 请求信息; + * ④ 导出:创建任务 → 轮询完成 → 导出历史可下载(API 断言 download_token 存在); + * ⑤ 列表新增 audit.export 记录(导出本身留痕); + * ⑥ 无 audit:read 账号访问 → /403;有 audit:read 无 audit:export → 不渲染导出按钮; + * ⑦ 运行日志:配置为空 → 空态;写入日志平台地址后 → 外链存在,结束后还原配置。 + * + * 门控:仅 A5_E2E=1 时执行;默认 skip,不影响 smoke / CI。 + * 前置:API 3000、Web 5173(`pnpm dev`,真实 PG + Redis,已 `pnpm db:init`)。 + * 数据自建自清:临时账号 qa5.audit.e2e(超管)/ qa5.audit.ro(无 audit:read)/ + * qa5.audit.viewer(仅 audit:read),自定义角色 A5E2E-审计只读,用例结束删除并还原系统配置。 + * ⚠️ 审计表 append-only,用例产生的审计记录(登录 / 查看 / 导出 / 下载)不删除,属预期留痕。 + * + * 运行:A5_E2E=1 pnpm exec playwright test e2e/a5-audit-logs.spec.ts --reporter=list + */ +import { randomBytes } from 'node:crypto'; + +import { type APIRequestContext, expect, type Page, request, test } from '@playwright/test'; +import { PrismaClient } from '@prisma/client'; + +const API_BASE = process.env.A5_E2E_API_BASE_URL ?? 'http://127.0.0.1:3000/api/v1'; +const WEB_BASE = process.env.A5_E2E_BASE_URL ?? 'http://127.0.0.1:5173'; +const ENABLED = process.env.A5_E2E === '1'; + +const PASSWORD = 'lingxi2026'; +/** 与 a1 / a3 / a4 共用(明文 lingxi2026) */ +const PASSWORD_HASH = + '$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4'; + +const ADMIN_ACCOUNT = 'qa5.audit.e2e'; +const NO_AUDIT_ACCOUNT = 'qa5.audit.ro'; +const VIEWER_ACCOUNT = 'qa5.audit.viewer'; +const VIEWER_ROLE_CODE = 'a5e2e_audit_viewer'; +const VIEWER_ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZA5E2'; +const PLATFORM_URL_KEY = 'audit.logs_platform_url'; +const PLATFORM_URL_SAMPLE = 'https://logs.lingxi.example/grafana'; + +const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; +const prisma = new PrismaClient(); + +let apiContext: APIRequestContext | null = null; +let adminSession: QaSession | null = null; +let viewerSession: QaSession | null = null; +let noAuditSession: QaSession | null = null; +let bootstrapError: string | null = null; +let platformUrlOriginalValue = ''; + +interface QaSession { + accessToken: string; + refreshToken: string; + user: { id: string; name: string; account: string }; + permissions: string[]; + mustChangePassword: boolean; +} + +test.skip(!ENABLED, 'A5_E2E=1 才运行审计与日志 e2e(依赖 API :3000 + web :5173 + PG)'); + +test.use({ baseURL: WEB_BASE, viewport: { width: 1440, height: 900 } }); + +// --------------------------------------------------------------------------- +// 基础工具 +// --------------------------------------------------------------------------- + +function ulid(): string { + let time = Date.now(); + const timeChars = new Array(10); + for (let index = 9; index >= 0; index -= 1) { + timeChars[index] = ENCODING[time % 32] ?? '0'; + time = Math.floor(time / 32); + } + const random = randomBytes(16); + return `${timeChars.join('')}${Array.from(random, (byte) => ENCODING[byte & 31] ?? '0').join('')}`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function requireRecord(value: unknown, label: string): Record { + if (!isRecord(value)) { + throw new Error(`${label} 不是 JSON 对象`); + } + return value; +} + +function requireString(source: Record, key: string, label: string): string { + const value = source[key]; + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${label}.${key} 缺失或不是字符串`); + } + return value; +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; +} + +function requireApi(): APIRequestContext { + if (apiContext === null) throw new Error('APIRequestContext 未初始化'); + return apiContext; +} + +function requireAdminSession(): QaSession { + if (adminSession === null) throw new Error('超管会话未就绪'); + return adminSession; +} + +function requireViewerSession(): QaSession { + if (viewerSession === null) throw new Error('审计只读会话未就绪'); + return viewerSession; +} + +function requireNoAuditSession(): QaSession { + if (noAuditSession === null) throw new Error('无审计权限会话未就绪'); + return noAuditSession; +} + +async function injectSession(page: Page, session: QaSession): Promise { + const persisted = { + accessToken: session.accessToken, + refreshToken: session.refreshToken, + user: session.user, + permissions: session.permissions, + mustChangePassword: session.mustChangePassword, + }; + await page.addInitScript((value: unknown) => { + window.localStorage.setItem('lingxi.auth.session', JSON.stringify(value)); + }, persisted); +} + +async function loginViaApi(account: string): Promise { + const context = requireApi(); + const login = await context.post(`${API_BASE}/auth/login`, { + data: { account, password: PASSWORD }, + }); + expect(login.status(), `POST /auth/login ${account}`).toBe(200); + const body = requireRecord((await login.json()) as unknown, '登录响应'); + const accessToken = requireString(body, 'accessToken', '登录响应'); + const refreshToken = requireString(body, 'refreshToken', '登录响应'); + const meResponse = await context.get(`${API_BASE}/me`, { + headers: { authorization: `Bearer ${accessToken}` }, + }); + expect(meResponse.status(), `GET /me ${account}`).toBe(200); + const me = requireRecord((await meResponse.json()) as unknown, '/me 响应'); + const user = requireRecord(me.user, '/me.user'); + return { + accessToken, + refreshToken, + user: { + id: requireString(user, 'id', '/me.user'), + name: requireString(user, 'name', '/me.user'), + account: requireString(user, 'account', '/me.user'), + }, + permissions: stringArray(me.permissions), + mustChangePassword: me.mustChangePassword === true, + }; +} + +async function createUser( + account: string, + name: string, + roleCodes: readonly string[], +): Promise { + const [department, roles] = await Promise.all([ + prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } }), + prisma.role.findMany({ where: { code: { in: [...roleCodes] } }, select: { id: true } }), + ]); + await prisma.user.deleteMany({ where: { account } }); + await prisma.user.create({ + data: { + id: ulid(), + name, + account, + email: `${account}@lingxi.cn`, + phone: `138${randomBytes(4).readUInt32BE(0).toString().padStart(8, '0').slice(-8)}`, + passwordHash: PASSWORD_HASH, + departmentId: department.id, + mustChangePassword: false, + roles: { create: roles.map((role) => ({ roleId: role.id })) }, + }, + }); +} + +/** 自定义「仅 audit:read」角色:验证无 audit:export 时不渲染导出入口 */ +async function createAuditViewerRole(): Promise { + await prisma.role.deleteMany({ where: { code: VIEWER_ROLE_CODE } }); + await prisma.role.create({ + data: { + id: VIEWER_ROLE_ID, + code: VIEWER_ROLE_CODE, + name: 'A5 端到端审计只读', + description: 'e2e 临时角色:仅审计查看权限', + isSystem: false, + status: 'ACTIVE', + permissions: { create: [{ permissionCode: 'audit:read' }] }, + dataScopes: { create: [{ id: ulid(), scopeType: 'all' }] }, + }, + }); +} + +async function preparePlatformUrl(): Promise { + const row = await prisma.systemConfig.findUnique({ where: { configKey: PLATFORM_URL_KEY } }); + platformUrlOriginalValue = typeof row?.configValue === 'string' ? row.configValue : ''; +} + +async function setPlatformUrl(value: string): Promise { + await prisma.systemConfig.update({ + where: { configKey: PLATFORM_URL_KEY }, + data: { configValue: value }, + }); +} + +async function restorePlatformUrl(): Promise { + await prisma.systemConfig.update({ + where: { configKey: PLATFORM_URL_KEY }, + data: { configValue: platformUrlOriginalValue }, + }); +} + +async function gotoWithSession(page: Page, session: QaSession, path: string): Promise { + await injectSession(page, session); + await page.goto(path); +} + +async function pickSelectOption( + page: Page, + triggerText: string, + optionName: string, +): Promise { + await page.locator('.lx-audit-filters .semi-select', { hasText: triggerText }).first().click(); + const option = page.locator('.semi-select-option').filter({ hasText: optionName }).first(); + await expect(option).toBeVisible({ timeout: 10_000 }); + await option.click(); +} + +async function waitToast(page: Page, text: string | RegExp): Promise { + await expect(page.locator('[role="alert"]', { hasText: text }).first()).toBeVisible({ + timeout: 15_000, + }); +} + +// --------------------------------------------------------------------------- +// 全局编排 +// --------------------------------------------------------------------------- + +test.beforeAll(async () => { + if (!ENABLED) return; + apiContext = await request.newContext(); + try { + const web = await apiContext.get(`${WEB_BASE}/`); + expect(web.status(), `web 未就绪:请在 ${WEB_BASE} 启动前端`).toBe(200); + await preparePlatformUrl(); + await createAuditViewerRole(); + await createUser(ADMIN_ACCOUNT, 'A5 端到端超管', ['super_admin']); + await createUser(VIEWER_ACCOUNT, 'A5 端到端审计只读', [VIEWER_ROLE_CODE]); + await createUser(NO_AUDIT_ACCOUNT, 'A5 端到端无权限', ['material_operator']); + adminSession = await loginViaApi(ADMIN_ACCOUNT); + viewerSession = await loginViaApi(VIEWER_ACCOUNT); + noAuditSession = await loginViaApi(NO_AUDIT_ACCOUNT); + expect(adminSession.permissions, '超管应含 audit:read').toContain('audit:read'); + expect(adminSession.permissions, '超管应含 audit:export').toContain('audit:export'); + expect(viewerSession.permissions, '只读账号应含 audit:read').toContain('audit:read'); + expect(viewerSession.permissions, '只读账号不应含 audit:export').not.toContain('audit:export'); + expect(noAuditSession.permissions, '素材运营不应含 audit:read').not.toContain('audit:read'); + } catch (error) { + bootstrapError = error instanceof Error ? error.message : String(error); + } +}); + +test.afterAll(async () => { + if (!ENABLED) return; + try { + await restorePlatformUrl(); + await prisma.user.deleteMany({ + where: { account: { in: [ADMIN_ACCOUNT, VIEWER_ACCOUNT, NO_AUDIT_ACCOUNT] } }, + }); + await prisma.role.deleteMany({ where: { code: VIEWER_ROLE_CODE } }); + } catch { + // 清理失败不覆盖真实断言结果;残留由下一次 beforeAll 按账号 / 角色前缀清理 + } + await apiContext?.dispose(); + apiContext = null; + await prisma.$disconnect(); +}); + +test.beforeEach(() => { + test.skip(bootstrapError !== null, bootstrapError ?? '测试环境未就绪'); +}); + +// --------------------------------------------------------------------------- +// ① / ② 列表与筛选 +// --------------------------------------------------------------------------- + +test('操作日志:列表可见 / 查看留痕提示 / 筛选变化 / 重置', async ({ page }) => { + test.setTimeout(90_000); + await gotoWithSession(page, requireAdminSession(), '/system/audit-logs'); + + await expect(page.getByRole('heading', { name: '操作日志', level: 1 })).toBeVisible(); + await expect(page.getByText('关键操作留痕 · 只读 · append-only')).toBeVisible(); + await expect(page.getByText('audit.log.view').first()).toBeVisible(); + + const table = page.locator('.lx-audit-table'); + await expect(table).toBeVisible(); + await expect(table.locator('tbody tr').first()).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('button', { name: '导出日志' })).toBeVisible(); + + // 动作分类筛选:权限与角色 + const beforeCount = await table.locator('tbody tr').count(); + await pickSelectOption(page, '全部分类', '登录与会话'); + // 等待 Select 浮层关闭后再提交,避免点击被浮层吞掉(半组件异步关闭) + await page.waitForTimeout(300); + await page.getByRole('button', { name: '查询' }).click(); + await expect(page).toHaveURL(/category=auth/); + await expect(table.locator('tbody tr').first()).toContainText('identity.', { + timeout: 15_000, + }); + const filteredCount = await table.locator('tbody tr').count(); + expect(filteredCount).toBeGreaterThan(0); + expect(filteredCount).toBeLessThanOrEqual(beforeCount); + + // 重置恢复默认筛选 + await page.getByRole('button', { name: '重置' }).click(); + await expect(page).not.toHaveURL(/category=permission/); + await expect(table.locator('tbody tr').first()).toBeVisible({ timeout: 15_000 }); +}); + +// --------------------------------------------------------------------------- +// ③ 详情:diff / 原始 JSON / trace +// --------------------------------------------------------------------------- + +test('日志详情:变更对比 / 原始 JSON / trace_id / 请求信息', async ({ page }) => { + test.setTimeout(90_000); + await gotoWithSession(page, requireAdminSession(), '/system/audit-logs'); + + // 审计表为 append-only,历史演示数据会被测试与真实操作记录挤出首屏; + // 这里点击当前列表第一条,验证详情页对任意审计记录都能打开与展示。 + const row = page.locator('.lx-audit-table tbody tr').first(); + await expect(row).toBeVisible({ timeout: 15_000 }); + await row.click(); + + await expect(page).toHaveURL(/\/system\/audit-logs\/[0-9A-Z]{26}/); + await expect(page.getByRole('heading', { name: '日志详情', level: 1 })).toBeVisible(); + await expect(page.getByText('只读 · append-only').first()).toBeVisible(); + await expect(page.getByText('变更对比')).toBeVisible(); + // 有 diff 时展示字段表;登录 / 查看类记录没有字段级变更时展示空态,两者均可 + const diffTable = page.locator('.lx-audit-diff-table'); + const diffEmpty = page.getByText('没有可展示的字段变更'); + await expect(diffTable.or(diffEmpty).first()).toBeVisible({ timeout: 10_000 }); + // 元信息:trace_id / IP / 请求 / 耗时 + await expect(page.locator('.lx-audit-hero__meta')).toContainText('trace_id'); + await expect(page.locator('.lx-audit-hero__meta')).toContainText('IP 地址'); + await expect(page.locator('.lx-audit-hero__meta')).toContainText('请求'); + await expect(page.locator('.lx-audit-hero__meta')).toContainText('耗时'); + + // 原始 JSON:完整记录 + await page.getByRole('tab', { name: '原始 JSON' }).click(); + const raw = page.locator('.lx-audit-raw__code'); + await expect(raw).toBeVisible(); + await expect(raw).toContainText('"occurred_at"'); + await expect(raw).toContainText('"actor"'); + await expect(raw).toContainText('"action"'); + await expect(raw).toContainText('"target"'); + await expect(raw).toContainText('"before"'); + await expect(raw).toContainText('"after"'); + + // 只读说明与相关记录卡 + await expect(page.getByText('只读说明')).toBeVisible(); + await expect(page.getByText('审计表 append-only', { exact: false })).toBeVisible(); +}); + +// --------------------------------------------------------------------------- +// ④ / ⑤ 导出:创建 → 轮询 → 历史 → 下载 token;列表新增 audit.export +// --------------------------------------------------------------------------- + +test('导出:创建任务 / 轮询完成 / 导出历史 / 下载 token / 列表留痕', async ({ page }) => { + test.setTimeout(150_000); + await gotoWithSession(page, requireAdminSession(), '/system/audit-logs'); + + await page.getByRole('button', { name: '导出日志' }).click(); + const sheet = page.locator('.semi-sidesheet'); + await expect(sheet).toBeVisible(); + await expect(sheet.getByText('导出操作日志')).toBeVisible(); + await expect(sheet.getByText('导出范围(跟随当前筛选)')).toBeVisible(); + await expect(sheet.getByText('CSV(只读)')).toBeVisible(); + + await sheet.getByRole('button', { name: '创建导出任务' }).click(); + await waitToast(page, /导出任务已创建/); + + // 轮询进度直到终态(后端 runner 生成本地 CSV) + const successTitle = sheet.getByText('导出完成'); + const failTitle = sheet.getByText('导出失败'); + await expect(successTitle.or(failTitle)).toBeVisible({ timeout: 60_000 }); + await expect(failTitle).toHaveCount(0); + await expect(sheet.getByRole('button', { name: '下载文件' })).toBeVisible(); + + // 导出历史:任务存在 + await sheet.getByRole('tab', { name: /导出历史/ }).click(); + await expect(sheet.locator('.lx-audit-history-item').first()).toBeVisible({ timeout: 15_000 }); + await expect(sheet.locator('.lx-audit-history-item').first()).toContainText('已完成'); + await expect( + sheet.locator('.lx-audit-history-item').first().getByRole('button', { name: '下载' }), + ).toBeVisible(); + + // API 断言 download_token 存在,且签名下载返回 200 CSV + const context = requireApi(); + const session = requireAdminSession(); + const exportsResponse = await context.get(`${API_BASE}/audit-logs/exports?limit=5`, { + headers: { authorization: `Bearer ${session.accessToken}` }, + }); + expect(exportsResponse.status()).toBe(200); + const exportsBody = requireRecord((await exportsResponse.json()) as unknown, '导出历史响应'); + const items = Array.isArray(exportsBody.items) ? exportsBody.items : []; + expect(items.length).toBeGreaterThan(0); + const latest = requireRecord(items[0], '导出任务'); + expect(latest.status).toBe('succeeded'); + const token = requireString(latest, 'download_token', '导出任务'); + const jobId = requireString(latest, 'id', '导出任务'); + const download = await context.get( + `${API_BASE}/audit-logs/exports/${jobId}/download?token=${encodeURIComponent(token)}`, + ); + expect(download.status(), '签名下载应返回 200').toBe(200); + expect(download.headers()['content-type'] ?? '').toContain('csv'); + + // 关闭抽屉,重置筛选,列表出现 audit.export 记录(导出自身留痕) + await sheet.getByRole('button', { name: '关闭' }).first().click(); + await page.getByRole('button', { name: '重置' }).click(); + const exportRow = page.locator('.lx-audit-table tbody tr', { hasText: '导出审计日志' }).first(); + await expect(exportRow).toBeVisible({ timeout: 20_000 }); + await expect(exportRow).toContainText('audit.export'); +}); + +// --------------------------------------------------------------------------- +// ⑥ 权限三态:无 audit:read → 403;有 audit:read 无 audit:export → 无导出入口 +// --------------------------------------------------------------------------- + +test('权限:无 audit:read 访问 403;无 audit:export 不渲染导出按钮', async ({ page }) => { + test.setTimeout(90_000); + + await gotoWithSession(page, requireNoAuditSession(), '/system/audit-logs'); + await expect(page).toHaveURL(/\/403/); + await expect(page.getByText('你的账号没有该页面的权限点', { exact: false })).toBeVisible({ + timeout: 15_000, + }); + + await gotoWithSession(page, requireViewerSession(), '/system/audit-logs'); + await expect(page.getByRole('heading', { name: '操作日志', level: 1 })).toBeVisible(); + await expect(page.getByText('只读 · 无 audit:export 权限')).toBeVisible(); + await expect(page.getByRole('button', { name: '导出日志' })).toHaveCount(0); + await expect(page.locator('.lx-audit-table tbody tr').first()).toBeVisible({ timeout: 15_000 }); +}); + +// --------------------------------------------------------------------------- +// ⑦ 运行日志:未配置空态 / 配置后外链 +// --------------------------------------------------------------------------- + +test('运行日志:未配置空态 → 配置地址后外链存在', async ({ page }) => { + test.setTimeout(90_000); + // 先置空,验证完整空态 + await setPlatformUrl(''); + await gotoWithSession(page, requireAdminSession(), '/system/logs'); + await expect(page.getByRole('heading', { name: '运行日志', level: 1 })).toBeVisible(); + await expect(page.getByText('尚未配置日志平台')).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('link', { name: '前往系统配置' })).toBeVisible(); + + // 再写入真实地址,验证外链与入口卡 + await setPlatformUrl(PLATFORM_URL_SAMPLE); + await page.reload(); + await expect(page.getByText('日志平台接入')).toBeVisible({ timeout: 15_000 }); + const externalLink = page.getByRole('link', { name: PLATFORM_URL_SAMPLE }); + await expect(externalLink).toBeVisible(); + await expect(externalLink).toHaveAttribute('href', PLATFORM_URL_SAMPLE); + await expect(page.getByRole('button', { name: '打开日志平台' }).first()).toBeVisible(); + await expect(page.getByText('trace 检索').first()).toBeVisible(); + await expect(page.getByText('服务状态').first()).toBeVisible(); + await expect(page.getByText('最近错误').first()).toBeVisible(); +});