test(e2e): A5 审计与日志端到端用例

覆盖操作日志列表 / 筛选 / 详情 diff 与原始 JSON / 导出创建轮询与签名下载 / 导出自身留痕 / 权限三态 / 运行日志空态与配置外链;A5_E2E=1 门控,临时账号与角色自建自清。
This commit is contained in:
Stev_Wang committed 2026-11-04 16:28:10 +08:00
1 parent a5933a286e
commit 26ffe97927
2 files changed
+516 -7

No files matched your search

+484
View File
@@ -0,0 +1,484 @@
/// <reference lib="dom" />
/**
* 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<string>(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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!isRecord(value)) {
throw new Error(`${label} 不是 JSON 对象`);
}
return value;
}
function requireString(source: Record<string, unknown>, 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<void> {
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<QaSession> {
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<void> {
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<void> {
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<void> {
const row = await prisma.systemConfig.findUnique({ where: { configKey: PLATFORM_URL_KEY } });
platformUrlOriginalValue = typeof row?.configValue === 'string' ? row.configValue : '';
}
async function setPlatformUrl(value: string): Promise<void> {
await prisma.systemConfig.update({
where: { configKey: PLATFORM_URL_KEY },
data: { configValue: value },
});
}
async function restorePlatformUrl(): Promise<void> {
await prisma.systemConfig.update({
where: { configKey: PLATFORM_URL_KEY },
data: { configValue: platformUrlOriginalValue },
});
}
async function gotoWithSession(page: Page, session: QaSession, path: string): Promise<void> {
await injectSession(page, session);
await page.goto(path);
}
async function pickSelectOption(
page: Page,
triggerText: string,
optionName: string,
): Promise<void> {
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<void> {
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();
});