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>; 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); 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> = []; 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('请稍后重试'); });