This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
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('请稍后重试');
|
||||
});
|
||||
Reference in New Issue
Block a user