Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 
 

311 строки
18 KiB

  1. import { test, expect, type Page } from '@playwright/test'
  2. import crypto from 'node:crypto'
  3. import { action, call, closeSession, diagnostics, evidence, freshScene, openEditor, ready, save, shot } from './scene-editor-closure-helpers'
  4. // @ts-ignore Credentials and temporary-user passwords remain in the private fixture only.
  5. import { fixture, writeFixture, login } from '../tools/scene-editor-closure-live.mjs'
  6. type Kind = 'update' | 'publish' | 'no-scene'
  7. const grants: Record<Kind, string[]> = {
  8. update: ['dashboard.view', 'content.scene', 'content.scene.update'],
  9. publish: ['dashboard.view', 'content.scene', 'content.scene.publish'],
  10. 'no-scene': ['dashboard.view'],
  11. }
  12. // Each mutation reloads the current fixture synchronously, so parallel independent
  13. // test authors do not overwrite project registrations made while an API call waits.
  14. function registerPrivateUser(user: any) {
  15. const current = fixture()
  16. current.extraUsers = [...(current.extraUsers || []), user]
  17. writeFixture(current)
  18. }
  19. function registerPrivateRole(id: string) {
  20. const current = fixture()
  21. current.roleIds = [...(current.roleIds || []), id]
  22. writeFixture(current)
  23. }
  24. async function prepareActor(page: Page, kind: Kind, includeReadOnlyRole = false) {
  25. const maintenance = await login('root')
  26. const rootToken = maintenance.accessToken
  27. let issuedToken: string | null = null
  28. let prepared = false
  29. try {
  30. const me = await call('auth/v1/auth/me', rootToken)
  31. expect(me.status).toBe(200)
  32. const roles = await call('auth/v1/roles/all', rootToken)
  33. expect(roles.status).toBe(200)
  34. const teacher = (roles.data.records || roles.data).find((role: any) => role.code === 'teacher')
  35. expect(teacher?.id).toBeTruthy()
  36. const suffix = crypto.randomBytes(4).toString('hex')
  37. const role = await call('auth/v1/roles', rootToken, {
  38. code: `scene_${kind.replace('-', '_')}_${suffix}`,
  39. name: `场景验收·${kind === 'update' ? '仅编辑' : kind === 'publish' ? '仅发布' : '无场景权限'}-${suffix}`,
  40. shortName: '测', description: 'UTE2E-SCENE-CLOSURE-20260906',
  41. dataScope: 'ALL', enabled: true, sortOrder: 95, departmentScopeIds: [],
  42. })
  43. expect(role.status, 'Create isolated role').toBe(200)
  44. registerPrivateRole(role.data.id)
  45. const permissionSave = await call(`auth/v1/permissions/roles/${role.data.id}`, rootToken, {
  46. permissionCodes: grants[kind], version: role.data.dataVersion ?? role.data.version,
  47. }, 'PUT')
  48. expect(permissionSave.status, 'Save isolated action permissions').toBe(200)
  49. let readOnlyRole: any = null
  50. if (includeReadOnlyRole) {
  51. const extra = await call('auth/v1/roles', rootToken, {
  52. code: `scene_read_${suffix}`, name: `场景验收·仅查看-${suffix}`, shortName: '测',
  53. description: 'UTE2E-SCENE-CLOSURE-20260906', dataScope: 'ALL', enabled: true,
  54. sortOrder: 96, departmentScopeIds: [],
  55. })
  56. expect(extra.status).toBe(200)
  57. readOnlyRole = extra.data
  58. registerPrivateRole(readOnlyRole.id)
  59. const saved = await call(`auth/v1/permissions/roles/${readOnlyRole.id}`, rootToken, {
  60. permissionCodes: ['dashboard.view', 'content.scene'], version: readOnlyRole.dataVersion ?? readOnlyRole.version,
  61. }, 'PUT')
  62. expect(saved.status).toBe(200)
  63. }
  64. const password = `Sc!${crypto.randomBytes(7).toString('hex')}`
  65. const created = await call('auth/v1/users', rootToken, {
  66. username: `scene_perm_${suffix}`, displayName: `场景权限验收${suffix.slice(0, 4)}`,
  67. departmentId: me.data.departmentId, roleIds: [teacher.id, role.data.id, ...(readOnlyRole ? [readOnlyRole.id] : [])], defaultRoleId: teacher.id,
  68. enabled: true, password, remark: 'UTE2E-SCENE-CLOSURE-20260906',
  69. })
  70. expect(created.status, 'Create isolated multi-role account').toBe(200)
  71. registerPrivateUser({ ...created.data, password })
  72. const signedIn = await call('auth/v1/auth/login', null, {
  73. userId: created.data.id, password, roleCode: 'teacher', departmentId: me.data.departmentId, rememberMe: false,
  74. })
  75. expect(signedIn.status, 'Sign in through the existing teacher entry').toBe(200)
  76. let session = signedIn.data
  77. issuedToken = session.accessToken
  78. const switched = await call('auth/v1/auth/active-role', session.accessToken, { roleId: role.data.id }, 'PUT')
  79. expect(switched.status, 'Activate the isolated limited role').toBe(200)
  80. if (switched.data?.accessToken) session = switched.data
  81. issuedToken = session.accessToken
  82. const actor = await call('auth/v1/auth/me', session.accessToken)
  83. expect(actor.status).toBe(200)
  84. expect(actor.data.authorizationMode).toBe('SINGLE_ACTIVE')
  85. expect(String(actor.data.activeRoleId)).toBe(String(role.data.id))
  86. expect(actor.data.permissions).not.toContain('content.model')
  87. expect(actor.data.permissions).not.toContain('content.model.update')
  88. expect(actor.data.permissions).not.toContain(kind === 'update' ? 'content.scene.publish' : 'content.scene.update')
  89. await page.addInitScript(({ accessToken, refreshToken }) => {
  90. if (!sessionStorage.getItem('scene-permission-closure-initialized')) {
  91. sessionStorage.setItem('unreal-tran:web:access-token:v1', accessToken)
  92. sessionStorage.setItem('unreal-tran:web:refresh-token:v1', refreshToken || '')
  93. sessionStorage.setItem('scene-permission-closure-initialized', '1')
  94. }
  95. }, { accessToken: session.accessToken, refreshToken: session.refreshToken })
  96. const copy = kind === 'no-scene' ? null : await freshScene(rootToken, 'fox', `权限-${kind}`)
  97. prepared = true
  98. return { token: session.accessToken as string, copy, permissionCodes: grants[kind], roleId: role.data.id, readOnlyRole }
  99. } finally {
  100. if (!prepared && issuedToken) await call('auth/v1/auth/logout', issuedToken, {}).catch(() => {})
  101. await call('auth/v1/auth/logout', rootToken, {}).catch(() => {})
  102. }
  103. }
  104. test.afterEach(async ({ page }) => closeSession(page))
  105. test('仅场景编辑权限:固定模型可加载和保存,不能发布或访问模型页面', async ({ page }, info) => {
  106. const actor = await prepareActor(page, 'update'), id = actor.copy.project.id
  107. await openEditor(page, id)
  108. const before = await diagnostics(page)
  109. expect(before.readOnly).toBe(false)
  110. const model = before.objects.find((object: any) => object.targetProjectId === '157')
  111. expect(model).toBeTruthy()
  112. expect(model.parts.length).toBeGreaterThan(0)
  113. await expect(action(page, 'save')).toBeEnabled()
  114. await expect(action(page, 'publish')).toBeDisabled()
  115. await expect(action(page, 'new')).toBeDisabled()
  116. await page.locator(`#se-tree [data-object-id="${model.sceneId}"]`).click()
  117. await action(page, 'focus').click()
  118. const name = `仅场景编辑权限保存-${id}`
  119. const input = page.locator('[data-inspector-field="name"]')
  120. await input.fill(name)
  121. await input.press('Tab')
  122. await save(page)
  123. const saved = await call(`tran/v1/content/projects/${id}`, actor.token)
  124. expect(saved.status).toBe(200)
  125. const dependencies = (values: any[]) => values.map(value => [value.targetProjectId, value.targetVersionId, value.relationType, value.required])
  126. expect(dependencies(saved.data.currentVersion.dependencies)).toEqual(dependencies(actor.copy.currentVersion.dependencies))
  127. expect(saved.data.currentVersion.content.objects.find((object: any) => object.sceneId === model.sceneId).name).toBe(name)
  128. await page.reload(); await ready(page)
  129. expect((await diagnostics(page)).objects.find((object: any) => object.sceneId === model.sceneId).name).toBe(name)
  130. await page.locator(`#se-tree [data-object-id="${model.sceneId}"]`).click()
  131. await action(page, 'focus').click()
  132. const forbiddenModel = await call('tran/v1/content/projects/157', actor.token)
  133. expect(forbiddenModel.status).toBe(403)
  134. const forbiddenPublish = await call(`tran/v1/content/projects/${id}/publish`, actor.token, { version: saved.data.project.version })
  135. expect(forbiddenPublish.status).toBe(403)
  136. await shot(page, info, '51-scene-update-only-saved')
  137. evidence('scene-update-only', { featureIds: ['permission.update', 'permission.publish', 'publish.fixed-model'], status: 'PASS',
  138. kind: 'browser', testTitle: info.title, projectId: id, permissionCodes: actor.permissionCodes, dependencies: dependencies(saved.data.currentVersion.dependencies),
  139. savedName: name, directModelReadStatus: forbiddenModel.status, directPublishStatus: forbiddenPublish.status })
  140. })
  141. test('仅场景发布权限:浏览器发布已存副本,不发送保存或上传请求', async ({ page }, info) => {
  142. const actor = await prepareActor(page, 'publish'), id = actor.copy.project.id
  143. const writes: Array<{ method: string; path: string }> = []
  144. page.on('request', request => {
  145. const pathname = new URL(request.url()).pathname
  146. if (pathname.startsWith(`/api/tran/v1/content/projects/${id}`) && !['GET', 'HEAD'].includes(request.method())) {
  147. writes.push({ method: request.method(), path: pathname })
  148. }
  149. })
  150. await openEditor(page, id)
  151. expect((await diagnostics(page)).readOnly).toBe(true)
  152. await expect(action(page, 'save')).toBeDisabled()
  153. await expect(action(page, 'publish')).toBeEnabled()
  154. const response = page.waitForResponse(value => value.url().endsWith(`/content/projects/${id}/publish`) && value.request().method() === 'POST')
  155. await action(page, 'publish').click()
  156. expect((await response).status()).toBe(200)
  157. await expect.poll(async () => (await diagnostics(page)).publishing).toBe(false)
  158. await expect(action(page, 'save')).toBeDisabled()
  159. const published = await call(`tran/v1/content/projects/${id}`, actor.token)
  160. expect(published.status).toBe(200)
  161. expect(published.data.project.status).toBe('PUBLISHED')
  162. expect(published.data.currentVersion.content).toEqual(actor.copy.currentVersion.content)
  163. expect(writes).toEqual([{ method: 'POST', path: `/api/tran/v1/content/projects/${id}/publish` }])
  164. await shot(page, info, '52-scene-publish-only-no-save')
  165. evidence('scene-publish-only', { featureIds: ['permission.publish', 'publish.only', 'publish.fixed-model'], status: 'PASS', kind: 'browser', testTitle: info.title,
  166. projectId: id, permissionCodes: actor.permissionCodes, versionId: published.data.project.publishedVersionId, writes })
  167. })
  168. test('无场景页面权限:编辑路由和直接内容接口均拒绝访问', async ({ page }, info) => {
  169. const actor = await prepareActor(page, 'no-scene'), id = fixture().originals.fox.id
  170. await page.goto(`/content/scenes/${id}/edit`)
  171. await expect(page).not.toHaveURL(new RegExp(`/content/scenes/${id}/edit`))
  172. await expect(page.locator('.se-shell')).toHaveCount(0)
  173. const denied = await call(`tran/v1/content/projects/${id}`, actor.token)
  174. expect(denied.status).toBe(403)
  175. await shot(page, info, '53-scene-route-access-denied')
  176. evidence('scene-no-page-permission', { featureIds: ['permission.publish'], status: 'PASS', kind: 'browser', testTitle: info.title,
  177. permissionCodes: actor.permissionCodes, route: new URL(page.url()).pathname, directReadStatus: denied.status })
  178. })
  179. async function editFoxName(page: Page, name: string) {
  180. const model = (await diagnostics(page)).objects.find((object: any) => object.targetProjectId === '157')
  181. expect(model).toBeTruthy()
  182. await page.locator(`#se-tree [data-object-id="${model.sceneId}"]`).click()
  183. const input = page.locator('[data-inspector-field="name"]')
  184. await input.fill(name); await input.press('Tab')
  185. return model.sceneId as string
  186. }
  187. async function openRoleDialog(page: Page) {
  188. await page.getByRole('button', { name: /^账号菜单:/ }).click()
  189. await page.getByRole('menuitem', { name: '切换角色', exact: true }).click()
  190. const dialog = page.getByRole('dialog', { name: '切换角色', exact: true })
  191. await expect(dialog).toBeVisible()
  192. return dialog
  193. }
  194. test('后台场景未保存时切换角色:关闭确认保留草稿,保存切换后重建只读权限', async ({ page }, info) => {
  195. const actor = await prepareActor(page, 'update', true), id = actor.copy.project.id
  196. const roleRequests: string[] = []
  197. page.on('request', request => {
  198. if (new URL(request.url()).pathname === '/api/auth/v1/auth/active-role' && request.method() === 'PUT') roleRequests.push('PUT')
  199. })
  200. await openEditor(page, id)
  201. const name = `后台场景保存后切换-${id}`
  202. const objectId = await editFoxName(page, name)
  203. await page.getByRole('tab', { name: '工作总览', exact: true }).click()
  204. let switcher = await openRoleDialog(page)
  205. await switcher.getByRole('button', { name: new RegExp(actor.readOnlyRole.name) }).click()
  206. const leave = page.getByRole('dialog', { name: '离开场景编辑器', exact: true })
  207. await expect(leave).toBeVisible()
  208. await leave.locator('.el-message-box__headerbtn').click()
  209. await expect(leave).not.toBeVisible()
  210. expect(roleRequests).toEqual([])
  211. const unchangedRole = await call('auth/v1/auth/me', actor.token)
  212. expect(String(unchangedRole.data.activeRoleId)).toBe(String(actor.roleId))
  213. const unchangedProject = await call(`tran/v1/content/projects/${id}`, actor.token)
  214. expect(unchangedProject.data.project.version).toBe(actor.copy.project.version)
  215. expect(unchangedProject.data.currentVersion.content).toEqual(actor.copy.currentVersion.content)
  216. await switcher.getByRole('button', { name: '关闭', exact: true }).click()
  217. await page.getByRole('tab', { name: '数字场景编辑', exact: true }).click()
  218. await ready(page)
  219. expect((await diagnostics(page)).dirty).toBe(true)
  220. expect((await diagnostics(page)).objects.find((object: any) => object.sceneId === objectId).name).toBe(name)
  221. await shot(page, info, '54-scene-role-switch-cancel-keeps-draft')
  222. await page.getByRole('tab', { name: '工作总览', exact: true }).click()
  223. switcher = await openRoleDialog(page)
  224. await switcher.getByRole('button', { name: new RegExp(actor.readOnlyRole.name) }).click()
  225. await expect(leave).toBeVisible()
  226. await leave.getByRole('button', { name: '保存并离开', exact: true }).click()
  227. await expect(page).toHaveURL(/\/dashboard$/)
  228. await expect(page.getByRole('button', { name: /^账号菜单:/ })).toContainText(actor.readOnlyRole.name)
  229. expect(roleRequests).toEqual(['PUT'])
  230. const token = await page.evaluate(() => sessionStorage.getItem('unreal-tran:web:access-token:v1'))
  231. const switched = await call('auth/v1/auth/me', token)
  232. expect(String(switched.data.activeRoleId)).toBe(String(actor.readOnlyRole.id))
  233. expect(switched.data.permissions).not.toContain('content.scene.update')
  234. await openEditor(page, id)
  235. expect((await diagnostics(page)).readOnly).toBe(true)
  236. expect((await diagnostics(page)).objects.find((object: any) => object.sceneId === objectId).name).toBe(name)
  237. await expect(action(page, 'save')).toBeDisabled()
  238. await expect(action(page, 'publish')).toBeDisabled()
  239. const saved = await call(`tran/v1/content/projects/${id}`, token)
  240. expect(saved.data.project.version).toBe(actor.copy.project.version + 1)
  241. expect(saved.data.currentVersion.content.objects.find((object: any) => object.sceneId === objectId).name).toBe(name)
  242. await shot(page, info, '55-scene-role-switch-saved-readonly')
  243. evidence('scene-role-switch', { featureIds: ['leave.role-switch', 'leave.cancel', 'leave.save', 'permission.readonly'],
  244. status: 'PASS', kind: 'browser', testTitle: info.title, projectId: id, savedName: name,
  245. roleChangeRequests: roleRequests.length, savedVersion: saved.data.project.version, readOnly: true })
  246. })
  247. async function recoveries(page: Page, id: string) {
  248. return page.evaluate(projectId => Object.keys(localStorage)
  249. .filter(key => key.includes(`edit3dv4:scene-api-recovery:${projectId}:session:`))
  250. .map(key => {
  251. const value = JSON.parse(localStorage.getItem(key) || '{}')
  252. return { key, nonce: value.nonce, version: value.projectVersion,
  253. name: value.document?.objects?.find((object: any) => object.targetProjectId === '157')?.name }
  254. }), id)
  255. }
  256. test('双标签各自修改场景:保存和明确丢弃当前页均保留另一页真实恢复稿', async ({ page, context }, info) => {
  257. const actor = await prepareActor(page, 'update'), id = actor.copy.project.id
  258. const other = await context.newPage()
  259. try {
  260. await other.addInitScript(token => {
  261. sessionStorage.setItem('unreal-tran:web:access-token:v1', token)
  262. }, actor.token)
  263. await openEditor(page, id)
  264. await openEditor(other, id)
  265. const nameA = `标签A已保存-${id}`, nameB = `标签B保持未保存-${id}`
  266. await editFoxName(page, nameA)
  267. await editFoxName(other, nameB)
  268. await expect.poll(async () => (await recoveries(page, id)).map(value => value.name).sort()).toEqual([nameA, nameB].sort())
  269. const originalB = (await recoveries(page, id)).find(value => value.name === nameB)!
  270. const before = await call(`tran/v1/content/projects/${id}`, actor.token)
  271. expect(before.data.project.version).toBe(actor.copy.project.version)
  272. expect(before.data.currentVersion.content).toEqual(actor.copy.currentVersion.content)
  273. await save(page)
  274. await expect.poll(async () => recoveries(page, id)).toEqual([originalB])
  275. expect((await diagnostics(other)).dirty).toBe(true)
  276. expect((await diagnostics(other)).objects.find((object: any) => object.targetProjectId === '157').name).toBe(nameB)
  277. const saved = await call(`tran/v1/content/projects/${id}`, actor.token)
  278. expect(saved.data.project.version).toBe(actor.copy.project.version + 1)
  279. expect(saved.data.currentVersion.content.objects.find((object: any) => object.targetProjectId === '157').name).toBe(nameA)
  280. await editFoxName(page, `标签A明确丢弃-${id}`)
  281. await expect.poll(async () => (await recoveries(page, id)).length).toBe(2)
  282. await page.reload()
  283. const recovery = page.getByRole('dialog', { name: '恢复未提交场景', exact: true })
  284. await expect(recovery).toBeVisible()
  285. await recovery.getByRole('button', { name: '删除副本并使用服务端', exact: true }).click()
  286. await ready(page)
  287. await expect.poll(async () => recoveries(other, id)).toEqual([originalB])
  288. const final = await call(`tran/v1/content/projects/${id}`, actor.token)
  289. expect(final.data.project.version).toBe(saved.data.project.version)
  290. expect(final.data.currentVersion.content).toEqual(saved.data.currentVersion.content)
  291. expect((await diagnostics(other)).dirty).toBe(true)
  292. await shot(other, info, '56-scene-other-tab-recovery-preserved')
  293. evidence('scene-multi-session', { featureIds: ['recovery.multi-session', 'recovery.autobuffer', 'recovery.discard', 'project.save'],
  294. status: 'PASS', kind: 'browser', testTitle: info.title, projectId: id, savedName: nameA,
  295. otherDraftName: originalB.name, otherDraftVersion: originalB.version, remainingRecoveryCount: 1,
  296. serverVersion: final.data.project.version })
  297. } finally { await other.close() }
  298. })