/// /** * 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(); });