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 } 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(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('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) } } })