270 lines
11 KiB
TypeScript
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('请稍后重试');
|
|
});
|