Files
kaidi/frontend/e2e/audit-logs.e2e.ts
2026-08-17 14:08:40 +08:00

270 lines
11 KiB
TypeScript

import { Buffer } from 'node:buffer';
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import type { Page, Route } from '@playwright/test';
import { expect, test } from '@playwright/test';
const auditId = '01N00000000000000000000001';
const actorId = '01N00000000000000000000002';
const companyId = '01N00000000000000000000003';
const projectId = '01N00000000000000000000004';
const exportContent =
'\uFEFFeventSequence,occurredAt,username,activeRole,actionCode,objectType,objectPublicId,resultCode,requestId\n' +
'"101","2026-08-15T08:30:00Z","审计管理员","SYSTEM_ADMIN","AUDIT_LOG_EXPORT","AUDIT_LOG","","SUCCESS","01N00000000000000000000005"\n';
const exportSha256 = createHash('sha256').update(exportContent, 'utf8').digest('hex');
interface AuditFixtureOptions {
permissions?: string[];
listQueries?: URL[];
exportRequests?: Array<Record<string, unknown>>;
failListAt?: number;
}
function auditLog() {
return {
publicId: auditId,
eventSequence: 101,
requestId: '01N00000000000000000000005',
userPublicId: actorId,
username: '审计管理员',
activeRole: 'SYSTEM_ADMIN',
companyPublicId: companyId,
projectPublicId: projectId,
actionCode: 'AUDIT_LOG_EXPORT',
objectType: 'AUDIT_LOG',
objectPublicId: null,
resultCode: 'SUCCESS',
reason: '导出当前查询结果',
beforeJson: JSON.stringify({ phone: '138****0000', visible: 'before' }),
afterJson: JSON.stringify({ accountNo: '****2020', visible: 'after' }),
occurredAt: '2026-08-15T08:30:00Z',
allowedActions: ['VIEW'],
};
}
function session(permissions: string[]) {
return {
authenticated: true,
user: {
publicId: actorId,
username: 'audit-e2e',
displayName: '审计管理员',
departmentName: '系统管理部',
mustChangePassword: false,
},
roles: [{ code: 'SYSTEM_ADMIN', name: '系统管理员', workbenchRoute: '/admin/users' }],
activeRole: 'SYSTEM_ADMIN',
permissions,
};
}
async function installAuditFixture(page: Page, options: AuditFixtureOptions = {}) {
const userSession = session(options.permissions || ['audit:log:view', 'audit:log:export']);
let listRequestCount = 0;
await page.route('**/api/v1/**', async (route: Route) => {
const request = route.request();
const url = new URL(request.url());
const { pathname } = url;
if (pathname === '/api/v1/auth/session') {
await route.fulfill({ json: { data: userSession, requestId: '01N00000000000000000000006' } });
return;
}
if (pathname === '/api/v1/auth/profile') {
await route.fulfill({ json: { data: userSession.user, requestId: '01N00000000000000000000007' } });
return;
}
if (pathname === '/api/v1/auth/roles') {
await route.fulfill({ json: { data: userSession.roles, requestId: '01N00000000000000000000008' } });
return;
}
if (pathname === '/api/v1/auth/csrf') {
await route.fulfill({
headers: { 'set-cookie': 'XSRF-TOKEN=audit-e2e-csrf; Path=/' },
json: { data: { token: 'audit-e2e-csrf' }, requestId: '01N00000000000000000000009' },
});
return;
}
if (request.method() === 'GET' && pathname === '/api/v1/audit/logs') {
listRequestCount += 1;
options.listQueries?.push(url);
if (options.failListAt === listRequestCount) {
await route.fulfill({
status: 503,
contentType: 'application/problem+json',
json: {
title: '服务暂时不可用',
status: 503,
code: 'SERVICE_UNAVAILABLE',
detail: '请稍后重试',
requestId: '01N0000000000000000000000F',
},
});
return;
}
const pageNumber = Number(url.searchParams.get('page') || 1);
const pageSize = Number(url.searchParams.get('size') || 20);
await route.fulfill({
json: {
data: [auditLog()],
meta: { page: pageNumber, size: pageSize, totalElements: 1, totalPages: 1 },
requestId: '01N0000000000000000000000A',
},
});
return;
}
if (request.method() === 'GET' && pathname === `/api/v1/audit/logs/${auditId}`) {
await route.fulfill({ json: { data: auditLog(), requestId: '01N0000000000000000000000B' } });
return;
}
if (request.method() === 'POST' && pathname === '/api/v1/audit/exports') {
options.exportRequests?.push(request.postDataJSON() as Record<string, unknown>);
await route.fulfill({
json: {
data: {
exportId: '01N0000000000000000000000C',
rowCount: 1,
sha256: exportSha256,
fileName: 'audit-logs.csv',
content: exportContent,
},
requestId: '01N0000000000000000000000D',
},
});
return;
}
if (request.method() === 'GET') {
await route.fulfill({
json: {
data: [],
meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 },
requestId: '01N0000000000000000000000E',
},
});
return;
}
await route.fulfill({ status: 204 });
});
}
test('PAGE-20 keeps the published filters in the URL and uses Beijing date boundaries', async ({ page }) => {
const listQueries: URL[] = [];
await installAuditFixture(page, { listQueries });
await page.goto(
`/governance/audit?keyword=${encodeURIComponent('导出')}&actorId=${actorId}&identityCode=SYSTEM_ADMIN` +
'&result=SUCCESS&occurredFrom=2026-08-15&occurredTo=2026-08-15&sort=eventSequence%2Casc&page=2&size=50',
);
await expect.poll(() => listQueries.length).toBeGreaterThan(0);
const initial = listQueries.at(-1)?.searchParams;
expect(initial?.get('keyword')).toBe('导出');
expect(initial?.get('actorId')).toBe(actorId);
expect(initial?.get('identityCode')).toBe('SYSTEM_ADMIN');
expect(initial?.get('result')).toBe('SUCCESS');
expect(initial?.get('occurredFrom')).toBe('2026-08-14T16:00:00.000Z');
expect(initial?.get('occurredTo')).toBe('2026-08-15T16:00:00.000Z');
expect(initial?.get('sort')).toBe('eventSequence,asc');
expect(initial?.get('page')).toBe('2');
expect(initial?.get('size')).toBe('50');
expect(initial?.has('_t')).toBe(false);
await expect
.poll(() => {
const url = new URL(page.url());
return {
occurredFrom: url.searchParams.get('occurredFrom'),
occurredTo: url.searchParams.get('occurredTo'),
sort: url.searchParams.get('sort'),
page: url.searchParams.get('page'),
size: url.searchParams.get('size'),
};
})
.toEqual({
occurredFrom: '2026-08-15',
occurredTo: '2026-08-15',
sort: 'eventSequence,asc',
page: '2',
size: '50',
});
await page.getByRole('button', { name: '重置', exact: true }).click();
await expect.poll(() => listQueries.length).toBeGreaterThan(1);
const reset = listQueries.at(-1)?.searchParams;
expect(reset?.has('keyword')).toBe(false);
expect(reset?.has('actorId')).toBe(false);
expect(reset?.has('identityCode')).toBe(false);
expect(reset?.has('result')).toBe(false);
expect(reset?.has('occurredFrom')).toBe(false);
expect(reset?.has('occurredTo')).toBe(false);
expect(reset?.get('sort')).toBe('occurredAt,desc');
expect(reset?.get('page')).toBe('1');
expect(reset?.get('size')).toBe('50');
});
test('PAGE-20 shows scoped masked snapshots and downloads exactly the verified bytes', async ({ page }) => {
const listQueries: URL[] = [];
const exportRequests: Array<Record<string, unknown>> = [];
await installAuditFixture(page, { listQueries, exportRequests });
await page.goto(`/governance/audit?keyword=${encodeURIComponent('导出')}&actorId=${actorId}`);
await expect(page.getByRole('heading', { name: '审计日志', exact: true })).toBeVisible();
await expect(page.getByText(`公司 ${companyId} / 项目 ${projectId}`, { exact: true })).toBeVisible();
await page.getByRole('button', { name: '查看快照', exact: true }).click();
const drawer = page.locator('.t-drawer').filter({ hasText: '审计记录详情' });
await expect(drawer.getByText('101', { exact: true })).toBeVisible();
await expect(drawer.getByText('已脱敏', { exact: true })).toHaveCount(2);
await expect(drawer.getByText('138****0000', { exact: false })).toBeVisible();
await expect(drawer.getByText('****2020', { exact: false })).toBeVisible();
await expect(drawer.getByText('13800000000', { exact: false })).toHaveCount(0);
await expect(drawer.getByText(`公司 ${companyId} / 项目 ${projectId}`, { exact: true })).toBeVisible();
await drawer.locator('.t-drawer__mask').click({ position: { x: 8, y: 8 } });
await expect(drawer).toBeHidden();
const listCountBeforeExport = listQueries.length;
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: '导出当前结果', exact: true }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('audit-logs.csv');
await expect.poll(() => exportRequests.length).toBe(1);
expect(exportRequests[0]).toEqual({
keyword: '导出',
actorId,
sort: 'occurredAt,desc',
});
expect(exportRequests[0]).not.toHaveProperty('page');
expect(exportRequests[0]).not.toHaveProperty('size');
const downloadPath = await download.path();
expect(downloadPath).not.toBeNull();
const bytes = await readFile(downloadPath as string);
expect(bytes.equals(Buffer.from(exportContent, 'utf8'))).toBe(true);
expect(createHash('sha256').update(bytes).digest('hex')).toBe(exportSha256);
await expect.poll(() => listQueries.length).toBeGreaterThan(listCountBeforeExport);
});
test('PAGE-20 keeps export available for SYSTEM_ADMIN when the session permission list is stale', async ({ page }) => {
await installAuditFixture(page, { permissions: ['audit:log:view'] });
await page.goto('/governance/audit');
await expect(page.getByRole('heading', { name: '审计日志', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '导出当前结果', exact: true })).toBeVisible();
});
test('PAGE-20 keeps submitted filters in the URL when the list request fails', async ({ page }) => {
const listQueries: URL[] = [];
await installAuditFixture(page, { listQueries, failListAt: 2 });
await page.goto('/governance/audit');
await expect.poll(() => listQueries.length).toBe(1);
await page.getByPlaceholder('用户、动作、对象或请求编号').fill('失败后保留');
await page.getByRole('button', { name: '查询', exact: true }).click();
await expect.poll(() => listQueries.length).toBe(2);
await expect.poll(() => new URL(page.url()).searchParams.get('keyword')).toBe('失败后保留');
await expect(page.locator('.t-alert')).toContainText('请稍后重试');
});