|
- import { test, expect, type Locator, type Page, type TestInfo } from '@playwright/test'
- import fs from 'node:fs'
- import path from 'node:path'
- // Credentials stay in the local helper and browser session; traces/video are disabled by the live config.
- // @ts-ignore The existing live API helper is JavaScript.
- import { call, login, work } from '../tools/role-permissions-live.mjs'
-
- type FixtureRole = { id: string | number; code: string }
- type Fixtures = { reader: FixtureRole; editor: FixtureRole; roleIds: Array<string | number> }
- const fixture = (): Fixtures => JSON.parse(fs.readFileSync(path.join(work, 'fixtures.json'), 'utf8'))
- const sorted = (codes: string[]) => [...new Set(codes)].sort()
- const permissionCodes = (value: { permissionCodes?: string[]; permissions?: string[] }) => sorted(value.permissionCodes ?? value.permissions ?? [])
- const deferred = () => {
- let resolve!: () => void
- const promise = new Promise<void>(done => { resolve = done })
- return { promise, resolve }
- }
-
- async function signIn(page: Page) {
- const session = await login()
- await page.addInitScript(({ accessToken, refreshToken }) => {
- sessionStorage.setItem('unreal-tran:web:access-token:v1', accessToken)
- if (refreshToken) sessionStorage.setItem('unreal-tran:web:refresh-token:v1', refreshToken)
- }, { accessToken: session.accessToken, refreshToken: session.refreshToken })
- return session.accessToken as string
- }
-
- const roleRow = (page: Page, code: string) => page.locator('.el-table__row').filter({
- has: page.locator('.role-identity code').filter({ hasText: new RegExp(`^${code}$`) }),
- })
- const treeNode = (container: Locator, code: string) => container.locator('.el-tree-node__content').filter({
- has: container.page().locator('code').filter({ hasText: new RegExp(`^${code.replaceAll('.', '\\.')}$`) }),
- })
- const checkedCodes = (drawer: Locator) => drawer.locator('.el-tree-node__content').evaluateAll(nodes => nodes
- .filter(node => node.querySelector<HTMLInputElement>('input[type="checkbox"]')?.checked)
- .map(node => node.querySelector('code')?.textContent?.trim() ?? '')
- .filter(Boolean).sort())
-
- async function attachEvidence(page: Page, info: TestInfo, name: string, evidence: unknown) {
- const filename = path.resolve('reports/role-permissions-20260906/screenshots', `${name}.png`)
- fs.mkdirSync(path.dirname(filename), { recursive: true })
- await page.screenshot({ path: filename, fullPage: true, animations: 'disabled' })
- await info.attach(name, { path: filename, contentType: 'image/png' })
- await info.attach(`${name}-evidence`, { body: JSON.stringify(evidence, null, 2), contentType: 'application/json' })
- }
-
- test('权限加载乱序:关闭 A 后打开 B,A 的迟到响应不能覆盖 B', async ({ page }, info) => {
- const data = fixture()
- const token = await signIn(page)
- const arrived = deferred()
- const release = deferred()
- const delivered = deferred()
- const endpoint = `**/api/auth/v1/permissions/roles/${data.reader.id}`
- try {
- const reader = await call(`auth/v1/permissions/roles/${data.reader.id}`, token)
- const editor = await call(`auth/v1/permissions/roles/${data.editor.id}`, token)
- expect(reader.status).toBe(200)
- expect(editor.status).toBe(200)
- const readerCodes = permissionCodes(reader.data)
- const editorCodes = permissionCodes(editor.data)
- expect(readerCodes).not.toEqual(editorCodes)
- await page.route(endpoint, async route => {
- if (route.request().method() !== 'GET') return route.continue()
- // Fetch the actual read-only response, then deliberately deliver it after B has loaded.
- const response = await route.fetch()
- arrived.resolve()
- await release.promise
- try { await route.fulfill({ response }) } finally { delivered.resolve() }
- })
- await page.goto('/system/roles')
- await roleRow(page, data.reader.code).getByRole('button', { name: '配置权限', exact: true }).click()
- await arrived.promise
- const drawer = page.getByRole('dialog', { name: '配置角色权限', exact: true })
- await expect(drawer.getByRole('button', { name: '保存授权' })).toBeDisabled()
- await drawer.locator('.el-drawer__close-btn').click()
- await expect(drawer).not.toBeVisible()
- await roleRow(page, data.editor.code).getByRole('button', { name: '配置权限', exact: true }).click()
- await expect.poll(() => checkedCodes(drawer)).toEqual(editorCodes)
- await expect(drawer.locator('.role-permission-summary h3')).toHaveText('权限实测·模型编辑')
- const lateResponse = page.waitForResponse(response => response.url().endsWith(`/permissions/roles/${data.reader.id}`)
- && response.request().method() === 'GET')
- release.resolve()
- await delivered.promise
- expect((await lateResponse).status()).toBe(200)
- await (await lateResponse).finished()
- // Let the fulfilled response reach the store and Vue rendering before checking for stale writes.
- await page.waitForTimeout(250)
- expect(await checkedCodes(drawer)).toEqual(editorCodes)
- await expect(drawer.locator('.role-permission-summary h3')).toHaveText('权限实测·模型编辑')
- await expect(drawer.getByRole('button', { name: '保存授权' })).toBeDisabled()
- await attachEvidence(page, info, '10-stale-permission-response-is-ignored', {
- delayedRole: data.reader.code, currentRole: data.editor.code,
- delayedPermissionCodes: readerCodes, displayedPermissionCodes: editorCodes, businessWrites: 0,
- })
- } finally {
- release.resolve()
- await page.unrouteAll({ behavior: 'wait' })
- await call('auth/v1/auth/logout', token, {}).catch(() => undefined)
- }
- })
-
- test('保存授权期间禁止恢复,PUT 成功但列表刷新失败仍保留提交快照', async ({ page }, info) => {
- const data = fixture()
- const token = await signIn(page)
- const arrived = deferred()
- const release = deferred()
- let original: string[] | undefined
- let writeAttempted = false
- let posted: string[] | undefined
- let refreshFailures = 0
- try {
- // Only the explicitly prepared temporary reader role may be changed by this regression.
- expect(data.reader.code).toMatch(/^rv_reader_/)
- expect(data.roleIds.map(String)).toContain(String(data.reader.id))
- const role = await call(`auth/v1/roles/${data.reader.id}`, token)
- expect(role.status).toBe(200)
- expect(role.data.code).toBe(data.reader.code)
- expect(role.data.permissionPolicy).toBe('CONFIGURABLE')
- expect(Boolean(role.data.builtIn ?? role.data.isBuiltin)).toBe(false)
- const initial = await call(`auth/v1/permissions/roles/${data.reader.id}`, token)
- expect(initial.status).toBe(200)
- original = permissionCodes(initial.data)
- expect(original).toContain('content.model')
- const action = 'content.model.create'
- const submitted = sorted(original.includes(action) ? original.filter(code => code !== action) : [...original, action])
- await page.goto('/system/roles')
- await roleRow(page, data.reader.code).getByRole('button', { name: '配置权限', exact: true }).click()
- const drawer = page.getByRole('dialog', { name: '配置角色权限', exact: true })
- await expect.poll(() => checkedCodes(drawer)).toEqual(original)
- await drawer.getByPlaceholder('搜索权限名称或编码').fill('content.model')
- await treeNode(drawer, action).locator('.el-checkbox').click()
- await expect.poll(() => checkedCodes(drawer)).toEqual(submitted)
- await page.route(`**/api/auth/v1/permissions/roles/${data.reader.id}`, async route => {
- if (route.request().method() !== 'PUT') return route.continue()
- posted = sorted(route.request().postDataJSON().permissionCodes)
- arrived.resolve()
- await release.promise
- writeAttempted = true
- // Await the real PUT here as well, so cleanup cannot race an outstanding mutation.
- const response = await route.fetch()
- await route.fulfill({ response })
- })
- // Install only after the initial real list load. The permission PUT itself reaches the real API.
- await page.route('**/api/auth/v1/roles/all', route => {
- refreshFailures += 1
- return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({
- code: 'UNAVAILABLE', message: '角色列表刷新临时不可用', data: null,
- }) })
- })
- const result = page.waitForResponse(response => response.url().endsWith(`/permissions/roles/${data.reader.id}`)
- && response.request().method() === 'PUT')
- await drawer.getByRole('button', { name: '保存授权' }).click()
- await arrived.promise
- await expect(drawer.getByRole('button', { name: '恢复', exact: true })).toBeDisabled()
- await expect(treeNode(drawer, action).getByRole('checkbox')).toBeDisabled()
- expect(posted).toEqual(submitted)
- release.resolve()
- const response = await result
- expect(response.status()).toBe(200)
- expect(permissionCodes((await response.json()).data)).toEqual(submitted)
- await expect(page.locator('.el-message--error')).toContainText('角色列表刷新临时不可用')
- await expect(drawer).toBeVisible()
- expect(await checkedCodes(drawer)).toEqual(submitted)
- await expect(drawer.getByRole('button', { name: '恢复', exact: true })).toBeDisabled()
- await expect(drawer.getByRole('button', { name: '保存授权' })).toBeDisabled()
- expect(refreshFailures).toBeGreaterThan(0)
- await attachEvidence(page, info, '11-permission-save-survives-list-refresh-failure', {
- temporaryRole: data.reader.code, originalPermissionCodes: original, submittedPermissionCodes: submitted,
- realPutStatus: response.status(), injectedListRefreshStatus: 503, resetDisabledWhileSaving: true,
- })
- const persisted = await call(`auth/v1/permissions/roles/${data.reader.id}`, token)
- expect(persisted.status).toBe(200)
- expect(permissionCodes(persisted.data)).toEqual(submitted)
- } finally {
- release.resolve()
- try {
- await page.unrouteAll({ behavior: 'wait' })
- if (original && writeAttempted) {
- const current = await call(`auth/v1/permissions/roles/${data.reader.id}`, token)
- expect(current.status, '读取临时 reader 最新版本以恢复授权').toBe(200)
- if (JSON.stringify(permissionCodes(current.data)) !== JSON.stringify(original)) {
- const restored = await call(`auth/v1/permissions/roles/${data.reader.id}`, token, {
- permissionCodes: original, version: current.data.version ?? current.data.dataVersion,
- }, 'PUT')
- expect(restored.status, '恢复临时 reader 原授权').toBe(200)
- }
- const verified = await call(`auth/v1/permissions/roles/${data.reader.id}`, token)
- expect(verified.status).toBe(200)
- expect(permissionCodes(verified.data), '临时 reader 授权恢复核验').toEqual(original)
- await info.attach('11-temporary-reader-restored', {
- body: JSON.stringify({ roleCode: data.reader.code, restored: true, permissionCodes: original }),
- contentType: 'application/json',
- })
- }
- } finally { await call('auth/v1/auth/logout', token, {}).catch(() => undefined) }
- }
- })
|