agent-exec transport 5b222edc742519fdf36f7710ec4e3167/3bafd8f39e76
This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
// permux 活体浏览器验收:真实 Backend + 真实 Frontend dist(vite preview,/api 代理到被测 Backend)。
|
||||
// 由外部 harness 注入 PLAYWRIGHT_BASE_URL、PERMUX_LIVE=1、PERMUX_OWNER/PERMUX_PASSWORD(已设密管理员);
|
||||
// 全程不拦截任何路由,服务端事实一律经同一 baseURL 的 API 核对。
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { test, expect, type APIRequestContext, type APIResponse, type BrowserContext, type Page } from '@playwright/test'
|
||||
|
||||
interface Perm { actionId: string; label: string; description: string; group: string; category?: string; scope: 'system' | 'project'; protected: boolean }
|
||||
interface RolePerm { actionId: string; allowed: boolean; capAllowed: boolean; permissionVersion: number }
|
||||
interface Role { roleId: string; name: string; description: string; enabled: boolean; roleVersion: number; bindingCount: number; permissions: RolePerm[] }
|
||||
interface Account { userId: string; owner: string; stateVersion: number; profile: { name: string } }
|
||||
interface UserRoleView { binding?: { roleId: string; active: boolean }; role?: Role }
|
||||
interface Me { owner: string; isAdmin?: boolean; linkedCapabilities?: { projectCreate: boolean; actions: string[] } }
|
||||
|
||||
const LIVE = process.env.PERMUX_LIVE === '1' && !!process.env.PLAYWRIGHT_BASE_URL
|
||||
const SUFFIX = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||
const DOC_CATEGORY = '文档工作'
|
||||
const PROJECT_CREATE = 'project.create'
|
||||
const LINKED = ['project.settings', 'project.members.manage', 'project.permissions.manage', 'document.duties.assign']
|
||||
const LINKED_NOTE = '创建项目时自动具备 · 仅本人创建的项目'
|
||||
|
||||
/** 与 rolePermissions.permissionCategory 同义:缺分类的非保护系统动作归入「项目与协作」。 */
|
||||
const categoryOf = (item: Perm) => item.category || (item.scope === 'system' ? '项目与协作' : item.group)
|
||||
const permKey = (role: Role) => role.permissions.filter(item => item.allowed || item.capAllowed)
|
||||
.map(item => `${item.actionId}=${item.allowed}/${item.capAllowed}`).sort()
|
||||
const rowOf = (role: Role, actionId: string) => role.permissions.find(item => item.actionId === actionId)
|
||||
|
||||
async function ok(res: APIResponse) {
|
||||
expect(res.ok(), `${res.url()} → ${res.status()} ${await res.text().catch(() => '')}`).toBe(true)
|
||||
return res
|
||||
}
|
||||
const getJSON = async <T>(api: APIRequestContext, path: string) => (await ok(await api.get(path))).json() as Promise<T>
|
||||
|
||||
async function roleByName(api: APIRequestContext, name: string): Promise<Role> {
|
||||
const page = await getJSON<{ roles: Role[] }>(api, `/api/admin/roles?query=${encodeURIComponent(name)}&mode=exact&status=all&limit=50`)
|
||||
const role = (page.roles ?? []).find(item => item.name === name)
|
||||
expect(role, `角色 ${name} 应存在`).toBeTruthy()
|
||||
return role!
|
||||
}
|
||||
const getRole = (api: APIRequestContext, roleId: string) => getJSON<Role>(api, `/api/admin/roles/${encodeURIComponent(roleId)}`)
|
||||
const getUser = (api: APIRequestContext, userId: string) => getJSON<Account>(api, `/api/admin/users/${encodeURIComponent(userId)}`)
|
||||
const userRoleId = async (api: APIRequestContext, userId: string) =>
|
||||
(await getJSON<UserRoleView>(api, `/api/admin/users/${encodeURIComponent(userId)}/role`)).role?.roleId ?? ''
|
||||
|
||||
async function userByOwner(api: APIRequestContext, owner: string): Promise<Account> {
|
||||
const page = await getJSON<{ accounts: Account[] }>(api,
|
||||
`/api/admin/users/search?query=${encodeURIComponent(owner)}&mode=exact&status=all&fields=owner&limit=20`)
|
||||
const account = (page.accounts ?? []).find(item => item.owner === owner)
|
||||
expect(account, `用户 ${owner} 应存在`).toBeTruthy()
|
||||
return account!
|
||||
}
|
||||
|
||||
async function assignRoleApi(api: APIRequestContext, userId: string, roleId: string) {
|
||||
const { stateVersion } = await getUser(api, userId)
|
||||
await ok(await api.put(`/api/admin/users/${encodeURIComponent(userId)}/role`, {
|
||||
data: { roleId, expectedStateVersion: stateVersion, idempotencyKey: randomUUID() },
|
||||
}))
|
||||
}
|
||||
|
||||
/** API 登录;首登受限会话先改密再以新密码重登,返回可用的完整会话。 */
|
||||
async function apiLogin(api: APIRequestContext, owner: string, password: string, nextPassword: string) {
|
||||
const first = await (await ok(await api.post('/api/auth/login', { data: { owner, password } }))).json() as { mustChangePassword?: boolean; sessionScope?: string }
|
||||
if (!first.mustChangePassword && first.sessionScope !== 'set_password_only') return
|
||||
await ok(await api.post('/api/auth/password', { data: { current: password, next: nextPassword } }))
|
||||
await ok(await api.post('/api/auth/login', { data: { owner, password: nextPassword } }))
|
||||
}
|
||||
|
||||
async function expand(page: Page, trigger: string) {
|
||||
const button = page.getByRole('button', { name: trigger, exact: true })
|
||||
await expect(button).toBeVisible({ timeout: 15_000 })
|
||||
if (await button.getAttribute('aria-expanded') !== 'true') await button.click()
|
||||
await expect(button).toHaveAttribute('aria-expanded', 'true')
|
||||
}
|
||||
|
||||
test.describe.serial('permux 活体主流程(真实 Backend + 真实 Frontend dist)', () => {
|
||||
test.skip(!LIVE, 'set PERMUX_LIVE=1 and PLAYWRIGHT_BASE_URL (vite preview proxied to the backend under test)')
|
||||
test.describe.configure({ timeout: 180_000 })
|
||||
|
||||
let context: BrowserContext
|
||||
let page: Page
|
||||
let api: APIRequestContext
|
||||
let catalog: Perm[] = []
|
||||
let actA: Perm, actB: Perm, keep: Perm, projectCreate: Perm
|
||||
let linked: Perm[] = []
|
||||
let roleA: Role, roleB: Role, roleC: Role
|
||||
const names = {
|
||||
roleA: `权限甲-${SUFFIX}`, roleB: `权限乙-${SUFFIX}`, roleC: `权限丙-${SUFFIX}`, roleD: `权限丁-${SUFFIX}`,
|
||||
sameName: `同名测试·${SUFFIX}`, owner1: `permux-u1-${SUFFIX}`, owner2: `permux-u2-${SUFFIX}`, linkedOwner: `permux-linked-${SUFFIX}`,
|
||||
}
|
||||
const label1 = () => `${names.sameName}(${names.owner1})`
|
||||
const label2 = () => `${names.sameName}(${names.owner2})`
|
||||
|
||||
const status = (text: string) => page.getByRole('status').filter({ hasText: text })
|
||||
const savebar = () => page.getByRole('region', { name: '未保存的权限修改' })
|
||||
const previewPanel = () => page.getByRole('region', { name: '保存前预览' })
|
||||
const cell = (role: Role, item: Perm) => page.getByLabel(`${role.name} ${item.label}`, { exact: true })
|
||||
const category = (name: string) => page.getByRole('group', { name: '权限分类' }).getByRole('button', { name, exact: true })
|
||||
const matrixRow = (role: Role) => page.locator('tr').filter({ has: page.getByRole('button', { name: role.name, exact: true }) })
|
||||
const userCheckbox = (label: string) => page.getByRole('checkbox', { name: `选择 ${label}`, exact: true })
|
||||
const userRow = (label: string) => page.locator('tr').filter({ has: userCheckbox(label) })
|
||||
const selectedBar = () => page.getByRole('region', { name: '已选用户' })
|
||||
|
||||
async function openSection(name: '角色与权限' | '用户管理') {
|
||||
const zone = page.getByRole('dialog', { name: '系统管理' })
|
||||
if (!await zone.isVisible()) {
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '系统管理…' }).click()
|
||||
await expect(zone).toBeVisible()
|
||||
}
|
||||
await zone.getByRole('button', { name, exact: true }).click()
|
||||
}
|
||||
|
||||
/** 角色面板:矩阵视图 + 搜索本轮后缀,只呈现本轮创建的角色。 */
|
||||
async function rolesMatrix(query = SUFFIX) {
|
||||
await openSection('角色与权限')
|
||||
await expect(page.getByRole('group', { name: '权限分类' })).toBeVisible({ timeout: 15_000 })
|
||||
const matrixButton = page.getByRole('button', { name: '角色矩阵', exact: true })
|
||||
if (await matrixButton.getAttribute('aria-pressed') !== 'true') await matrixButton.click()
|
||||
await expand(page, '搜索与过滤')
|
||||
await page.getByLabel('搜索角色').fill(query)
|
||||
}
|
||||
|
||||
async function openPreview() {
|
||||
await savebar().getByRole('button', { name: '保存', exact: true }).click()
|
||||
await expect(previewPanel()).toBeVisible({ timeout: 15_000 })
|
||||
await expect(previewPanel()).toContainText('请确认以下变化后保存')
|
||||
}
|
||||
|
||||
async function confirmSave(roles: number) {
|
||||
const confirm = savebar().getByRole('button', { name: '确认保存', exact: true })
|
||||
await expect(confirm).toBeEnabled()
|
||||
await confirm.click()
|
||||
await expect(status(`已保存 ${roles} 个角色的权限设置`)).toBeVisible({ timeout: 15_000 })
|
||||
await expect(savebar()).toHaveCount(0)
|
||||
}
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
if (!LIVE) return
|
||||
context = await browser.newContext({ baseURL: process.env.PLAYWRIGHT_BASE_URL, locale: 'zh-CN' })
|
||||
page = await context.newPage()
|
||||
api = context.request
|
||||
})
|
||||
|
||||
test.afterAll(async () => { await context?.close() })
|
||||
|
||||
test('登录:管理员经登录表单进入工作台,/api/me 为管理员', async () => {
|
||||
await page.goto('/')
|
||||
await page.getByPlaceholder('由管理员分配').fill(process.env.PERMUX_OWNER ?? '')
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(process.env.PERMUX_PASSWORD ?? '')
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
await expect(page.locator('.tsb-user')).toBeVisible({ timeout: 15_000 })
|
||||
const res = await api.get('/api/me')
|
||||
expect(res.status()).toBe(200)
|
||||
const me = await res.json() as Me
|
||||
expect(me.isAdmin).toBe(true)
|
||||
expect(me.owner).toBe(process.env.PERMUX_OWNER)
|
||||
|
||||
catalog = (await getJSON<{ permissions: Perm[] }>(api, '/api/admin/permissions/catalog')).permissions ?? []
|
||||
const editable = catalog.filter(item => !item.protected)
|
||||
const docActions = editable.filter(item => categoryOf(item) === DOC_CATEGORY && item.scope === 'project' && !LINKED.includes(item.actionId))
|
||||
expect(docActions.length, '「文档工作」至少需要两个可编辑项目动作').toBeGreaterThanOrEqual(2)
|
||||
actA = docActions[0]
|
||||
actB = docActions[1]
|
||||
keep = docActions[2] ?? editable.find(item => item.scope === 'project' && !LINKED.includes(item.actionId)
|
||||
&& item.actionId !== actA.actionId && item.actionId !== actB.actionId)!
|
||||
expect(keep, '需要第三个可编辑项目动作用于验证未提及行保留').toBeTruthy()
|
||||
projectCreate = editable.find(item => item.actionId === PROJECT_CREATE)!
|
||||
expect(projectCreate, '目录应含 project.create').toBeTruthy()
|
||||
linked = LINKED.map(id => editable.find(item => item.actionId === id)!)
|
||||
expect(linked.every(Boolean), '目录应含四个创建联动动作').toBe(true)
|
||||
})
|
||||
|
||||
test('C01+C02 多角色矩阵编辑:跨分类与搜索保留草稿,预览后一次提交', async () => {
|
||||
await rolesMatrix()
|
||||
for (const name of [names.roleA, names.roleB]) {
|
||||
await page.getByLabel('新角色名称').fill(name)
|
||||
await page.getByLabel('新角色描述').fill('permux 活体验收')
|
||||
await page.getByRole('button', { name: '创建角色', exact: true }).click()
|
||||
await expect(status(`已创建角色 ${name}`)).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
roleA = await roleByName(api, names.roleA)
|
||||
roleB = await roleByName(api, names.roleB)
|
||||
expect(roleA.enabled && roleB.enabled).toBe(true)
|
||||
|
||||
// 预置第三动作行(默认允许):界面提交未提及它,提交后必须原样保留。
|
||||
await ok(await api.post('/api/admin/roles/permissions/batch', { data: { idempotencyKey: randomUUID(), roles: [roleA, roleB].map(role => ({
|
||||
roleId: role.roleId, expectedRoleVersion: role.roleVersion, changes: [{ actionId: keep.actionId, allowed: true, capAllowed: true }],
|
||||
})) } }))
|
||||
// 切走再回到分区使面板重挂载,草稿基线取预置后的最新版本。
|
||||
await openSection('用户管理')
|
||||
await rolesMatrix()
|
||||
|
||||
await category(DOC_CATEGORY).click()
|
||||
await expect(category(DOC_CATEGORY)).toHaveAttribute('aria-pressed', 'true')
|
||||
await cell(roleA, actA).selectOption('default')
|
||||
await cell(roleB, actB).selectOption('project')
|
||||
await expect(savebar()).toContainText('已修改 2 个角色、2 项设置')
|
||||
|
||||
const other = (await page.getByRole('group', { name: '权限分类' }).getByRole('button').allInnerTexts()).find(text => text.trim() !== DOC_CATEGORY)
|
||||
expect(other, '应存在第二个权限分类').toBeTruthy()
|
||||
await category(other!.trim()).click()
|
||||
await expect(cell(roleA, actA)).toHaveCount(0)
|
||||
await category(DOC_CATEGORY).click()
|
||||
await expect(cell(roleA, actA)).toHaveValue('default')
|
||||
await expect(cell(roleB, actB)).toHaveValue('project')
|
||||
await expect(savebar()).toContainText('已修改 2 个角色、2 项设置')
|
||||
|
||||
await page.getByLabel('搜索角色').fill(roleA.name)
|
||||
await expect(cell(roleB, actB)).toHaveCount(0, { timeout: 15_000 })
|
||||
await expect(savebar()).toContainText('已修改 2 个角色、2 项设置')
|
||||
await page.getByLabel('搜索角色').fill('')
|
||||
await expect(savebar()).toContainText('已修改 2 个角色、2 项设置')
|
||||
await page.getByLabel('搜索角色').fill(SUFFIX)
|
||||
await expect(cell(roleA, actA)).toHaveValue('default', { timeout: 15_000 })
|
||||
await expect(cell(roleB, actB)).toHaveValue('project')
|
||||
await expect(savebar()).toContainText('已修改 2 个角色、2 项设置')
|
||||
|
||||
await openPreview()
|
||||
const preview = previewPanel()
|
||||
for (const text of [roleA.name, roleB.name, actA.label, actB.label, '不允许', '默认允许', '按项目授权']) await expect(preview).toContainText(text)
|
||||
await confirmSave(2)
|
||||
|
||||
const freshA = await getRole(api, roleA.roleId)
|
||||
const freshB = await getRole(api, roleB.roleId)
|
||||
expect(rowOf(freshA, actA.actionId)).toMatchObject({ allowed: true, capAllowed: true })
|
||||
expect(rowOf(freshB, actB.actionId)).toMatchObject({ allowed: false, capAllowed: true })
|
||||
expect(rowOf(freshA, actB.actionId)?.capAllowed ?? false).toBe(false)
|
||||
expect(rowOf(freshB, actA.actionId)?.capAllowed ?? false).toBe(false)
|
||||
for (const role of [freshA, freshB]) expect(rowOf(role, keep.actionId)).toMatchObject({ allowed: true, capAllowed: true })
|
||||
})
|
||||
|
||||
test('C03 创建联动:开启创建项目自动补齐四项按项目授权,运行时 /api/me 联动生效', async ({ request }) => {
|
||||
await rolesMatrix()
|
||||
await category(categoryOf(projectCreate)).click()
|
||||
await cell(roleA, projectCreate).selectOption(projectCreate.scope === 'system' ? 'on' : 'default')
|
||||
await expect(savebar()).toContainText('已修改 1 个角色、1 项设置')
|
||||
await category(categoryOf(linked[0])).click()
|
||||
await expect(matrixRow(roleA).getByText(LINKED_NOTE).first()).toBeVisible()
|
||||
await expect(matrixRow(roleB).getByText(LINKED_NOTE)).toHaveCount(0)
|
||||
|
||||
await openPreview()
|
||||
const expansion = previewPanel().locator('.rp-linked')
|
||||
await expect(expansion).toContainText('系统将自动补齐')
|
||||
for (const item of linked) await expect(expansion).toContainText(item.label)
|
||||
await confirmSave(1)
|
||||
|
||||
const fresh = await getRole(api, roleA.roleId)
|
||||
expect(rowOf(fresh, PROJECT_CREATE)).toMatchObject({ allowed: true })
|
||||
for (const id of LINKED) expect(rowOf(fresh, id), id).toMatchObject({ allowed: false, capAllowed: true })
|
||||
|
||||
const password = `Permux!${SUFFIX}Aa9`
|
||||
const nextPassword = `Permux!${SUFFIX}Bb8`
|
||||
const created = await (await ok(await api.post('/api/admin/users', {
|
||||
data: { owner: names.linkedOwner, profile: { name: '联动验证' }, password },
|
||||
}))).json() as Account
|
||||
await assignRoleApi(api, created.userId, roleA.roleId)
|
||||
expect(await userRoleId(api, created.userId)).toBe(roleA.roleId)
|
||||
|
||||
await apiLogin(request, names.linkedOwner, password, nextPassword)
|
||||
const meRes = await request.get('/api/me')
|
||||
expect(meRes.status()).toBe(200)
|
||||
const me = await meRes.json() as Me
|
||||
expect(me.owner).toBe(names.linkedOwner)
|
||||
expect(me.linkedCapabilities?.projectCreate).toBe(true)
|
||||
for (const id of LINKED) expect(me.linkedCapabilities?.actions ?? []).toContain(id)
|
||||
})
|
||||
|
||||
test('C04 断链拒绝:创建项目开启时联动项不允许不可保存,恢复按项目授权后保存成功', async () => {
|
||||
const before = await getRole(api, roleA.roleId)
|
||||
const broken = linked[0]
|
||||
await rolesMatrix()
|
||||
await category(DOC_CATEGORY).click()
|
||||
await cell(roleA, actB).selectOption('project')
|
||||
await category(categoryOf(broken)).click()
|
||||
await expect(cell(roleA, broken)).toHaveValue('project')
|
||||
await cell(roleA, broken).selectOption('denied')
|
||||
await expect(savebar()).toContainText('已修改 1 个角色、2 项设置')
|
||||
|
||||
await openPreview()
|
||||
const confirm = savebar().getByRole('button', { name: '确认保存', exact: true })
|
||||
const blockedNote = previewPanel().getByRole('alert')
|
||||
if (await blockedNote.isVisible()) {
|
||||
await expect(blockedNote).toContainText('存在冲突或错误,调整后才能保存')
|
||||
await expect(previewPanel()).toContainText(broken.label)
|
||||
await expect(confirm).toBeDisabled()
|
||||
} else {
|
||||
await expect(confirm).toBeEnabled()
|
||||
await confirm.click()
|
||||
await expect(page.locator('.rp-status').getByRole('alert')).toContainText(broken.label, { timeout: 15_000 })
|
||||
await expect(savebar()).toContainText('已修改 1 个角色、2 项设置')
|
||||
}
|
||||
const unchanged = await getRole(api, roleA.roleId)
|
||||
expect(unchanged.roleVersion).toBe(before.roleVersion)
|
||||
expect(permKey(unchanged)).toEqual(permKey(before))
|
||||
|
||||
await cell(roleA, broken).selectOption('project')
|
||||
await expect(savebar()).toContainText('已修改 1 个角色、1 项设置')
|
||||
await openPreview()
|
||||
await confirmSave(1)
|
||||
const fresh = await getRole(api, roleA.roleId)
|
||||
expect(rowOf(fresh, actB.actionId)).toMatchObject({ allowed: false, capAllowed: true })
|
||||
expect(rowOf(fresh, PROJECT_CREATE)).toMatchObject({ allowed: true })
|
||||
for (const id of LINKED) expect(rowOf(fresh, id), id).toMatchObject({ allowed: false, capAllowed: true })
|
||||
})
|
||||
|
||||
test('C07 同名辨识:两名同名用户按账号辨识,勾选互不串行', async () => {
|
||||
await openSection('用户管理')
|
||||
for (const owner of [names.owner1, names.owner2]) {
|
||||
await page.getByLabel('新用户登录名').fill(owner)
|
||||
await expand(page, '可选资料:姓名、电话、邮箱、工号、部门、备注')
|
||||
await page.locator('#new-user-name').fill(names.sameName)
|
||||
await page.getByRole('button', { name: '创建用户', exact: true }).click()
|
||||
await expect(status(`已创建 ${owner}`)).toBeVisible({ timeout: 15_000 })
|
||||
await page.getByRole('button', { name: '返回列表', exact: true }).click()
|
||||
}
|
||||
await page.getByLabel('搜索用户').fill(names.sameName)
|
||||
await expect(userRow(label1())).toBeVisible({ timeout: 15_000 })
|
||||
await expect(userRow(label2())).toBeVisible()
|
||||
await expect(page.getByText(`存在同名用户「${names.sameName}」`)).toBeVisible()
|
||||
for (const [label, owner] of [[label1(), names.owner1], [label2(), names.owner2]]) {
|
||||
await expect(userRow(label)).toContainText(names.sameName)
|
||||
await expect(userRow(label)).toContainText(owner)
|
||||
await expect(userRow(label)).toContainText('同名 · 按账号辨识')
|
||||
}
|
||||
|
||||
await userCheckbox(label1()).check()
|
||||
await expect(userCheckbox(label1())).toBeChecked()
|
||||
await expect(userCheckbox(label2())).not.toBeChecked()
|
||||
await expect(userRow(label2())).toContainText(names.owner2)
|
||||
await expect(userRow(label2())).not.toContainText(names.owner1)
|
||||
await expect(selectedBar()).toContainText('已选 1 人')
|
||||
})
|
||||
|
||||
test('C08 批量分配:两人预览 未分配 → 角色乙 后一次提交', async () => {
|
||||
await userCheckbox(label2()).check()
|
||||
await expect(selectedBar()).toContainText('已选 2 人')
|
||||
await selectedBar().getByRole('button', { name: '为选中的 2 人分配角色', exact: true }).click()
|
||||
const batch = page.getByRole('region', { name: '为选中的 2 人分配角色' })
|
||||
await batch.getByLabel('搜索可分配角色').fill(roleB.name)
|
||||
const target = batch.getByLabel('批量分配的目标角色')
|
||||
await expect(target.locator('option', { hasText: roleB.name })).toHaveCount(1, { timeout: 15_000 })
|
||||
await target.selectOption({ label: roleB.name })
|
||||
for (const owner of [names.owner1, names.owner2]) {
|
||||
await expect(batch.locator('li', { hasText: owner })).toContainText(`未分配 → ${roleB.name}`, { timeout: 15_000 })
|
||||
}
|
||||
await batch.getByRole('button', { name: '确认分配', exact: true }).click()
|
||||
await expect(status(`已为 2 人分配角色「${roleB.name}」`)).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
for (const owner of [names.owner1, names.owner2]) {
|
||||
const account = await userByOwner(api, owner)
|
||||
expect(await userRoleId(api, account.userId), owner).toBe(roleB.roleId)
|
||||
}
|
||||
await expect(userRow(label1())).toContainText(roleB.name)
|
||||
await expect(userRow(label2())).toContainText(roleB.name)
|
||||
})
|
||||
|
||||
test('C09 冲突整批不写:确认前他人改动一人角色,409 后整批未写入且保留选择', async () => {
|
||||
roleC = await (await ok(await api.post('/api/admin/roles', { data: { name: names.roleC, description: 'permux 并发改动' } }))).json() as Role
|
||||
const user1 = await userByOwner(api, names.owner1)
|
||||
const user2 = await userByOwner(api, names.owner2)
|
||||
|
||||
await userCheckbox(label1()).check()
|
||||
await userCheckbox(label2()).check()
|
||||
await expect(selectedBar()).toContainText('已选 2 人')
|
||||
await selectedBar().getByRole('button', { name: '为选中的 2 人分配角色', exact: true }).click()
|
||||
const batch = page.getByRole('region', { name: '为选中的 2 人分配角色' })
|
||||
await batch.getByLabel('搜索可分配角色').fill(roleA.name)
|
||||
const target = batch.getByLabel('批量分配的目标角色')
|
||||
await expect(target.locator('option', { hasText: roleA.name })).toHaveCount(1, { timeout: 15_000 })
|
||||
await target.selectOption({ label: roleA.name })
|
||||
for (const owner of [names.owner1, names.owner2]) {
|
||||
await expect(batch.locator('li', { hasText: owner })).toContainText(`${roleB.name} → ${roleA.name}`, { timeout: 15_000 })
|
||||
}
|
||||
const confirm = batch.getByRole('button', { name: '确认分配', exact: true })
|
||||
await expect(confirm).toBeEnabled()
|
||||
|
||||
await assignRoleApi(api, user2.userId, roleC.roleId)
|
||||
await confirm.click()
|
||||
const alert = page.getByRole('alert').filter({ hasText: '整批均未写入' })
|
||||
await expect(alert).toBeVisible({ timeout: 15_000 })
|
||||
await expect(alert).toContainText('请重新预览')
|
||||
await expect(userCheckbox(label1())).toBeChecked()
|
||||
await expect(userCheckbox(label2())).toBeChecked()
|
||||
await expect(selectedBar()).toContainText('已选 2 人')
|
||||
|
||||
expect(await userRoleId(api, user1.userId)).toBe(roleB.roleId)
|
||||
expect(await userRoleId(api, user2.userId)).toBe(roleC.roleId)
|
||||
})
|
||||
|
||||
test('C10 复制并分配:源角色权限复制到新角色并换绑,新角色独立维护', async () => {
|
||||
await selectedBar().getByRole('button', { name: '清空选择', exact: true }).click()
|
||||
await expect(selectedBar()).toHaveCount(0)
|
||||
const user1 = await userByOwner(api, names.owner1)
|
||||
const sourceBefore = await getRole(api, roleB.roleId)
|
||||
|
||||
await userRow(label1()).getByRole('button', { name: '查看与管理', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeVisible()
|
||||
await expect(page.getByText(`当前角色 ${roleB.name}`)).toBeVisible({ timeout: 15_000 })
|
||||
await expand(page, '复制角色并分配…')
|
||||
await page.getByLabel('新角色名称(必填)').fill(names.roleD)
|
||||
await page.getByLabel('新角色描述').fill('permux 复制并分配')
|
||||
await expect(page.getByText(`相对「${roleB.name}」调整 0 项`)).toBeVisible()
|
||||
await page.getByRole('button', { name: '确认复制并分配', exact: true }).click()
|
||||
const done = status('新角色独立维护')
|
||||
await expect(done).toBeVisible({ timeout: 15_000 })
|
||||
await expect(done).toContainText(names.roleD)
|
||||
await expect(page.getByText(`当前角色 ${names.roleD}`)).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
const copied = await roleByName(api, names.roleD)
|
||||
expect(copied.roleId).not.toBe(roleB.roleId)
|
||||
expect(copied.description).toBe('permux 复制并分配')
|
||||
expect(permKey(copied)).toEqual(permKey(sourceBefore))
|
||||
expect(await userRoleId(api, user1.userId)).toBe(copied.roleId)
|
||||
const sourceAfter = await getRole(api, roleB.roleId)
|
||||
expect(sourceAfter.roleVersion).toBe(sourceBefore.roleVersion)
|
||||
expect(permKey(sourceAfter)).toEqual(permKey(sourceBefore))
|
||||
// 源角色仅失去被换绑的这一人,其余绑定不受影响。
|
||||
expect(sourceAfter.bindingCount).toBe(sourceBefore.bindingCount - 1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user