Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 

279 linhas
12 KiB

  1. import type { Page, Route } from '@playwright/test'
  2. import { attachJson, captureScreenshot, expect, test } from './fixtures'
  3. type AuditRow = {
  4. id: string
  5. time: string
  6. auditType: string
  7. module: string
  8. actionCode: string
  9. action: string
  10. username: string
  11. user: string
  12. roleCodes: string
  13. ip: string
  14. success: boolean
  15. targetName: string
  16. requestId: string
  17. httpMethod: string
  18. requestUri: string
  19. }
  20. type MockOptions = {
  21. permissions: string[]
  22. includeAuditMenu: boolean
  23. }
  24. const currentDateParts = Object.fromEntries(
  25. new Intl.DateTimeFormat('en-US', {
  26. timeZone: 'Asia/Shanghai',
  27. year: 'numeric',
  28. month: '2-digit',
  29. }).formatToParts(new Date()).map((part) => [part.type, part.value]),
  30. )
  31. const currentYearMonth = `${currentDateParts.year}-${currentDateParts.month}`
  32. const rangeStartDate = `${currentYearMonth}-01`
  33. const rangeEndDate = `${currentYearMonth}-02`
  34. const json = (route: Route, data: unknown, status = 200) => route.fulfill({
  35. status,
  36. contentType: 'application/json',
  37. body: JSON.stringify({
  38. code: status >= 400 ? status * 100 : 0,
  39. message: status >= 400 ? '请求失败' : '成功',
  40. data,
  41. timestamp: Date.now(),
  42. requestId: 'ape2e-audit-controlled-api',
  43. }),
  44. })
  45. const auditRows: AuditRow[] = Array.from({ length: 23 }, (_, offset) => {
  46. const number = 23 - offset
  47. const authFailure = number === 17
  48. return {
  49. id: String(number),
  50. time: `${rangeEndDate}T${String(8 + (number % 10)).padStart(2, '0')}:15:00+08:00`,
  51. auditType: authFailure ? 'SECURITY' : 'OPERATION',
  52. module: authFailure ? 'AUTH' : number % 3 === 0 ? 'AGENTS' : 'SYSTEM',
  53. actionCode: authFailure ? 'auth.login' : `system.audit.controlled-${number}`,
  54. action: authFailure ? '登录失败' : `受控审计操作 ${number}`,
  55. username: authFailure ? 'audit-teacher' : 'admin',
  56. user: authFailure ? '审计测试教员' : '系统管理员',
  57. roleCodes: authFailure ? 'teacher' : 'admin',
  58. ip: authFailure ? '192.168.10.17' : `192.168.10.${number}`,
  59. success: !authFailure,
  60. targetName: authFailure ? '审计测试身份' : `审计对象 ${number}`,
  61. requestId: `ape2e-audit-${number}`,
  62. httpMethod: number % 2 === 0 ? 'PUT' : 'GET',
  63. requestUri: authFailure
  64. ? '/api/auth/v1/system-config/general'
  65. : `/api/auth/v1/controlled-resources/${number}`,
  66. }
  67. })
  68. const navigation = (includeAuditMenu: boolean) => [
  69. {
  70. id: '1', parentId: null, code: 'overview', name: '工作概览', description: '平台概览', type: 'PAGE',
  71. sortOrder: 10, enabled: true, builtIn: true, dataVersion: 1, path: '/overview', icon: 'Monitor',
  72. permissionCodes: ['ai.overview.view'], children: [],
  73. },
  74. ...(includeAuditMenu ? [{
  75. id: '80', parentId: null, code: 'settings', name: '系统设置', description: '平台、审计与菜单', type: 'GROUP',
  76. sortOrder: 80, enabled: true, builtIn: true, dataVersion: 1, path: '', icon: 'Setting', permissionCodes: [],
  77. children: [{
  78. id: '84', parentId: '80', code: 'settings-audit', name: '审计日志', description: '查询登录、安全与系统操作记录',
  79. type: 'PAGE', sortOrder: 40, enabled: true, builtIn: true, dataVersion: 1, path: '/settings/audit',
  80. icon: 'Document', permissionCodes: ['system.audit'], children: [],
  81. }],
  82. }] : []),
  83. ]
  84. const inRange = (value: string, start: string, end: string) => {
  85. const timestamp = new Date(value).getTime()
  86. return (!start || timestamp >= new Date(start).getTime()) && (!end || timestamp <= new Date(end).getTime())
  87. }
  88. async function mockPlatform(page: Page, options: MockOptions) {
  89. const auditRequests: Array<Record<string, string>> = []
  90. const exportRequests: Array<Record<string, string>> = []
  91. await page.route(/\/api\/(?:auth\/v1|v1)\//, async (route) => {
  92. const url = new URL(route.request().url())
  93. const path = url.pathname
  94. if (path.endsWith('/auth/me')) {
  95. return json(route, {
  96. userId: '1', username: 'admin', displayName: '系统管理员', departmentId: '100', departmentName: '数字人平台',
  97. activeRoleId: '1', authorizationMode: 'SINGLE_ACTIVE', mustChangePassword: false,
  98. roles: [{ id: '1', code: 'admin', name: '管理员' }], permissions: options.permissions,
  99. })
  100. }
  101. if (path.endsWith('/menus/navigation')) return json(route, navigation(options.includeAuditMenu))
  102. if (path.endsWith('/system-config/public')) return json(route, { systemName: '数字人平台' })
  103. if (path.endsWith('/audit-logs/export')) {
  104. exportRequests.push(Object.fromEntries(url.searchParams.entries()))
  105. return json(route, {
  106. filename: 'audit-logs-controlled.csv',
  107. contentType: 'text/csv; charset=utf-8',
  108. contentBase64: Buffer.from('\ufeff时间,模块,结果\r\n受控时间,AUTH,失败\r\n').toString('base64'),
  109. recordCount: 1,
  110. })
  111. }
  112. if (path.endsWith('/audit-logs')) {
  113. const query = Object.fromEntries(url.searchParams.entries())
  114. auditRequests.push(query)
  115. const keyword = (query.keyword || '').toLowerCase()
  116. const result = (query.result || 'ALL').toUpperCase()
  117. const module = (query.module || 'ALL').toUpperCase()
  118. const filtered = auditRows.filter((row) => {
  119. const searchable = [row.username, row.user, row.module, row.action, row.actionCode, row.targetName, row.ip, row.requestUri, row.requestId]
  120. .join('\n').toLowerCase()
  121. return (!keyword || searchable.includes(keyword))
  122. && (module === 'ALL' || row.module === module)
  123. && (result === 'ALL' || (result === 'SUCCESS' ? row.success : !row.success))
  124. && inRange(row.time, query.start || '', query.end || '')
  125. })
  126. const current = Math.max(1, Number(query.page || 1))
  127. const size = Math.max(1, Number(query.size || 20))
  128. const start = (current - 1) * size
  129. return json(route, {
  130. records: filtered.slice(start, start + size),
  131. total: filtered.length,
  132. page: current,
  133. size,
  134. pages: Math.ceil(filtered.length / size),
  135. })
  136. }
  137. return json(route, {})
  138. })
  139. await page.addInitScript(() => {
  140. window.sessionStorage.setItem('ai-person:web:access-token:v1', 'ape2e-audit-controlled-token')
  141. })
  142. return { auditRequests, exportRequests }
  143. }
  144. async function chooseSelect(page: Page, testId: string, label: string) {
  145. await page.getByTestId(testId).click()
  146. const option = page.locator('.el-select-dropdown__item:visible').filter({ hasText: label }).last()
  147. await expect(option).toBeVisible()
  148. await option.click()
  149. }
  150. async function chooseCurrentDateRange(page: Page) {
  151. await page.locator('.audit-time-filter').click()
  152. const panel = page.locator('.el-picker-panel:visible')
  153. await expect(panel).toBeVisible()
  154. const currentMonthDay = (day: string) => panel
  155. .locator('td.available:not(.prev-month):not(.next-month) .el-date-table-cell__text')
  156. .filter({ hasText: new RegExp(`^${day}$`) })
  157. .first()
  158. await currentMonthDay('1').click()
  159. await currentMonthDay('2').click()
  160. const confirm = panel.getByRole('button', { name: '确定', exact: true })
  161. if (await confirm.count()) await confirm.click()
  162. }
  163. test.describe('审计日志页面', () => {
  164. test('非空展示、中文模块、URI搜索、多条件筛选和服务端分页', async ({ page }, testInfo) => {
  165. const { auditRequests, exportRequests } = await mockPlatform(page, {
  166. permissions: ['ai.overview.view', 'system.audit', 'system.audit.export'],
  167. includeAuditMenu: true,
  168. })
  169. await page.goto('/settings/audit')
  170. await expect(page).toHaveURL(/\/settings\/audit(?:[?#].*)?$/)
  171. await expect(page.getByRole('heading', { name: '审计日志', exact: true })).toBeVisible()
  172. await expect(page.getByTestId('audit-log-page')).toBeVisible()
  173. await expect(page.getByText('审计日志', { exact: true }).first()).toBeVisible()
  174. const table = page.getByTestId('audit-table')
  175. const bodyRows = table.locator('.el-table__body tbody tr')
  176. await expect(bodyRows).toHaveCount(20)
  177. await expect(page.getByText('共 23 条记录').first()).toBeVisible()
  178. await expect(table).toContainText('系统管理')
  179. await expect(table).toContainText('智能体管理')
  180. await expect(table).not.toContainText('SYSTEM')
  181. await page.getByTestId('audit-pagination').locator('.btn-next').click()
  182. await expect.poll(() => auditRequests.at(-1)?.page).toBe('2')
  183. await expect(bodyRows).toHaveCount(3)
  184. await expect(table).toContainText('受控审计操作 3')
  185. const keyword = '/api/auth/v1/system-config/general'
  186. await page.getByTestId('audit-filter-keyword').fill(keyword)
  187. await page.getByTestId('audit-search').click()
  188. await expect.poll(() => auditRequests.at(-1)?.keyword).toBe(keyword)
  189. await expect.poll(() => auditRequests.at(-1)?.page).toBe('1')
  190. await expect(bodyRows).toHaveCount(1)
  191. await expect(table).toContainText(keyword)
  192. await expect(table).toContainText('身份认证')
  193. await captureScreenshot(page, testInfo, '01-audit-request-uri-search')
  194. await page.getByTestId('audit-reset').click()
  195. await expect.poll(() => auditRequests.at(-1)?.keyword).toBeUndefined()
  196. await chooseSelect(page, 'audit-filter-module', '身份认证')
  197. await chooseSelect(page, 'audit-filter-result', '失败')
  198. await chooseCurrentDateRange(page)
  199. await page.getByTestId('audit-search').click()
  200. await expect.poll(() => auditRequests.at(-1)?.module).toBe('AUTH')
  201. await expect.poll(() => auditRequests.at(-1)?.result).toBe('FAILURE')
  202. await expect.poll(() => auditRequests.at(-1)?.start).toContain(rangeStartDate)
  203. await expect.poll(() => auditRequests.at(-1)?.end).toContain(rangeEndDate)
  204. await expect(bodyRows).toHaveCount(1)
  205. await expect(table).toContainText('身份认证')
  206. await expect(table).toContainText('失败')
  207. await expect(table).toContainText('登录失败')
  208. await captureScreenshot(page, testInfo, '02-audit-module-result-time-filters')
  209. const downloadPromise = page.waitForEvent('download')
  210. await page.getByTestId('audit-export').click()
  211. const download = await downloadPromise
  212. expect(download.suggestedFilename()).toBe('audit-logs-controlled.csv')
  213. await expect.poll(() => exportRequests).toHaveLength(1)
  214. expect(exportRequests[0]).toMatchObject({ module: 'AUTH', result: 'FAILURE' })
  215. expect(exportRequests[0]?.start).toContain(rangeStartDate)
  216. expect(exportRequests[0]?.end).toContain(rangeEndDate)
  217. expect(exportRequests[0]?.page).toBeUndefined()
  218. expect(exportRequests[0]?.size).toBeUndefined()
  219. await attachJson(testInfo, 'audit-server-query-evidence', { auditRequests, exportRequests })
  220. })
  221. test('没有审计权限时菜单隐藏且路由被守卫', async ({ page }, testInfo) => {
  222. const { auditRequests } = await mockPlatform(page, {
  223. permissions: ['ai.overview.view'],
  224. includeAuditMenu: false,
  225. })
  226. await page.goto('/settings/audit')
  227. await expect(page).toHaveURL(/\/overview\?forbidden=1$/)
  228. await expect(page.getByRole('heading', { name: '审计日志', exact: true })).toHaveCount(0)
  229. await expect(page.getByText('审计日志', { exact: true })).toHaveCount(0)
  230. expect(auditRequests).toHaveLength(0)
  231. await captureScreenshot(page, testInfo, '03-audit-route-forbidden')
  232. })
  233. test('当前筛选没有数据时提示不可导出且不请求导出接口', async ({ page }, testInfo) => {
  234. const { auditRequests, exportRequests } = await mockPlatform(page, {
  235. permissions: ['ai.overview.view', 'system.audit', 'system.audit.export'],
  236. includeAuditMenu: true,
  237. })
  238. let downloadCount = 0
  239. page.on('download', () => { downloadCount += 1 })
  240. await page.goto('/settings/audit')
  241. await page.getByTestId('audit-filter-keyword').fill('no-matching-audit-record')
  242. await page.getByTestId('audit-search').click()
  243. await expect.poll(() => auditRequests.at(-1)?.keyword).toBe('no-matching-audit-record')
  244. await expect(page.getByText('暂无符合当前条件的审计日志', { exact: true })).toBeVisible()
  245. await page.getByTestId('audit-export').click()
  246. await expect(page.getByText('暂无符合当前条件的审计日志,不可导出', { exact: true })).toBeVisible()
  247. expect(exportRequests).toHaveLength(0)
  248. expect(downloadCount).toBe(0)
  249. await captureScreenshot(page, testInfo, '04-audit-empty-export-blocked')
  250. })
  251. })