| @@ -0,0 +1,257 @@ | |||||
| import type { Page, Route } from '@playwright/test' | |||||
| import { attachJson, captureScreenshot, expect, test } from './fixtures' | |||||
| type AuditRow = { | |||||
| id: string | |||||
| time: string | |||||
| auditType: string | |||||
| module: string | |||||
| actionCode: string | |||||
| action: string | |||||
| username: string | |||||
| user: string | |||||
| roleCodes: string | |||||
| ip: string | |||||
| success: boolean | |||||
| targetName: string | |||||
| requestId: string | |||||
| httpMethod: string | |||||
| requestUri: string | |||||
| } | |||||
| type MockOptions = { | |||||
| permissions: string[] | |||||
| includeAuditMenu: boolean | |||||
| } | |||||
| const currentDateParts = Object.fromEntries( | |||||
| new Intl.DateTimeFormat('en-US', { | |||||
| timeZone: 'Asia/Shanghai', | |||||
| year: 'numeric', | |||||
| month: '2-digit', | |||||
| }).formatToParts(new Date()).map((part) => [part.type, part.value]), | |||||
| ) | |||||
| const currentYearMonth = `${currentDateParts.year}-${currentDateParts.month}` | |||||
| const rangeStartDate = `${currentYearMonth}-01` | |||||
| const rangeEndDate = `${currentYearMonth}-02` | |||||
| const json = (route: Route, data: unknown, status = 200) => route.fulfill({ | |||||
| status, | |||||
| contentType: 'application/json', | |||||
| body: JSON.stringify({ | |||||
| code: status >= 400 ? status * 100 : 0, | |||||
| message: status >= 400 ? '请求失败' : '成功', | |||||
| data, | |||||
| timestamp: Date.now(), | |||||
| requestId: 'ape2e-audit-controlled-api', | |||||
| }), | |||||
| }) | |||||
| const auditRows: AuditRow[] = Array.from({ length: 23 }, (_, offset) => { | |||||
| const number = 23 - offset | |||||
| const authFailure = number === 17 | |||||
| return { | |||||
| id: String(number), | |||||
| time: `${rangeEndDate}T${String(8 + (number % 10)).padStart(2, '0')}:15:00+08:00`, | |||||
| auditType: authFailure ? 'SECURITY' : 'OPERATION', | |||||
| module: authFailure ? 'AUTH' : number % 3 === 0 ? 'AGENTS' : 'SYSTEM', | |||||
| actionCode: authFailure ? 'auth.login' : `system.audit.controlled-${number}`, | |||||
| action: authFailure ? '登录失败' : `受控审计操作 ${number}`, | |||||
| username: authFailure ? 'audit-teacher' : 'admin', | |||||
| user: authFailure ? '审计测试教员' : '系统管理员', | |||||
| roleCodes: authFailure ? 'teacher' : 'admin', | |||||
| ip: authFailure ? '192.168.10.17' : `192.168.10.${number}`, | |||||
| success: !authFailure, | |||||
| targetName: authFailure ? '审计测试身份' : `审计对象 ${number}`, | |||||
| requestId: `ape2e-audit-${number}`, | |||||
| httpMethod: number % 2 === 0 ? 'PUT' : 'GET', | |||||
| requestUri: authFailure | |||||
| ? '/api/auth/v1/system-config/general' | |||||
| : `/api/auth/v1/controlled-resources/${number}`, | |||||
| } | |||||
| }) | |||||
| const navigation = (includeAuditMenu: boolean) => [ | |||||
| { | |||||
| id: '1', parentId: null, code: 'overview', name: '工作概览', description: '平台概览', type: 'PAGE', | |||||
| sortOrder: 10, enabled: true, builtIn: true, dataVersion: 1, path: '/overview', icon: 'Monitor', | |||||
| permissionCodes: ['ai.overview.view'], children: [], | |||||
| }, | |||||
| ...(includeAuditMenu ? [{ | |||||
| id: '80', parentId: null, code: 'settings', name: '系统设置', description: '平台、审计与菜单', type: 'GROUP', | |||||
| sortOrder: 80, enabled: true, builtIn: true, dataVersion: 1, path: '', icon: 'Setting', permissionCodes: [], | |||||
| children: [{ | |||||
| id: '84', parentId: '80', code: 'settings-audit', name: '审计日志', description: '查询登录、安全与系统操作记录', | |||||
| type: 'PAGE', sortOrder: 40, enabled: true, builtIn: true, dataVersion: 1, path: '/settings/audit', | |||||
| icon: 'Document', permissionCodes: ['system.audit'], children: [], | |||||
| }], | |||||
| }] : []), | |||||
| ] | |||||
| const inRange = (value: string, start: string, end: string) => { | |||||
| const timestamp = new Date(value).getTime() | |||||
| return (!start || timestamp >= new Date(start).getTime()) && (!end || timestamp <= new Date(end).getTime()) | |||||
| } | |||||
| async function mockPlatform(page: Page, options: MockOptions) { | |||||
| const auditRequests: Array<Record<string, string>> = [] | |||||
| const exportRequests: Array<Record<string, string>> = [] | |||||
| await page.route(/\/api\/(?:auth\/v1|v1)\//, async (route) => { | |||||
| const url = new URL(route.request().url()) | |||||
| const path = url.pathname | |||||
| if (path.endsWith('/auth/me')) { | |||||
| return json(route, { | |||||
| userId: '1', username: 'admin', displayName: '系统管理员', departmentId: '100', departmentName: '数字人平台', | |||||
| activeRoleId: '1', authorizationMode: 'SINGLE_ACTIVE', mustChangePassword: false, | |||||
| roles: [{ id: '1', code: 'admin', name: '管理员' }], permissions: options.permissions, | |||||
| }) | |||||
| } | |||||
| if (path.endsWith('/menus/navigation')) return json(route, navigation(options.includeAuditMenu)) | |||||
| if (path.endsWith('/system-config/public')) return json(route, { systemName: '数字人平台' }) | |||||
| if (path.endsWith('/audit-logs/export')) { | |||||
| exportRequests.push(Object.fromEntries(url.searchParams.entries())) | |||||
| return json(route, { | |||||
| filename: 'audit-logs-controlled.csv', | |||||
| contentType: 'text/csv; charset=utf-8', | |||||
| contentBase64: Buffer.from('\ufeff时间,模块,结果\r\n受控时间,AUTH,失败\r\n').toString('base64'), | |||||
| recordCount: 1, | |||||
| }) | |||||
| } | |||||
| if (path.endsWith('/audit-logs')) { | |||||
| const query = Object.fromEntries(url.searchParams.entries()) | |||||
| auditRequests.push(query) | |||||
| const keyword = (query.keyword || '').toLowerCase() | |||||
| const result = (query.result || 'ALL').toUpperCase() | |||||
| const module = (query.module || 'ALL').toUpperCase() | |||||
| const filtered = auditRows.filter((row) => { | |||||
| const searchable = [row.username, row.user, row.module, row.action, row.actionCode, row.targetName, row.ip, row.requestUri, row.requestId] | |||||
| .join('\n').toLowerCase() | |||||
| return (!keyword || searchable.includes(keyword)) | |||||
| && (module === 'ALL' || row.module === module) | |||||
| && (result === 'ALL' || (result === 'SUCCESS' ? row.success : !row.success)) | |||||
| && inRange(row.time, query.start || '', query.end || '') | |||||
| }) | |||||
| const current = Math.max(1, Number(query.page || 1)) | |||||
| const size = Math.max(1, Number(query.size || 20)) | |||||
| const start = (current - 1) * size | |||||
| return json(route, { | |||||
| records: filtered.slice(start, start + size), | |||||
| total: filtered.length, | |||||
| page: current, | |||||
| size, | |||||
| pages: Math.ceil(filtered.length / size), | |||||
| }) | |||||
| } | |||||
| return json(route, {}) | |||||
| }) | |||||
| await page.addInitScript(() => { | |||||
| window.sessionStorage.setItem('ai-person:web:access-token:v1', 'ape2e-audit-controlled-token') | |||||
| }) | |||||
| return { auditRequests, exportRequests } | |||||
| } | |||||
| async function chooseSelect(page: Page, testId: string, label: string) { | |||||
| await page.getByTestId(testId).click() | |||||
| const option = page.locator('.el-select-dropdown__item:visible').filter({ hasText: label }).last() | |||||
| await expect(option).toBeVisible() | |||||
| await option.click() | |||||
| } | |||||
| async function chooseCurrentDateRange(page: Page) { | |||||
| await page.locator('.audit-time-filter').click() | |||||
| const panel = page.locator('.el-picker-panel:visible') | |||||
| await expect(panel).toBeVisible() | |||||
| const currentMonthDay = (day: string) => panel | |||||
| .locator('td.available:not(.prev-month):not(.next-month) .el-date-table-cell__text') | |||||
| .filter({ hasText: new RegExp(`^${day}$`) }) | |||||
| .first() | |||||
| await currentMonthDay('1').click() | |||||
| await currentMonthDay('2').click() | |||||
| const confirm = panel.getByRole('button', { name: '确定', exact: true }) | |||||
| if (await confirm.count()) await confirm.click() | |||||
| } | |||||
| test.describe('审计日志页面', () => { | |||||
| test('非空展示、中文模块、URI搜索、多条件筛选和服务端分页', async ({ page }, testInfo) => { | |||||
| const { auditRequests, exportRequests } = await mockPlatform(page, { | |||||
| permissions: ['ai.overview.view', 'system.audit', 'system.audit.export'], | |||||
| includeAuditMenu: true, | |||||
| }) | |||||
| await page.goto('/settings/audit') | |||||
| await expect(page).toHaveURL(/\/settings\/audit(?:[?#].*)?$/) | |||||
| await expect(page.getByRole('heading', { name: '审计日志', exact: true })).toBeVisible() | |||||
| await expect(page.getByTestId('audit-log-page')).toBeVisible() | |||||
| await expect(page.getByText('审计日志', { exact: true }).first()).toBeVisible() | |||||
| const table = page.getByTestId('audit-table') | |||||
| const bodyRows = table.locator('.el-table__body tbody tr') | |||||
| await expect(bodyRows).toHaveCount(20) | |||||
| await expect(page.getByText('共 23 条记录').first()).toBeVisible() | |||||
| await expect(table).toContainText('系统管理') | |||||
| await expect(table).toContainText('智能体管理') | |||||
| await expect(table).not.toContainText('SYSTEM') | |||||
| await page.getByTestId('audit-pagination').locator('.btn-next').click() | |||||
| await expect.poll(() => auditRequests.at(-1)?.page).toBe('2') | |||||
| await expect(bodyRows).toHaveCount(3) | |||||
| await expect(table).toContainText('受控审计操作 3') | |||||
| const keyword = '/api/auth/v1/system-config/general' | |||||
| await page.getByTestId('audit-filter-keyword').fill(keyword) | |||||
| await page.getByTestId('audit-search').click() | |||||
| await expect.poll(() => auditRequests.at(-1)?.keyword).toBe(keyword) | |||||
| await expect.poll(() => auditRequests.at(-1)?.page).toBe('1') | |||||
| await expect(bodyRows).toHaveCount(1) | |||||
| await expect(table).toContainText(keyword) | |||||
| await expect(table).toContainText('身份认证') | |||||
| await captureScreenshot(page, testInfo, '01-audit-request-uri-search') | |||||
| await page.getByTestId('audit-reset').click() | |||||
| await expect.poll(() => auditRequests.at(-1)?.keyword).toBeUndefined() | |||||
| await chooseSelect(page, 'audit-filter-module', '身份认证') | |||||
| await chooseSelect(page, 'audit-filter-result', '失败') | |||||
| await chooseCurrentDateRange(page) | |||||
| await page.getByTestId('audit-search').click() | |||||
| await expect.poll(() => auditRequests.at(-1)?.module).toBe('AUTH') | |||||
| await expect.poll(() => auditRequests.at(-1)?.result).toBe('FAILURE') | |||||
| await expect.poll(() => auditRequests.at(-1)?.start).toContain(rangeStartDate) | |||||
| await expect.poll(() => auditRequests.at(-1)?.end).toContain(rangeEndDate) | |||||
| await expect(bodyRows).toHaveCount(1) | |||||
| await expect(table).toContainText('身份认证') | |||||
| await expect(table).toContainText('失败') | |||||
| await expect(table).toContainText('登录失败') | |||||
| await captureScreenshot(page, testInfo, '02-audit-module-result-time-filters') | |||||
| const downloadPromise = page.waitForEvent('download') | |||||
| await page.getByTestId('audit-export').click() | |||||
| const download = await downloadPromise | |||||
| expect(download.suggestedFilename()).toBe('audit-logs-controlled.csv') | |||||
| await expect.poll(() => exportRequests).toHaveLength(1) | |||||
| expect(exportRequests[0]).toMatchObject({ module: 'AUTH', result: 'FAILURE' }) | |||||
| expect(exportRequests[0]?.start).toContain(rangeStartDate) | |||||
| expect(exportRequests[0]?.end).toContain(rangeEndDate) | |||||
| expect(exportRequests[0]?.page).toBeUndefined() | |||||
| expect(exportRequests[0]?.size).toBeUndefined() | |||||
| await attachJson(testInfo, 'audit-server-query-evidence', { auditRequests, exportRequests }) | |||||
| }) | |||||
| test('没有审计权限时菜单隐藏且路由被守卫', async ({ page }, testInfo) => { | |||||
| const { auditRequests } = await mockPlatform(page, { | |||||
| permissions: ['ai.overview.view'], | |||||
| includeAuditMenu: false, | |||||
| }) | |||||
| await page.goto('/settings/audit') | |||||
| await expect(page).toHaveURL(/\/overview\?forbidden=1$/) | |||||
| await expect(page.getByRole('heading', { name: '审计日志', exact: true })).toHaveCount(0) | |||||
| await expect(page.getByText('审计日志', { exact: true })).toHaveCount(0) | |||||
| expect(auditRequests).toHaveLength(0) | |||||
| await captureScreenshot(page, testInfo, '03-audit-route-forbidden') | |||||
| }) | |||||
| }) | |||||