|
- import { test, expect, type Page } from '@playwright/test'
- import crypto from 'node:crypto'
- import { action, call, closeSession, diagnostics, evidence, freshScene, openEditor, ready, save, shot } from './scene-editor-closure-helpers'
- // @ts-ignore Credentials and temporary-user passwords remain in the private fixture only.
- import { fixture, writeFixture, login } from '../tools/scene-editor-closure-live.mjs'
-
- type Kind = 'update' | 'publish' | 'no-scene'
- const grants: Record<Kind, string[]> = {
- update: ['dashboard.view', 'content.scene', 'content.scene.update'],
- publish: ['dashboard.view', 'content.scene', 'content.scene.publish'],
- 'no-scene': ['dashboard.view'],
- }
-
- // Each mutation reloads the current fixture synchronously, so parallel independent
- // test authors do not overwrite project registrations made while an API call waits.
- function registerPrivateUser(user: any) {
- const current = fixture()
- current.extraUsers = [...(current.extraUsers || []), user]
- writeFixture(current)
- }
- function registerPrivateRole(id: string) {
- const current = fixture()
- current.roleIds = [...(current.roleIds || []), id]
- writeFixture(current)
- }
-
- async function prepareActor(page: Page, kind: Kind, includeReadOnlyRole = false) {
- const maintenance = await login('root')
- const rootToken = maintenance.accessToken
- let issuedToken: string | null = null
- let prepared = false
- try {
- const me = await call('auth/v1/auth/me', rootToken)
- expect(me.status).toBe(200)
- const roles = await call('auth/v1/roles/all', rootToken)
- expect(roles.status).toBe(200)
- const teacher = (roles.data.records || roles.data).find((role: any) => role.code === 'teacher')
- expect(teacher?.id).toBeTruthy()
- const suffix = crypto.randomBytes(4).toString('hex')
- const role = await call('auth/v1/roles', rootToken, {
- code: `scene_${kind.replace('-', '_')}_${suffix}`,
- name: `场景验收·${kind === 'update' ? '仅编辑' : kind === 'publish' ? '仅发布' : '无场景权限'}-${suffix}`,
- shortName: '测', description: 'UTE2E-SCENE-CLOSURE-20260906',
- dataScope: 'ALL', enabled: true, sortOrder: 95, departmentScopeIds: [],
- })
- expect(role.status, 'Create isolated role').toBe(200)
- registerPrivateRole(role.data.id)
- const permissionSave = await call(`auth/v1/permissions/roles/${role.data.id}`, rootToken, {
- permissionCodes: grants[kind], version: role.data.dataVersion ?? role.data.version,
- }, 'PUT')
- expect(permissionSave.status, 'Save isolated action permissions').toBe(200)
- let readOnlyRole: any = null
- if (includeReadOnlyRole) {
- const extra = await call('auth/v1/roles', rootToken, {
- code: `scene_read_${suffix}`, name: `场景验收·仅查看-${suffix}`, shortName: '测',
- description: 'UTE2E-SCENE-CLOSURE-20260906', dataScope: 'ALL', enabled: true,
- sortOrder: 96, departmentScopeIds: [],
- })
- expect(extra.status).toBe(200)
- readOnlyRole = extra.data
- registerPrivateRole(readOnlyRole.id)
- const saved = await call(`auth/v1/permissions/roles/${readOnlyRole.id}`, rootToken, {
- permissionCodes: ['dashboard.view', 'content.scene'], version: readOnlyRole.dataVersion ?? readOnlyRole.version,
- }, 'PUT')
- expect(saved.status).toBe(200)
- }
- const password = `Sc!${crypto.randomBytes(7).toString('hex')}`
- const created = await call('auth/v1/users', rootToken, {
- username: `scene_perm_${suffix}`, displayName: `场景权限验收${suffix.slice(0, 4)}`,
- departmentId: me.data.departmentId, roleIds: [teacher.id, role.data.id, ...(readOnlyRole ? [readOnlyRole.id] : [])], defaultRoleId: teacher.id,
- enabled: true, password, remark: 'UTE2E-SCENE-CLOSURE-20260906',
- })
- expect(created.status, 'Create isolated multi-role account').toBe(200)
- registerPrivateUser({ ...created.data, password })
- const signedIn = await call('auth/v1/auth/login', null, {
- userId: created.data.id, password, roleCode: 'teacher', departmentId: me.data.departmentId, rememberMe: false,
- })
- expect(signedIn.status, 'Sign in through the existing teacher entry').toBe(200)
- let session = signedIn.data
- issuedToken = session.accessToken
- const switched = await call('auth/v1/auth/active-role', session.accessToken, { roleId: role.data.id }, 'PUT')
- expect(switched.status, 'Activate the isolated limited role').toBe(200)
- if (switched.data?.accessToken) session = switched.data
- issuedToken = session.accessToken
- const actor = await call('auth/v1/auth/me', session.accessToken)
- expect(actor.status).toBe(200)
- expect(actor.data.authorizationMode).toBe('SINGLE_ACTIVE')
- expect(String(actor.data.activeRoleId)).toBe(String(role.data.id))
- expect(actor.data.permissions).not.toContain('content.model')
- expect(actor.data.permissions).not.toContain('content.model.update')
- expect(actor.data.permissions).not.toContain(kind === 'update' ? 'content.scene.publish' : 'content.scene.update')
- await page.addInitScript(({ accessToken, refreshToken }) => {
- if (!sessionStorage.getItem('scene-permission-closure-initialized')) {
- sessionStorage.setItem('unreal-tran:web:access-token:v1', accessToken)
- sessionStorage.setItem('unreal-tran:web:refresh-token:v1', refreshToken || '')
- sessionStorage.setItem('scene-permission-closure-initialized', '1')
- }
- }, { accessToken: session.accessToken, refreshToken: session.refreshToken })
- const copy = kind === 'no-scene' ? null : await freshScene(rootToken, 'fox', `权限-${kind}`)
- prepared = true
- return { token: session.accessToken as string, copy, permissionCodes: grants[kind], roleId: role.data.id, readOnlyRole }
- } finally {
- if (!prepared && issuedToken) await call('auth/v1/auth/logout', issuedToken, {}).catch(() => {})
- await call('auth/v1/auth/logout', rootToken, {}).catch(() => {})
- }
- }
-
- test.afterEach(async ({ page }) => closeSession(page))
-
- test('仅场景编辑权限:固定模型可加载和保存,不能发布或访问模型页面', async ({ page }, info) => {
- const actor = await prepareActor(page, 'update'), id = actor.copy.project.id
- await openEditor(page, id)
- const before = await diagnostics(page)
- expect(before.readOnly).toBe(false)
- const model = before.objects.find((object: any) => object.targetProjectId === '157')
- expect(model).toBeTruthy()
- expect(model.parts.length).toBeGreaterThan(0)
- await expect(action(page, 'save')).toBeEnabled()
- await expect(action(page, 'publish')).toBeDisabled()
- await expect(action(page, 'new')).toBeDisabled()
- await page.locator(`#se-tree [data-object-id="${model.sceneId}"]`).click()
- await action(page, 'focus').click()
- const name = `仅场景编辑权限保存-${id}`
- const input = page.locator('[data-inspector-field="name"]')
- await input.fill(name)
- await input.press('Tab')
- await save(page)
- const saved = await call(`tran/v1/content/projects/${id}`, actor.token)
- expect(saved.status).toBe(200)
- const dependencies = (values: any[]) => values.map(value => [value.targetProjectId, value.targetVersionId, value.relationType, value.required])
- expect(dependencies(saved.data.currentVersion.dependencies)).toEqual(dependencies(actor.copy.currentVersion.dependencies))
- expect(saved.data.currentVersion.content.objects.find((object: any) => object.sceneId === model.sceneId).name).toBe(name)
- await page.reload(); await ready(page)
- expect((await diagnostics(page)).objects.find((object: any) => object.sceneId === model.sceneId).name).toBe(name)
- await page.locator(`#se-tree [data-object-id="${model.sceneId}"]`).click()
- await action(page, 'focus').click()
- const forbiddenModel = await call('tran/v1/content/projects/157', actor.token)
- expect(forbiddenModel.status).toBe(403)
- const forbiddenPublish = await call(`tran/v1/content/projects/${id}/publish`, actor.token, { version: saved.data.project.version })
- expect(forbiddenPublish.status).toBe(403)
- await shot(page, info, '51-scene-update-only-saved')
- evidence('scene-update-only', { featureIds: ['permission.update', 'permission.publish', 'publish.fixed-model'], status: 'PASS',
- kind: 'browser', testTitle: info.title, projectId: id, permissionCodes: actor.permissionCodes, dependencies: dependencies(saved.data.currentVersion.dependencies),
- savedName: name, directModelReadStatus: forbiddenModel.status, directPublishStatus: forbiddenPublish.status })
- })
-
- test('仅场景发布权限:浏览器发布已存副本,不发送保存或上传请求', async ({ page }, info) => {
- const actor = await prepareActor(page, 'publish'), id = actor.copy.project.id
- const writes: Array<{ method: string; path: string }> = []
- page.on('request', request => {
- const pathname = new URL(request.url()).pathname
- if (pathname.startsWith(`/api/tran/v1/content/projects/${id}`) && !['GET', 'HEAD'].includes(request.method())) {
- writes.push({ method: request.method(), path: pathname })
- }
- })
- await openEditor(page, id)
- expect((await diagnostics(page)).readOnly).toBe(true)
- await expect(action(page, 'save')).toBeDisabled()
- await expect(action(page, 'publish')).toBeEnabled()
- const response = page.waitForResponse(value => value.url().endsWith(`/content/projects/${id}/publish`) && value.request().method() === 'POST')
- await action(page, 'publish').click()
- expect((await response).status()).toBe(200)
- await expect.poll(async () => (await diagnostics(page)).publishing).toBe(false)
- await expect(action(page, 'save')).toBeDisabled()
- const published = await call(`tran/v1/content/projects/${id}`, actor.token)
- expect(published.status).toBe(200)
- expect(published.data.project.status).toBe('PUBLISHED')
- expect(published.data.currentVersion.content).toEqual(actor.copy.currentVersion.content)
- expect(writes).toEqual([{ method: 'POST', path: `/api/tran/v1/content/projects/${id}/publish` }])
- await shot(page, info, '52-scene-publish-only-no-save')
- evidence('scene-publish-only', { featureIds: ['permission.publish', 'publish.only', 'publish.fixed-model'], status: 'PASS', kind: 'browser', testTitle: info.title,
- projectId: id, permissionCodes: actor.permissionCodes, versionId: published.data.project.publishedVersionId, writes })
- })
-
- test('无场景页面权限:编辑路由和直接内容接口均拒绝访问', async ({ page }, info) => {
- const actor = await prepareActor(page, 'no-scene'), id = fixture().originals.fox.id
- await page.goto(`/content/scenes/${id}/edit`)
- await expect(page).not.toHaveURL(new RegExp(`/content/scenes/${id}/edit`))
- await expect(page.locator('.se-shell')).toHaveCount(0)
- const denied = await call(`tran/v1/content/projects/${id}`, actor.token)
- expect(denied.status).toBe(403)
- await shot(page, info, '53-scene-route-access-denied')
- evidence('scene-no-page-permission', { featureIds: ['permission.publish'], status: 'PASS', kind: 'browser', testTitle: info.title,
- permissionCodes: actor.permissionCodes, route: new URL(page.url()).pathname, directReadStatus: denied.status })
- })
-
- async function editFoxName(page: Page, name: string) {
- const model = (await diagnostics(page)).objects.find((object: any) => object.targetProjectId === '157')
- expect(model).toBeTruthy()
- await page.locator(`#se-tree [data-object-id="${model.sceneId}"]`).click()
- const input = page.locator('[data-inspector-field="name"]')
- await input.fill(name); await input.press('Tab')
- return model.sceneId as string
- }
-
- async function openRoleDialog(page: Page) {
- await page.getByRole('button', { name: /^账号菜单:/ }).click()
- await page.getByRole('menuitem', { name: '切换角色', exact: true }).click()
- const dialog = page.getByRole('dialog', { name: '切换角色', exact: true })
- await expect(dialog).toBeVisible()
- return dialog
- }
-
- test('后台场景未保存时切换角色:关闭确认保留草稿,保存切换后重建只读权限', async ({ page }, info) => {
- const actor = await prepareActor(page, 'update', true), id = actor.copy.project.id
- const roleRequests: string[] = []
- page.on('request', request => {
- if (new URL(request.url()).pathname === '/api/auth/v1/auth/active-role' && request.method() === 'PUT') roleRequests.push('PUT')
- })
- await openEditor(page, id)
- const name = `后台场景保存后切换-${id}`
- const objectId = await editFoxName(page, name)
- await page.getByRole('tab', { name: '工作总览', exact: true }).click()
- let switcher = await openRoleDialog(page)
- await switcher.getByRole('button', { name: new RegExp(actor.readOnlyRole.name) }).click()
- const leave = page.getByRole('dialog', { name: '离开场景编辑器', exact: true })
- await expect(leave).toBeVisible()
- await leave.locator('.el-message-box__headerbtn').click()
- await expect(leave).not.toBeVisible()
- expect(roleRequests).toEqual([])
- const unchangedRole = await call('auth/v1/auth/me', actor.token)
- expect(String(unchangedRole.data.activeRoleId)).toBe(String(actor.roleId))
- const unchangedProject = await call(`tran/v1/content/projects/${id}`, actor.token)
- expect(unchangedProject.data.project.version).toBe(actor.copy.project.version)
- expect(unchangedProject.data.currentVersion.content).toEqual(actor.copy.currentVersion.content)
- await switcher.getByRole('button', { name: '关闭', exact: true }).click()
- await page.getByRole('tab', { name: '数字场景编辑', exact: true }).click()
- await ready(page)
- expect((await diagnostics(page)).dirty).toBe(true)
- expect((await diagnostics(page)).objects.find((object: any) => object.sceneId === objectId).name).toBe(name)
- await shot(page, info, '54-scene-role-switch-cancel-keeps-draft')
- await page.getByRole('tab', { name: '工作总览', exact: true }).click()
- switcher = await openRoleDialog(page)
- await switcher.getByRole('button', { name: new RegExp(actor.readOnlyRole.name) }).click()
- await expect(leave).toBeVisible()
- await leave.getByRole('button', { name: '保存并离开', exact: true }).click()
- await expect(page).toHaveURL(/\/dashboard$/)
- await expect(page.getByRole('button', { name: /^账号菜单:/ })).toContainText(actor.readOnlyRole.name)
- expect(roleRequests).toEqual(['PUT'])
- const token = await page.evaluate(() => sessionStorage.getItem('unreal-tran:web:access-token:v1'))
- const switched = await call('auth/v1/auth/me', token)
- expect(String(switched.data.activeRoleId)).toBe(String(actor.readOnlyRole.id))
- expect(switched.data.permissions).not.toContain('content.scene.update')
- await openEditor(page, id)
- expect((await diagnostics(page)).readOnly).toBe(true)
- expect((await diagnostics(page)).objects.find((object: any) => object.sceneId === objectId).name).toBe(name)
- await expect(action(page, 'save')).toBeDisabled()
- await expect(action(page, 'publish')).toBeDisabled()
- const saved = await call(`tran/v1/content/projects/${id}`, token)
- expect(saved.data.project.version).toBe(actor.copy.project.version + 1)
- expect(saved.data.currentVersion.content.objects.find((object: any) => object.sceneId === objectId).name).toBe(name)
- await shot(page, info, '55-scene-role-switch-saved-readonly')
- evidence('scene-role-switch', { featureIds: ['leave.role-switch', 'leave.cancel', 'leave.save', 'permission.readonly'],
- status: 'PASS', kind: 'browser', testTitle: info.title, projectId: id, savedName: name,
- roleChangeRequests: roleRequests.length, savedVersion: saved.data.project.version, readOnly: true })
- })
-
- async function recoveries(page: Page, id: string) {
- return page.evaluate(projectId => Object.keys(localStorage)
- .filter(key => key.includes(`edit3dv4:scene-api-recovery:${projectId}:session:`))
- .map(key => {
- const value = JSON.parse(localStorage.getItem(key) || '{}')
- return { key, nonce: value.nonce, version: value.projectVersion,
- name: value.document?.objects?.find((object: any) => object.targetProjectId === '157')?.name }
- }), id)
- }
-
- test('双标签各自修改场景:保存和明确丢弃当前页均保留另一页真实恢复稿', async ({ page, context }, info) => {
- const actor = await prepareActor(page, 'update'), id = actor.copy.project.id
- const other = await context.newPage()
- try {
- await other.addInitScript(token => {
- sessionStorage.setItem('unreal-tran:web:access-token:v1', token)
- }, actor.token)
- await openEditor(page, id)
- await openEditor(other, id)
- const nameA = `标签A已保存-${id}`, nameB = `标签B保持未保存-${id}`
- await editFoxName(page, nameA)
- await editFoxName(other, nameB)
- await expect.poll(async () => (await recoveries(page, id)).map(value => value.name).sort()).toEqual([nameA, nameB].sort())
- const originalB = (await recoveries(page, id)).find(value => value.name === nameB)!
- const before = await call(`tran/v1/content/projects/${id}`, actor.token)
- expect(before.data.project.version).toBe(actor.copy.project.version)
- expect(before.data.currentVersion.content).toEqual(actor.copy.currentVersion.content)
- await save(page)
- await expect.poll(async () => recoveries(page, id)).toEqual([originalB])
- expect((await diagnostics(other)).dirty).toBe(true)
- expect((await diagnostics(other)).objects.find((object: any) => object.targetProjectId === '157').name).toBe(nameB)
- const saved = await call(`tran/v1/content/projects/${id}`, actor.token)
- expect(saved.data.project.version).toBe(actor.copy.project.version + 1)
- expect(saved.data.currentVersion.content.objects.find((object: any) => object.targetProjectId === '157').name).toBe(nameA)
- await editFoxName(page, `标签A明确丢弃-${id}`)
- await expect.poll(async () => (await recoveries(page, id)).length).toBe(2)
- await page.reload()
- const recovery = page.getByRole('dialog', { name: '恢复未提交场景', exact: true })
- await expect(recovery).toBeVisible()
- await recovery.getByRole('button', { name: '删除副本并使用服务端', exact: true }).click()
- await ready(page)
- await expect.poll(async () => recoveries(other, id)).toEqual([originalB])
- const final = await call(`tran/v1/content/projects/${id}`, actor.token)
- expect(final.data.project.version).toBe(saved.data.project.version)
- expect(final.data.currentVersion.content).toEqual(saved.data.currentVersion.content)
- expect((await diagnostics(other)).dirty).toBe(true)
- await shot(other, info, '56-scene-other-tab-recovery-preserved')
- evidence('scene-multi-session', { featureIds: ['recovery.multi-session', 'recovery.autobuffer', 'recovery.discard', 'project.save'],
- status: 'PASS', kind: 'browser', testTitle: info.title, projectId: id, savedName: nameA,
- otherDraftName: originalB.name, otherDraftVersion: originalB.version, remainingRecoveryCount: 1,
- serverVersion: final.data.project.version })
- } finally { await other.close() }
- })
|