Files
lingxi/e2e/a5-audit-logs.spec.ts
T
Stev_Wang 26ffe97927 test(e2e): A5 审计与日志端到端用例
覆盖操作日志列表 / 筛选 / 详情 diff 与原始 JSON / 导出创建轮询与签名下载 / 导出自身留痕 / 权限三态 / 运行日志空态与配置外链;A5_E2E=1 门控,临时账号与角色自建自清。
2026-11-04 16:28:10 +08:00

485 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// <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();
});