Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.
 
 
 
 

214 rader
13 KiB

  1. import { test, expect, type Page, type TestInfo } from '@playwright/test'
  2. import fs from 'node:fs'
  3. import path from 'node:path'
  4. // Local helper reads credentials in memory. Reports and Playwright traces never contain them.
  5. // @ts-ignore JS helper is also executable independently for API checks.
  6. import { call, login, work } from '../tools/role-permissions-live.mjs'
  7. const shots = path.resolve('reports/role-permissions-20260906/screenshots')
  8. fs.mkdirSync(shots, { recursive: true })
  9. const fixture = () => JSON.parse(fs.readFileSync(path.join(work, 'fixtures.json'), 'utf8'))
  10. const networkFailures = new WeakMap<Page, Array<{ path: string; status?: number; error?: string }>>()
  11. test.beforeEach(async ({ page }) => {
  12. const failures: Array<{ path: string; status?: number; error?: string }> = []
  13. networkFailures.set(page, failures)
  14. page.on('requestfailed', request => {
  15. if (new URL(request.url()).pathname.startsWith('/api/')) failures.push({ path: new URL(request.url()).pathname, error: request.failure()?.errorText })
  16. })
  17. page.on('response', response => {
  18. if (response.status() >= 400 && new URL(response.url()).pathname.startsWith('/api/')) failures.push({ path: new URL(response.url()).pathname, status: response.status() })
  19. })
  20. })
  21. test.afterEach(async ({ page }, info) => {
  22. await info.attach('sanitized-api-failures', { body: JSON.stringify(networkFailures.get(page) ?? []), contentType: 'application/json' })
  23. const token = await page.evaluate(() => sessionStorage.getItem('unreal-tran:web:access-token:v1') || localStorage.getItem('unreal-tran:web:access-token:v1')).catch(() => null)
  24. if (token) await call('auth/v1/auth/logout', token, {}).catch(() => undefined)
  25. })
  26. const shot = async (page: Page, info: TestInfo, name: string) => {
  27. const filename = path.join(shots, `${name}.png`)
  28. await expect(page.locator('.el-loading-mask:visible')).toHaveCount(0)
  29. await page.screenshot({ path: filename, fullPage: true, animations: 'disabled' })
  30. await info.attach(name, { path: filename, contentType: 'image/png' })
  31. }
  32. async function signIn(page: Page, role: 'admin' | 'reader' | 'editor' | 'teacher' = 'admin') {
  33. let session
  34. if (role === 'admin') session = await login()
  35. else {
  36. const data = fixture()
  37. const result = await call('auth/v1/auth/login', null, { userId: data.user.id, password: data.user.password, roleCode: 'teacher', departmentId: data.user.departmentId, rememberMe: false })
  38. expect(result.status).toBe(200)
  39. const selected = await call('auth/v1/auth/active-role', result.data.accessToken, { roleId: role === 'teacher' ? data.teacherRoleId : data[role].id }, 'PUT')
  40. expect(selected.status).toBe(200)
  41. session = selected.data?.accessToken ? selected.data : result.data
  42. }
  43. await page.addInitScript(({ accessToken, refreshToken }) => {
  44. // Only initialize once: a role switch may rotate tokens before a full app reload.
  45. if (!sessionStorage.getItem('role-permissions-test-initialized')) {
  46. sessionStorage.setItem('unreal-tran:web:access-token:v1', accessToken)
  47. if (refreshToken) sessionStorage.setItem('unreal-tran:web:refresh-token:v1', refreshToken)
  48. sessionStorage.setItem('role-permissions-test-initialized', '1')
  49. }
  50. }, { accessToken: session.accessToken, refreshToken: session.refreshToken })
  51. }
  52. async function openSwitcher(page: Page) {
  53. await page.getByRole('button', { name: /^账号菜单:/ }).click()
  54. await page.getByRole('menuitem', { name: '切换角色', exact: true }).click()
  55. const dialog = page.getByRole('dialog', { name: '切换角色', exact: true })
  56. await expect(dialog).toBeVisible()
  57. return dialog
  58. }
  59. const treeNode = (container: ReturnType<Page['locator']>, code: string) => container.locator('.el-tree-node__content').filter({ has: container.page().locator('code').filter({ hasText: new RegExp(`^${code.replaceAll('.', '\\.')}$$`) }) })
  60. test('管理员授权固定,权限树细分到页面操作', async ({ page }, info) => {
  61. await signIn(page)
  62. await page.goto('/system/roles')
  63. const adminRow = page.locator('.el-table__row').filter({ has: page.locator('.role-identity code').filter({ hasText: /^admin$/ }) })
  64. await adminRow.getByRole('button', { name: '配置权限', exact: true }).click()
  65. const drawer = page.getByRole('dialog', { name: '配置角色权限' })
  66. await expect(drawer).toContainText('该角色采用固定授权')
  67. await drawer.getByPlaceholder('搜索权限名称或编码').fill('content.model')
  68. await expect(drawer.locator('code').filter({ hasText: /^content\.model\.create$/ })).toBeVisible()
  69. await expect(drawer.getByRole('button', { name: '保存授权' })).toBeDisabled()
  70. await expect(treeNode(drawer, 'content.model.create').getByRole('checkbox')).toBeDisabled()
  71. await shot(page, info, '01-admin-fixed-permissions')
  72. })
  73. test('权限依赖:勾选操作自动补齐查看,取消查看需要确认', async ({ page }, info) => {
  74. await signIn(page)
  75. const data = fixture()
  76. await page.goto('/system/roles')
  77. const row = page.locator('.el-table__row').filter({ has: page.locator('.role-identity code').filter({ hasText: data.reader.code }) })
  78. await row.getByRole('button', { name: '配置权限', exact: true }).click()
  79. const drawer = page.getByRole('dialog', { name: '配置角色权限' })
  80. const editor = drawer.locator('.permission-tree-editor')
  81. await editor.getByPlaceholder('搜索权限名称或编码').fill('content.model')
  82. const read = treeNode(editor, 'content.model').getByRole('checkbox')
  83. const create = treeNode(editor, 'content.model.create').getByRole('checkbox')
  84. await expect(read).toBeChecked()
  85. await expect(create).not.toBeChecked()
  86. await treeNode(editor, 'content.model').locator('.el-checkbox').click()
  87. await expect(read).not.toBeChecked()
  88. await treeNode(editor, 'content.model.create').locator('.el-checkbox').click()
  89. await expect(read).toBeChecked()
  90. await expect(create).toBeChecked()
  91. await shot(page, info, '02-action-adds-page-permission')
  92. await treeNode(editor, 'content.model').locator('.el-checkbox').click()
  93. const confirm = page.getByRole('dialog', { name: '确认取消关联权限' })
  94. await expect(confirm).toBeVisible()
  95. await shot(page, info, '03-removing-page-confirms-dependent-actions')
  96. await confirm.getByRole('button', { name: '保留权限' }).click()
  97. await expect(read).toBeChecked()
  98. await expect(create).toBeChecked()
  99. await drawer.getByRole('button', { name: '保存授权' }).click()
  100. await expect(drawer).not.toBeVisible()
  101. await row.getByRole('button', { name: '配置权限', exact: true }).click()
  102. await editor.getByPlaceholder('搜索权限名称或编码').fill('content.model')
  103. await expect(create).toBeChecked()
  104. await treeNode(editor, 'content.model.create').locator('.el-checkbox').click()
  105. await expect(create).not.toBeChecked()
  106. await drawer.getByRole('button', { name: '保存授权' }).click()
  107. await expect(drawer).not.toBeVisible()
  108. })
  109. test('头像弹窗切换自定义角色,清理旧缓存并改变操作能力', async ({ page }, info) => {
  110. await signIn(page, 'reader')
  111. await page.goto('/content/models')
  112. await expect(page.getByText('只读权限', { exact: true })).toBeVisible()
  113. await shot(page, info, '04-custom-reader-model-list')
  114. const dialog = await openSwitcher(page)
  115. await expect(dialog.getByRole('button', { name: /权限实测·模型只读/ })).toContainText('当前角色')
  116. await shot(page, info, '05-avatar-role-switch-dialog')
  117. await dialog.getByRole('button', { name: /权限实测·模型编辑/ }).click()
  118. await expect(page).toHaveURL(/\/dashboard$/)
  119. await expect(page.getByRole('button', { name: /^账号菜单:/ })).toContainText('权限实测·模型编辑')
  120. await page.goto('/content/models')
  121. await expect(page.getByText('只读权限', { exact: true })).toHaveCount(0)
  122. await expect(page.getByRole('button', { name: /新建模型|新增模型/ }).first()).toBeVisible()
  123. await shot(page, info, '06-custom-editor-model-list')
  124. const scopes = await page.evaluate(() => Object.keys(JSON.parse(localStorage.getItem('unreal-tran:web:tabs:v2') || '{}')))
  125. const data = fixture()
  126. expect(scopes).toContain(`${data.user.id}:${data.reader.id}`)
  127. expect(scopes).toContain(`${data.user.id}:${data.editor.id}`)
  128. await page.goto('/system/roles')
  129. await expect(page).not.toHaveURL(/\/system\/roles$/)
  130. })
  131. test('角色切换接口失败保留原身份与页面', async ({ page }, info) => {
  132. await signIn(page, 'reader')
  133. await page.goto('/content/models')
  134. await expect(page.getByText('只读权限', { exact: true })).toBeVisible()
  135. await page.route('**/api/auth/v1/auth/active-role', route => route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ code: 'UNAVAILABLE', message: '角色服务临时不可用', data: null }) }))
  136. const dialog = await openSwitcher(page)
  137. await dialog.getByRole('button', { name: /权限实测·模型编辑/ }).click()
  138. await expect(page.locator('.el-message--error')).toContainText('角色服务临时不可用')
  139. await expect(page.getByRole('button', { name: /^账号菜单:/ })).toContainText('权限实测·模型只读')
  140. await expect(page).toHaveURL(/\/content\/models$/)
  141. await shot(page, info, '07-failed-switch-keeps-current-role')
  142. })
  143. test('切换已提交但身份加载失败,不展示旧角色业务页面', async ({ page }, info) => {
  144. await signIn(page, 'reader')
  145. await page.goto('/content/models')
  146. await expect(page.getByText('只读权限', { exact: true })).toBeVisible()
  147. await page.route('**/api/auth/v1/auth/me', route => route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ code: 'UNAVAILABLE', message: '身份信息临时不可用', data: null }) }))
  148. const dialog = await openSwitcher(page)
  149. await dialog.getByRole('button', { name: /权限实测·模型编辑/ }).click()
  150. await expect(page).toHaveURL(/\/login/)
  151. await expect(page.getByRole('button', { name: /^账号菜单:/ })).toHaveCount(0)
  152. await shot(page, info, '08-role-refresh-failure-clears-old-view')
  153. })
  154. test('训练编排未保存时,取消切换保留当前内容与身份', async ({ page }, info) => {
  155. await signIn(page, 'teacher')
  156. await page.goto('/content/training-projects/164/legacy')
  157. await page.getByRole('button', { name: '项目设置', exact: true }).click()
  158. const name = page.locator('[data-field="name"][data-scope="project"]')
  159. await expect(name).toBeVisible()
  160. await name.fill('权限切换未保存验证')
  161. await name.blur()
  162. await page.locator('[data-action="close-dialog"]').filter({ hasText: '完成' }).click()
  163. const switcher = await openSwitcher(page)
  164. let submitted = 0
  165. page.on('request', request => { if (request.url().endsWith('/auth/active-role') && request.method() === 'PUT') submitted++ })
  166. await switcher.getByRole('button', { name: /权限实测·模型只读/ }).click()
  167. const warning = page.getByRole('dialog', { name: '离开训练编排?', exact: true })
  168. await expect(warning).toBeVisible()
  169. await shot(page, info, '09-unsaved-training-blocks-role-switch')
  170. await warning.getByRole('button', { name: /close|关闭/i }).click()
  171. await expect(warning).not.toBeVisible()
  172. await expect(page.getByRole('button', { name: /^账号菜单:/ })).toContainText('教员')
  173. expect(submitted).toBe(0)
  174. await switcher.getByRole('button', { name: '关闭', exact: true }).click()
  175. await page.getByRole('button', { name: '项目设置', exact: true }).click()
  176. await expect(name).toHaveValue('权限切换未保存验证')
  177. })
  178. test('未保存训练的保存请求失败时,阻止提交角色切换', async ({ page }, info) => {
  179. await signIn(page, 'teacher')
  180. await page.goto('/content/training-projects/164/legacy')
  181. await page.getByRole('button', { name: '项目设置', exact: true }).click()
  182. const name = page.locator('[data-field="name"][data-scope="project"]')
  183. await name.fill('保存失败角色切换验证')
  184. await name.blur()
  185. await page.locator('[data-action="close-dialog"]').filter({ hasText: '完成' }).click()
  186. let roleWrites = 0
  187. let failedSaves = 0
  188. page.on('request', request => { if (request.url().endsWith('/auth/active-role') && request.method() === 'PUT') roleWrites++ })
  189. await page.route('**/api/tran/v1/content/projects/164', async route => {
  190. if (route.request().method() !== 'PUT') return route.continue()
  191. failedSaves++
  192. return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ code: 'UNAVAILABLE', message: '实测保存失败,请稍后重试', data: null }) })
  193. })
  194. const switcher = await openSwitcher(page)
  195. await switcher.getByRole('button', { name: /权限实测·模型只读/ }).click()
  196. await page.getByRole('dialog', { name: '离开训练编排?', exact: true }).getByRole('button', { name: '保存并离开' }).click()
  197. await expect.poll(() => failedSaves).toBe(1)
  198. await expect(page.locator('.el-message--error')).toContainText('实测保存失败')
  199. await expect(page.getByRole('button', { name: /^账号菜单:/ })).toContainText('教员')
  200. expect(roleWrites).toBe(0)
  201. await shot(page, info, '09b-save-failure-blocks-role-switch')
  202. await switcher.getByRole('button', { name: '关闭', exact: true }).click()
  203. await page.getByRole('button', { name: '项目设置', exact: true }).click()
  204. await expect(name).toHaveValue('保存失败角色切换验证')
  205. })