test(e2e): A5 审计与日志端到端用例
覆盖操作日志列表 / 筛选 / 详情 diff 与原始 JSON / 导出创建轮询与签名下载 / 导出自身留痕 / 权限三态 / 运行日志空态与配置外链;A5_E2E=1 门控,临时账号与角色自建自清。
This commit is contained in:
1 parent
a5933a286e
commit
26ffe97927
2 files changed
+516
-7
No files matched your search
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user