You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

198 lines
11 KiB

  1. import { test, expect, type Locator, type Page, type TestInfo } from '@playwright/test'
  2. import fs from 'node:fs'
  3. import path from 'node:path'
  4. // Credentials stay in the local helper and browser session; traces/video are disabled by the live config.
  5. // @ts-ignore The existing live API helper is JavaScript.
  6. import { call, login, work } from '../tools/role-permissions-live.mjs'
  7. type FixtureRole = { id: string | number; code: string }
  8. type Fixtures = { reader: FixtureRole; editor: FixtureRole; roleIds: Array<string | number> }
  9. const fixture = (): Fixtures => JSON.parse(fs.readFileSync(path.join(work, 'fixtures.json'), 'utf8'))
  10. const sorted = (codes: string[]) => [...new Set(codes)].sort()
  11. const permissionCodes = (value: { permissionCodes?: string[]; permissions?: string[] }) => sorted(value.permissionCodes ?? value.permissions ?? [])
  12. const deferred = () => {
  13. let resolve!: () => void
  14. const promise = new Promise<void>(done => { resolve = done })
  15. return { promise, resolve }
  16. }
  17. async function signIn(page: Page) {
  18. const session = await login()
  19. await page.addInitScript(({ accessToken, refreshToken }) => {
  20. sessionStorage.setItem('unreal-tran:web:access-token:v1', accessToken)
  21. if (refreshToken) sessionStorage.setItem('unreal-tran:web:refresh-token:v1', refreshToken)
  22. }, { accessToken: session.accessToken, refreshToken: session.refreshToken })
  23. return session.accessToken as string
  24. }
  25. const roleRow = (page: Page, code: string) => page.locator('.el-table__row').filter({
  26. has: page.locator('.role-identity code').filter({ hasText: new RegExp(`^${code}$`) }),
  27. })
  28. const treeNode = (container: Locator, code: string) => container.locator('.el-tree-node__content').filter({
  29. has: container.page().locator('code').filter({ hasText: new RegExp(`^${code.replaceAll('.', '\\.')}$`) }),
  30. })
  31. const checkedCodes = (drawer: Locator) => drawer.locator('.el-tree-node__content').evaluateAll(nodes => nodes
  32. .filter(node => node.querySelector<HTMLInputElement>('input[type="checkbox"]')?.checked)
  33. .map(node => node.querySelector('code')?.textContent?.trim() ?? '')
  34. .filter(Boolean).sort())
  35. async function attachEvidence(page: Page, info: TestInfo, name: string, evidence: unknown) {
  36. const filename = path.resolve('reports/role-permissions-20260906/screenshots', `${name}.png`)
  37. fs.mkdirSync(path.dirname(filename), { recursive: true })
  38. await page.screenshot({ path: filename, fullPage: true, animations: 'disabled' })
  39. await info.attach(name, { path: filename, contentType: 'image/png' })
  40. await info.attach(`${name}-evidence`, { body: JSON.stringify(evidence, null, 2), contentType: 'application/json' })
  41. }
  42. test('权限加载乱序:关闭 A 后打开 B,A 的迟到响应不能覆盖 B', async ({ page }, info) => {
  43. const data = fixture()
  44. const token = await signIn(page)
  45. const arrived = deferred()
  46. const release = deferred()
  47. const delivered = deferred()
  48. const endpoint = `**/api/auth/v1/permissions/roles/${data.reader.id}`
  49. try {
  50. const reader = await call(`auth/v1/permissions/roles/${data.reader.id}`, token)
  51. const editor = await call(`auth/v1/permissions/roles/${data.editor.id}`, token)
  52. expect(reader.status).toBe(200)
  53. expect(editor.status).toBe(200)
  54. const readerCodes = permissionCodes(reader.data)
  55. const editorCodes = permissionCodes(editor.data)
  56. expect(readerCodes).not.toEqual(editorCodes)
  57. await page.route(endpoint, async route => {
  58. if (route.request().method() !== 'GET') return route.continue()
  59. // Fetch the actual read-only response, then deliberately deliver it after B has loaded.
  60. const response = await route.fetch()
  61. arrived.resolve()
  62. await release.promise
  63. try { await route.fulfill({ response }) } finally { delivered.resolve() }
  64. })
  65. await page.goto('/system/roles')
  66. await roleRow(page, data.reader.code).getByRole('button', { name: '配置权限', exact: true }).click()
  67. await arrived.promise
  68. const drawer = page.getByRole('dialog', { name: '配置角色权限', exact: true })
  69. await expect(drawer.getByRole('button', { name: '保存授权' })).toBeDisabled()
  70. await drawer.locator('.el-drawer__close-btn').click()
  71. await expect(drawer).not.toBeVisible()
  72. await roleRow(page, data.editor.code).getByRole('button', { name: '配置权限', exact: true }).click()
  73. await expect.poll(() => checkedCodes(drawer)).toEqual(editorCodes)
  74. await expect(drawer.locator('.role-permission-summary h3')).toHaveText('权限实测·模型编辑')
  75. const lateResponse = page.waitForResponse(response => response.url().endsWith(`/permissions/roles/${data.reader.id}`)
  76. && response.request().method() === 'GET')
  77. release.resolve()
  78. await delivered.promise
  79. expect((await lateResponse).status()).toBe(200)
  80. await (await lateResponse).finished()
  81. // Let the fulfilled response reach the store and Vue rendering before checking for stale writes.
  82. await page.waitForTimeout(250)
  83. expect(await checkedCodes(drawer)).toEqual(editorCodes)
  84. await expect(drawer.locator('.role-permission-summary h3')).toHaveText('权限实测·模型编辑')
  85. await expect(drawer.getByRole('button', { name: '保存授权' })).toBeDisabled()
  86. await attachEvidence(page, info, '10-stale-permission-response-is-ignored', {
  87. delayedRole: data.reader.code, currentRole: data.editor.code,
  88. delayedPermissionCodes: readerCodes, displayedPermissionCodes: editorCodes, businessWrites: 0,
  89. })
  90. } finally {
  91. release.resolve()
  92. await page.unrouteAll({ behavior: 'wait' })
  93. await call('auth/v1/auth/logout', token, {}).catch(() => undefined)
  94. }
  95. })
  96. test('保存授权期间禁止恢复,PUT 成功但列表刷新失败仍保留提交快照', async ({ page }, info) => {
  97. const data = fixture()
  98. const token = await signIn(page)
  99. const arrived = deferred()
  100. const release = deferred()
  101. let original: string[] | undefined
  102. let writeAttempted = false
  103. let posted: string[] | undefined
  104. let refreshFailures = 0
  105. try {
  106. // Only the explicitly prepared temporary reader role may be changed by this regression.
  107. expect(data.reader.code).toMatch(/^rv_reader_/)
  108. expect(data.roleIds.map(String)).toContain(String(data.reader.id))
  109. const role = await call(`auth/v1/roles/${data.reader.id}`, token)
  110. expect(role.status).toBe(200)
  111. expect(role.data.code).toBe(data.reader.code)
  112. expect(role.data.permissionPolicy).toBe('CONFIGURABLE')
  113. expect(Boolean(role.data.builtIn ?? role.data.isBuiltin)).toBe(false)
  114. const initial = await call(`auth/v1/permissions/roles/${data.reader.id}`, token)
  115. expect(initial.status).toBe(200)
  116. original = permissionCodes(initial.data)
  117. expect(original).toContain('content.model')
  118. const action = 'content.model.create'
  119. const submitted = sorted(original.includes(action) ? original.filter(code => code !== action) : [...original, action])
  120. await page.goto('/system/roles')
  121. await roleRow(page, data.reader.code).getByRole('button', { name: '配置权限', exact: true }).click()
  122. const drawer = page.getByRole('dialog', { name: '配置角色权限', exact: true })
  123. await expect.poll(() => checkedCodes(drawer)).toEqual(original)
  124. await drawer.getByPlaceholder('搜索权限名称或编码').fill('content.model')
  125. await treeNode(drawer, action).locator('.el-checkbox').click()
  126. await expect.poll(() => checkedCodes(drawer)).toEqual(submitted)
  127. await page.route(`**/api/auth/v1/permissions/roles/${data.reader.id}`, async route => {
  128. if (route.request().method() !== 'PUT') return route.continue()
  129. posted = sorted(route.request().postDataJSON().permissionCodes)
  130. arrived.resolve()
  131. await release.promise
  132. writeAttempted = true
  133. // Await the real PUT here as well, so cleanup cannot race an outstanding mutation.
  134. const response = await route.fetch()
  135. await route.fulfill({ response })
  136. })
  137. // Install only after the initial real list load. The permission PUT itself reaches the real API.
  138. await page.route('**/api/auth/v1/roles/all', route => {
  139. refreshFailures += 1
  140. return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({
  141. code: 'UNAVAILABLE', message: '角色列表刷新临时不可用', data: null,
  142. }) })
  143. })
  144. const result = page.waitForResponse(response => response.url().endsWith(`/permissions/roles/${data.reader.id}`)
  145. && response.request().method() === 'PUT')
  146. await drawer.getByRole('button', { name: '保存授权' }).click()
  147. await arrived.promise
  148. await expect(drawer.getByRole('button', { name: '恢复', exact: true })).toBeDisabled()
  149. await expect(treeNode(drawer, action).getByRole('checkbox')).toBeDisabled()
  150. expect(posted).toEqual(submitted)
  151. release.resolve()
  152. const response = await result
  153. expect(response.status()).toBe(200)
  154. expect(permissionCodes((await response.json()).data)).toEqual(submitted)
  155. await expect(page.locator('.el-message--error')).toContainText('角色列表刷新临时不可用')
  156. await expect(drawer).toBeVisible()
  157. expect(await checkedCodes(drawer)).toEqual(submitted)
  158. await expect(drawer.getByRole('button', { name: '恢复', exact: true })).toBeDisabled()
  159. await expect(drawer.getByRole('button', { name: '保存授权' })).toBeDisabled()
  160. expect(refreshFailures).toBeGreaterThan(0)
  161. await attachEvidence(page, info, '11-permission-save-survives-list-refresh-failure', {
  162. temporaryRole: data.reader.code, originalPermissionCodes: original, submittedPermissionCodes: submitted,
  163. realPutStatus: response.status(), injectedListRefreshStatus: 503, resetDisabledWhileSaving: true,
  164. })
  165. const persisted = await call(`auth/v1/permissions/roles/${data.reader.id}`, token)
  166. expect(persisted.status).toBe(200)
  167. expect(permissionCodes(persisted.data)).toEqual(submitted)
  168. } finally {
  169. release.resolve()
  170. try {
  171. await page.unrouteAll({ behavior: 'wait' })
  172. if (original && writeAttempted) {
  173. const current = await call(`auth/v1/permissions/roles/${data.reader.id}`, token)
  174. expect(current.status, '读取临时 reader 最新版本以恢复授权').toBe(200)
  175. if (JSON.stringify(permissionCodes(current.data)) !== JSON.stringify(original)) {
  176. const restored = await call(`auth/v1/permissions/roles/${data.reader.id}`, token, {
  177. permissionCodes: original, version: current.data.version ?? current.data.dataVersion,
  178. }, 'PUT')
  179. expect(restored.status, '恢复临时 reader 原授权').toBe(200)
  180. }
  181. const verified = await call(`auth/v1/permissions/roles/${data.reader.id}`, token)
  182. expect(verified.status).toBe(200)
  183. expect(permissionCodes(verified.data), '临时 reader 授权恢复核验').toEqual(original)
  184. await info.attach('11-temporary-reader-restored', {
  185. body: JSON.stringify({ roleCode: data.reader.code, restored: true, permissionCodes: original }),
  186. contentType: 'application/json',
  187. })
  188. }
  189. } finally { await call('auth/v1/auth/logout', token, {}).catch(() => undefined) }
  190. }
  191. })