Files
Agent-System-Frontend/e2e/iam-role-authority.playwright.ts
T

624 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// IAM U4 阶段 7 确定性浏览器切片(DR6–DR10):真实 React/Picker 交互 +
// 完全受控的 HTTP 应答,不依赖任何账号或数据库。活体隔离产品层由
// Agent-System-RDM scripts/iam-u4-role-acceptance.sh 以真实 Backend/RDM
// 授权路径复用同一场景(live 模式不拦截路由)。
import { test, expect, type Page, type Route } from '@playwright/test'
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to an isolated frontend server')
function deferred() {
let resolve!: () => void
const promise = new Promise<void>(ok => { resolve = ok })
return { promise, resolve }
}
interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; systemActions?: string[] }
interface Perm { actionId: string; label: string; description: string; group: 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; profile: { name: string; phone: string; email: string; staffNo: string; department: string; notes: string }
profileVersion: number; enabled: boolean; stateVersion: number; isAdmin: boolean; passwordSet: boolean
initializationState: string; initializationGeneration: number
}
const FULL_ACTIONS = ['system.users.view', 'system.users.create', 'system.users.edit', 'system.users.disable',
'system.roles.view', 'system.roles.manage', 'system.roles.assign', 'system.permissions.view', 'system.permissions.manage']
function perm(actionId: string, label: string, group: string, scope: 'system' | 'project', extra: Partial<Perm> = {}): Perm {
return { actionId, label, description: `${label}说明`, group, scope, protected: false, ...extra }
}
const CATALOG: Perm[] = [
perm('project.create', '创建项目', '项目入口', 'system'),
perm('project.member.manage', '管理项目成员', '项目入口', 'project'),
perm('project.override.set', '设置项目覆盖', '项目入口', 'project'),
perm('document.read', '读取文档', '文档动作', 'project'),
perm('document.author', '编制文档', '文档动作', 'project'),
perm('document.approve', '批准文档', '文档动作', 'project'),
perm('export.create', '创建导出', '平台动作', 'project'),
perm('audit.read', '读取审计', '平台动作', 'project'),
perm('system.users.manage', '管理用户账号', '系统动作', 'system', { protected: true }),
]
function emptyProfile(name = '') {
return { name, phone: '', email: '', staffNo: '', department: '', notes: '' }
}
function account(userId: string, owner: string, profile = emptyProfile(), overrides: Partial<Account> = {}): Account {
return { userId, owner, profile, profileVersion: 1, enabled: true, stateVersion: 3, isAdmin: false, passwordSet: true, initializationState: 'done', initializationGeneration: 0, ...overrides }
}
function rp(actionId: string, allowed = false, capAllowed = false): RolePerm {
return { actionId, allowed, capAllowed, permissionVersion: 1 }
}
/** 可变角色/绑定状态:一个 fixture 贯穿用例内全部请求,支持版本化 409。 */
function world(me: Me) {
const roles: Role[] = [
{ roleId: 'role_eng', name: '软件工程师', description: '研发职位', enabled: true, roleVersion: 3, bindingCount: 2, permissions: [rp('project.create', true, true), rp('document.read', true, true)] },
{ roleId: 'role_ops', name: '运维工程师', description: '运维职位', enabled: true, roleVersion: 1, bindingCount: 0, permissions: [] },
{ roleId: 'role_review', name: '审核工程师', description: '质量职位', enabled: false, roleVersion: 2, bindingCount: 1, permissions: [rp('document.read', true, true), rp('document.approve', false, true)] },
]
const bindings: Record<string, string> = { u1: 'role_eng', u3: 'role_eng' }
const users: Account[] = [
account('u1', 'alice', { ...emptyProfile('张三'), phone: '13800000001', staffNo: 'A001' }),
account('u2', 'bob'),
account('u3', 'carol', { ...emptyProfile('李四'), email: 'carol@example.com' }),
]
const grants: Record<string, { actions: string[]; authorityEpoch: number }> = {}
const calls: { method: string; path: string; query: Record<string, string>; body?: Record<string, unknown> }[] = []
const holds = new Map<string, { started: ReturnType<typeof deferred>; release: ReturnType<typeof deferred> }>()
const failNext = new Map<string, number>()
let grantEpoch = 5
const PAGE = 2
const visible = (q: URLSearchParams): Role[] => roles.filter(role => {
const status = q.get('status') ?? 'all'
if (status === 'enabled' && !role.enabled) return false
if (status === 'disabled' && role.enabled) return false
const action = q.get('action') ?? ''
if (action && !role.permissions.some(item => item.actionId === action && item.allowed)) return false
const query = (q.get('query') ?? '').trim().toLocaleLowerCase()
if (!query) return true
const fields = [role.name, role.description, role.roleId].map(value => value.toLocaleLowerCase())
return (q.get('mode') ?? 'fuzzy') === 'exact' ? fields.some(value => value === query) : fields.some(value => value.includes(query))
})
const recount = () => {
for (const role of roles) role.bindingCount = Object.values(bindings).filter(roleId => roleId === role.roleId).length
}
const notFound = () => ({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'not found' }) })
const deny = (route: Route, status: number) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify({ error: `code:${status}` }) })
const ok = (route: Route, payload: unknown) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(payload) })
return {
roles, bindings, users, grants, calls,
hold(key: string) {
const pending = { started: deferred(), release: deferred() }
holds.set(key, pending)
return pending
},
failNextTimes(path: string, times: number) { failNext.set(path, times) },
async route(route: Route) {
const request = route.request()
const url = new URL(request.url())
const path = decodeURIComponent(url.pathname)
const method = request.method()
const raw = request.postData()
calls.push({ method, path, query: Object.fromEntries(url.searchParams), body: raw ? JSON.parse(raw) as Record<string, unknown> : undefined })
const hold = holds.get(`${method} ${path}`)
if (hold) { holds.delete(`${method} ${path}`); hold.started.resolve(); await hold.release.promise }
const fails = failNext.get(path) ?? 0
if (fails > 0) { failNext.set(path, fails - 1); return route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'boom' }) }) }
if (path === '/api/health') return ok(route, { ok: true })
if (path === '/api/me') return ok(route, me)
if (path === '/api/projects') return ok(route, [])
if (path === '/api/settings') return ok(route, { schema: [], defaults: {}, user: {}, project: {} })
if (path === '/api/commands') return ok(route, [])
if (path === '/api/admin-zone/sections') return ok(route, [])
if (path.endsWith('/cards')) return ok(route, { cards: [] })
if (path === '/api/admin/permissions/catalog') return ok(route, { permissions: CATALOG })
if (path === '/api/admin/roles' && method === 'GET') {
const list = visible(url.searchParams)
const start = Number(url.searchParams.get('cursor') ?? 0) || 0
const page = list.slice(start, start + PAGE)
return ok(route, { roles: page, nextCursor: start + PAGE < list.length ? String(start + PAGE) : '' })
}
if (path === '/api/admin/roles' && method === 'POST') {
const body = request.postDataJSON() as { name: string; description: string }
const role: Role = { roleId: `role_${body.name}`, name: body.name, description: body.description, enabled: true, roleVersion: 1, bindingCount: 0, permissions: [] }
roles.unshift(role)
return ok(route, role)
}
const roleMatch = path.match(/^\/api\/admin\/roles\/([^/]+)(\/permissions)?$/)
if (roleMatch) {
const role = roles.find(item => item.roleId === roleMatch[1])
if (!role) return route.fulfill(notFound())
if (!roleMatch[2]) {
if (method === 'GET') return ok(route, role)
const body = request.postDataJSON() as { expectedRoleVersion: number; name?: string; description?: string; enabled?: boolean }
if (body.expectedRoleVersion !== role.roleVersion) return deny(route, 409)
if (body.name !== undefined) role.name = body.name
if (body.description !== undefined) role.description = body.description
if (body.enabled !== undefined) role.enabled = body.enabled
role.roleVersion += 1
return ok(route, role)
}
if (method === 'PUT') {
const body = request.postDataJSON() as { expectedRoleVersion: number; changes: { actionId: string; allowed: boolean; capAllowed: boolean }[] }
if (body.expectedRoleVersion !== role.roleVersion) return deny(route, 409)
role.permissions = body.changes.map(change => ({ ...change, permissionVersion: 1 }))
role.roleVersion += 1
return ok(route, { receiptId: 'r1', roleVersion: role.roleVersion, authorityEpoch: 6 })
}
}
if (path === '/api/admin/users/search' && method === 'GET') {
const q = url.searchParams
const query = (q.get('query') ?? '').trim().toLocaleLowerCase()
const fields = (q.get('fields') ?? 'owner,name,userId').split(',')
const status = q.get('status') ?? 'all'
const list = users.filter(item => {
if (status === 'enabled' && !item.enabled) return false
if (status === 'disabled' && item.enabled) return false
if (!query) return true
const values = (fields.includes('default') ? ['owner', 'name', 'userId'] : fields)
.map(field => field === 'userId' ? item.userId : field === 'owner' ? item.owner : item.profile[field as 'name'] ?? '')
return (q.get('mode') ?? 'fuzzy') === 'exact' ? values.some(value => value.toLowerCase() === query) : values.some(value => value.toLowerCase().includes(query))
})
return ok(route, { accounts: list, nextCursor: '', directoryVersion: 'd1' })
}
if (path === '/api/admin/users' && method === 'POST') {
const body = request.postDataJSON() as { owner: string; profile: Account['profile'] }
if (users.some(item => item.owner === body.owner)) return deny(route, 409)
const created = account(`u-${body.owner}`, body.owner, { ...emptyProfile(), ...body.profile })
users.push(created)
return ok(route, created)
}
const userMatch = path.match(/^\/api\/admin\/users\/([^/]+)(\/role|\/profile|\/state|\/system-grants)?$/)
if (userMatch) {
const user = users.find(item => item.userId === userMatch[1])
if (!user) return route.fulfill(notFound())
const sub = userMatch[2] ?? ''
if (sub === '' && method === 'GET') return ok(route, user)
if (sub === '/profile' && method === 'PATCH') {
const body = request.postDataJSON() as { expectedProfileVersion: number; patch: Record<string, string> }
if (body.expectedProfileVersion !== user.profileVersion) return deny(route, 409)
user.profile = { ...user.profile, ...body.patch }
user.profileVersion += 1
return ok(route, user)
}
if (sub === '/state' && method === 'PUT') {
const body = request.postDataJSON() as { expectedStateVersion: number; enabled: boolean }
if (body.expectedStateVersion !== user.stateVersion) return deny(route, 409)
user.enabled = body.enabled
user.stateVersion += 1
return ok(route, user)
}
if (sub === '/role' && method === 'GET') {
const role = roles.find(item => item.roleId === bindings[user.userId])
return ok(route, role ? {
binding: { bindingId: `b-${user.userId}`, userId: user.userId, roleId: role.roleId, active: true, bindingVersion: 1, assignedBy: 'root', assignedAt: '', userStateVersion: user.stateVersion, authorityEpoch: grantEpoch },
role,
} : {})
}
if (sub === '/role' && method === 'PUT') {
const body = request.postDataJSON() as { roleId: string; expectedStateVersion: number }
if (body.expectedStateVersion !== user.stateVersion) return deny(route, 409)
bindings[user.userId] = body.roleId
recount()
return ok(route, { bindingId: `b-${user.userId}`, userId: user.userId, roleId: body.roleId, active: true, bindingVersion: 2, assignedBy: 'root', assignedAt: '', userStateVersion: user.stateVersion, authorityEpoch: grantEpoch })
}
if (sub === '/system-grants' && method === 'GET') {
return ok(route, grants[user.userId] ?? { actions: [], authorityEpoch: grantEpoch })
}
if (sub === '/system-grants' && method === 'PUT') {
const body = request.postDataJSON() as { actions: string[]; expectedAuthorityEpoch: number }
if (body.expectedAuthorityEpoch !== (grants[user.userId]?.authorityEpoch ?? grantEpoch)) return deny(route, 409)
const hadManage = (grants[user.userId]?.actions ?? []).includes('system.permissions.manage')
if (hadManage && !body.actions.includes('system.permissions.manage')) {
return route.fulfill({ status: 400, contentType: 'application/json', body: JSON.stringify({ error: 'cannot remove the final permission manager' }) })
}
grants[user.userId] = { actions: body.actions, authorityEpoch: ++grantEpoch }
return ok(route, { receiptId: 'g1', authorityEpoch: grantEpoch })
}
}
// 未点名端点(profiles/plugins/conversations/workspaces 等启动探测)按空列表兜底。
return route.fulfill({ contentType: 'application/json', body: '[]' })
},
}
}
type World = ReturnType<typeof world>
async function openAdmin(page: Page, me: Me): Promise<World> {
const w = world(me)
await page.route('**/api/**', route => { void w.route(route) })
await page.goto('/')
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '系统管理…' }).click()
await expect(page.getByRole('dialog', { name: '系统管理' })).toBeVisible()
return w
}
const openAdminAsAdmin = (page: Page) => openAdmin(page, { owner: 'root', name: '管理员', auth: true, isAdmin: true, systemActions: [...FULL_ACTIONS] })
async function openRolesSection(page: Page) {
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
// 面板加载带 180ms 防抖,等待首笔角色清单返回后再交互。
await expect(page.locator('.rp-role-list button', { hasText: '软件工程师' })).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: '搜索与过滤' }).click()
}
async function openUsersSection(page: Page) {
await page.getByRole('button', { name: '用户管理', exact: true }).click()
await expect(page.locator('tr', { hasText: 'alice' })).toBeVisible({ timeout: 10_000 })
}
const engRow = (page: Page) => page.locator('.rp-role-list button', { hasText: '软件工程师' })
const reviewRow = (page: Page) => page.locator('.rp-role-list button', { hasText: '审核工程师' })
test('DR10 细粒度管理员:仅用户查看权时看不到角色分区,也不发出角色请求', async ({ page }) => {
const w = await openAdmin(page, { owner: 'mgr', name: '用户管理员', auth: true, isAdmin: false, systemActions: ['system.users.view'] })
await expect(page.getByRole('button', { name: '用户管理', exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: '角色与权限', exact: true })).toHaveCount(0)
await openUsersSection(page)
await page.locator('tr', { hasText: 'alice' }).getByRole('button', { name: '查看与管理' }).click()
await expect(page.getByText(/当前角色 无角色查看权限/)).toBeVisible()
await expect(page.getByLabel('用户生效角色')).toHaveCount(0)
expect(w.calls.filter(call => call.path.endsWith('/role'))).toEqual([])
expect(w.calls.filter(call => call.path === '/api/admin/permissions/catalog')).toEqual([])
})
test('DR10 权限管理者无查看权:出现守护提示且不拉取目录', async ({ page }) => {
const w = await openAdmin(page, { owner: 'mgr', name: '权限管理者', auth: true, isAdmin: false, systemActions: ['system.users.view', 'system.permissions.manage'] })
await openUsersSection(page)
await page.locator('tr', { hasText: 'alice' }).getByRole('button', { name: '查看与管理' }).click()
await expect(page.getByText('需要同时具备系统权限查看权,才能安全编辑现有授权')).toBeVisible()
await expect(page.getByRole('button', { name: '明确系统权限' })).toHaveCount(0)
expect(w.calls.filter(call => call.path === '/api/admin/permissions/catalog')).toEqual([])
})
test('DR6 创建带描述的角色,后续修改保持稳定 roleId', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openRolesSection(page)
await page.getByLabel('新角色名称').fill('数据工程师')
await page.getByLabel('新角色描述').fill('数据管道职位')
await page.getByRole('button', { name: '创建角色', exact: true }).click()
await expect(page.getByRole('status')).toContainText('已创建角色 数据工程师')
await page.getByLabel('角色描述', { exact: true }).fill('数据管道与质量职位')
await page.getByRole('button', { name: '保存资料', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色资料已保存')
expect(w.calls.filter(call => call.method === 'POST' && call.path === '/api/admin/roles')).toHaveLength(1)
const patches = w.calls.filter(call => call.method === 'PATCH' && call.path.startsWith('/api/admin/roles/'))
expect(patches).toHaveLength(1)
expect(patches[0].path).toBe('/api/admin/roles/role_数据工程师')
})
test('DR6 停用角色需显式确认;取消不发出任何请求', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).click()
await page.getByRole('button', { name: '停用角色', exact: true }).click()
const dialog = page.getByRole('alertdialog', { name: '确认停用角色 软件工程师' })
await expect(dialog).toContainText('软件工程师')
await expect(dialog).toContainText('2 个用户绑定')
await expect(dialog).toContainText('不再授予权限')
await dialog.getByRole('button', { name: '取消', exact: true }).click()
await expect(dialog).toHaveCount(0)
expect(w.calls.filter(call => call.method === 'PATCH' && call.path === '/api/admin/roles/role_eng')).toEqual([])
await page.getByRole('button', { name: '停用角色', exact: true }).click()
await page.getByRole('alertdialog').getByRole('button', { name: '确认停用', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色已停用,现有绑定不再授予权限')
const patches = w.calls.filter(call => call.method === 'PATCH' && call.path === '/api/admin/roles/role_eng')
expect(patches).toHaveLength(1)
expect(patches[0].body).toMatchObject({ expectedRoleVersion: 3, enabled: false })
})
test('DR6 停用版本冲突:刷新最新状态并保留重试上下文', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).click()
// 并发方先推进版本,首次确认停用必然 409。
w.roles.find(role => role.roleId === 'role_eng')!.roleVersion = 4
await page.getByRole('button', { name: '停用角色', exact: true }).click()
await page.getByRole('alertdialog').getByRole('button', { name: '确认停用', exact: true }).click()
await expect(page.getByRole('alert')).toContainText('角色状态版本冲突')
await page.getByRole('button', { name: '停用角色', exact: true }).click()
await page.getByRole('alertdialog').getByRole('button', { name: '确认停用', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色已停用')
const patches = w.calls.filter(call => call.method === 'PATCH' && call.path === '/api/admin/roles/role_eng')
expect(patches).toHaveLength(2)
expect(patches[0].body).toMatchObject({ expectedRoleVersion: 3 })
expect(patches[1].body).toMatchObject({ expectedRoleVersion: 4 })
expect(w.calls.some(call => call.method === 'GET' && call.path === '/api/admin/roles/role_eng')).toBe(true)
})
test('DR9 过滤、翻页与视图切换按 roleId 保留草稿且永不串角色', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).click()
await page.getByLabel('编制文档 默认允许').check()
await expect(page.getByText('变更预览(1)')).toBeVisible()
// 切到第二页的审核工程师:不带走软件工程师的草稿。
await page.getByRole('button', { name: '下一页角色', exact: true }).click()
await reviewRow(page).click()
await expect(page.getByText('变更预览(0)')).toBeVisible()
await expect(page.getByLabel('编制文档 默认允许')).not.toBeChecked()
// 切回软件工程师:草稿原样保留。
await page.getByRole('button', { name: '上一页角色', exact: true }).click()
await engRow(page).click()
await expect(page.getByText('变更预览(1)')).toBeVisible()
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
// 状态过滤触发重载:草稿仍在。
await page.getByLabel('角色状态').selectOption('disabled')
await expect(reviewRow(page)).toBeVisible()
await page.getByLabel('角色状态').selectOption('all')
await engRow(page).click()
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
// 单角色 ↔ 矩阵切换保留;矩阵中当前角色的勾选状态一致。
await page.getByRole('button', { name: '角色矩阵', exact: true }).click()
await expect(page.getByLabel('软件工程师 编制文档 默认允许')).toBeChecked()
await page.getByRole('button', { name: '单角色配置', exact: true }).click()
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
expect(w.calls.filter(call => call.path === '/api/admin/roles/role_ops')).toEqual([])
})
test('DR9 离开管理区须显式决定;确认离开后草稿被放弃', async ({ page }) => {
await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).click()
await page.getByLabel('编制文档 默认允许').check()
const dialogs: string[] = []
let leave = false
page.on('dialog', async dialog => {
dialogs.push(dialog.message())
if (dialog.type() === 'confirm') leave ? await dialog.accept() : await dialog.dismiss()
else await dialog.accept()
})
await page.getByRole('button', { name: '返回工作台' }).click()
expect(dialogs).toHaveLength(1)
expect(dialogs[0]).toContain('软件工程师')
await expect(page.getByRole('dialog', { name: '系统管理' })).toBeVisible()
leave = true
await page.getByRole('button', { name: '返回工作台' }).click()
await expect(page.getByRole('dialog', { name: '系统管理' })).toHaveCount(0)
// 重新进入后草稿不复存在(离开即放弃)。
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '系统管理…' }).click()
await openRolesSection(page)
await engRow(page).click()
await expect(page.getByText('变更预览(0)')).toBeVisible()
})
test('DR9 409 只重放同一角色草稿;另一角色草稿不受影响', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).click()
await page.getByLabel('编制文档 默认允许').check()
await page.getByRole('button', { name: '下一页角色', exact: true }).click()
await reviewRow(page).click()
await page.getByLabel('批准文档 默认允许').check()
// 切回软件工程师再保存:并发方已推进版本并改变服务端基线,保存触发 409 rebase。
await page.getByRole('button', { name: '上一页角色', exact: true }).click()
await engRow(page).click()
const eng = w.roles.find(role => role.roleId === 'role_eng')!
eng.roleVersion = 4
eng.permissions = [rp('project.create', false, false), rp('document.read', true, true)]
await page.getByRole('button', { name: '保存权限', exact: true }).click()
await expect(page.getByRole('alert')).toContainText('权限版本冲突;已基于最新版本重放本地草稿')
expect(w.calls.filter(call => call.method === 'GET' && call.path === '/api/admin/roles/role_review')).toEqual([])
// 重放后:本地对编制文档的修改保留;再次保存基于最新版本成功。
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
await page.getByRole('button', { name: '保存权限', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色权限已保存')
const puts = w.calls.filter(call => call.method === 'PUT' && call.path === '/api/admin/roles/role_eng/permissions')
expect(puts).toHaveLength(2)
expect(puts[0].body).toMatchObject({ expectedRoleVersion: 3 })
expect(puts[1].body).toMatchObject({ expectedRoleVersion: 4 })
expect((puts[1].body!.changes as { actionId: string; allowed: boolean }[]).find(change => change.actionId === 'document.author')).toMatchObject({ allowed: true, capAllowed: true })
// 审核工程师的草稿未被动过:切回后仍在。
await page.getByRole('button', { name: '下一页角色', exact: true }).click()
await reviewRow(page).click()
await expect(page.getByLabel('批准文档 默认允许')).toBeChecked()
})
test('DR9 矩阵批量编辑只作用于当前可见页', async ({ page }) => {
await openAdminAsAdmin(page)
await openRolesSection(page)
await page.getByRole('button', { name: '角色矩阵', exact: true }).click()
await page.locator('.rp-matrix button', { hasText: '软件工程师' }).click()
await page.getByRole('button', { name: '当前页默认全选', exact: true }).click()
for (const label of ['创建项目', '管理项目成员', '设置项目覆盖', '读取文档', '编制文档', '批准文档']) {
await expect(page.getByLabel(`软件工程师 ${label} 默认允许`)).toBeChecked()
}
await page.getByRole('button', { name: '下一组', exact: true }).click()
await expect(page.getByLabel('软件工程师 创建导出 默认允许')).not.toBeChecked()
await page.getByRole('button', { name: '上一组', exact: true }).click()
await page.getByRole('button', { name: '当前页上限关闭', exact: true }).click()
await expect(page.getByLabel('软件工程师 创建项目 默认允许')).not.toBeChecked()
// 变更预览在单角色视图:批量编辑按 roleId 保留,切回可见 2 项相对基线变化。
await page.getByRole('button', { name: '单角色配置', exact: true }).click()
await expect(page.getByText('变更预览(2)')).toBeVisible()
})
test('DR10 角色清单失败不拖垮目录加载,可经检索重试恢复', async ({ page }) => {
const w = await openAdminAsAdmin(page)
w.failNextTimes('/api/admin/roles', 1)
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
await expect(page.getByRole('alert')).toContainText('500')
// 目录请求独立发出并成功(互不拖垮)。
expect(w.calls.some(call => call.path === '/api/admin/permissions/catalog')).toBe(true)
await page.getByRole('button', { name: '搜索与过滤' }).click()
await page.getByLabel('搜索角色').fill('软件')
await expect(page.locator('.rp-role-list button', { hasText: '软件工程师' })).toBeVisible({ timeout: 10_000 })
await expect(page.getByRole('alert')).toHaveCount(0)
})
test('DR10 权限目录失败不影响角色列表呈现', async ({ page }) => {
const w = await openAdminAsAdmin(page)
w.failNextTimes('/api/admin/permissions/catalog', 1)
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
await expect(page.getByRole('alert')).toContainText('权限目录加载失败')
await expect(page.locator('.rp-role-list button', { hasText: '软件工程师' })).toBeVisible()
expect(w.calls.some(call => call.path === '/api/admin/roles')).toBe(true)
})
test('DR10 键盘完成核心流程,保存后焦点稳定且状态可被播报', async ({ page }) => {
await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).focus()
await page.keyboard.press('Enter')
await expect(page.getByLabel('角色名称', { exact: true })).toHaveValue('软件工程师')
await page.getByLabel('编制文档 默认允许').focus()
await page.keyboard.press('Space')
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
await page.getByRole('button', { name: '保存权限', exact: true }).focus()
await page.keyboard.press('Enter')
await expect(page.getByRole('status')).toContainText('角色权限已保存')
const focused = await page.evaluate(() => (document.activeElement as HTMLElement | null)?.textContent ?? '')
expect(focused).toContain('保存权限')
})
test('DR10 窄屏矩阵横向可导航且可操作', async ({ page }) => {
await openAdminAsAdmin(page)
await page.setViewportSize({ width: 480, height: 800 })
await openRolesSection(page)
// 窄屏下视图切换按钮存在持续的布局微抖动,Playwright 稳定性检查无法通过;
// 用 DOM 事件切换视图,随后的横向滚动与勾选仍是真实交互。
await page.getByRole('button', { name: '角色矩阵', exact: true }).dispatchEvent('click')
const wrap = page.locator('.rp-matrix-wrap')
await expect(wrap).toBeVisible()
const scroll = await wrap.evaluate(el => ({ sw: el.scrollWidth, cw: el.clientWidth }))
expect(scroll.sw).toBeGreaterThan(scroll.cw)
await page.getByLabel('软件工程师 创建项目 默认允许').check()
await expect(page.getByLabel('软件工程师 创建项目 默认允许')).toBeChecked()
})
test('DR7 两人共享同一角色;换绑后仅一个生效角色且计数随之变化', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openUsersSection(page)
const openUser = async (owner: string) => {
await page.locator('tr', { hasText: owner }).getByRole('button', { name: '查看与管理' }).click()
await expect(page.getByRole('button', { name: '返回列表' })).toBeVisible()
}
await openUser('bob')
await page.getByLabel('用户生效角色').selectOption({ label: '软件工程师' })
await page.getByRole('button', { name: '替换角色', exact: true }).click()
await expect(page.getByRole('status')).toContainText('用户角色已替换')
expect(w.bindings.u2).toBe('role_eng')
await page.getByRole('button', { name: '返回列表', exact: true }).click()
await openUser('alice')
await expect(page.getByText(/当前角色 软件工程师/)).toBeVisible()
await page.getByLabel('用户生效角色').selectOption({ label: '运维工程师' })
await page.getByRole('button', { name: '替换角色', exact: true }).click()
await expect(page.getByRole('status')).toContainText('用户角色已替换')
expect(w.bindings.u1).toBe('role_ops')
expect(w.roles.find(role => role.roleId === 'role_eng')!.bindingCount).toBe(2) // bob + carol
// 用户角色视图始终单角色:GET /role 只返回一个 binding。
expect(w.calls.filter(call => call.method === 'GET' && call.path === '/api/admin/users/u1/role').length).toBeGreaterThan(0)
})
test('DR8 资料可全空创建,并支持按电话等字段检索', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openUsersSection(page)
await page.getByLabel('新用户登录名').fill('dave')
await page.getByRole('button', { name: '创建用户', exact: true }).click()
await expect(page.getByRole('status')).toContainText('已创建 dave')
const created = w.calls.find(call => call.method === 'POST' && call.path === '/api/admin/users')
expect(created!.body!.profile).toEqual(emptyProfile())
await page.getByRole('button', { name: '返回列表', exact: true }).click()
await page.getByLabel('用户搜索字段').selectOption({ label: '电话' })
await page.getByLabel('搜索用户').fill('13800000001')
await expect(page.locator('tr', { hasText: 'alice' })).toBeVisible({ timeout: 10_000 })
await expect(page.locator('tr', { hasText: 'carol' })).toHaveCount(0)
const searches = w.calls.filter(call => call.method === 'GET' && call.path === '/api/admin/users/search')
expect(searches.at(-1)!.query.fields).toContain('phone')
})
test('DR10 加载与空态:加载期间无结果无报错,过滤为空给出选择提示', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
await expect(page.locator('.rp-role-list button', { hasText: '软件工程师' })).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: '搜索与过滤' }).click()
const pending = w.hold('GET /api/admin/roles')
await page.getByLabel('搜索角色').fill('不存在的角色')
await pending.started.promise
await expect(page.locator('.rp-role-list button').first()).toBeDisabled()
await expect(page.getByRole('alert')).toHaveCount(0)
pending.release.resolve()
await expect(page.locator('.rp-role-list button')).toHaveCount(0)
await expect(page.getByText('请选择角色')).toBeVisible()
})
// ── 活体层(IAM_E2E_LIVE=1):同一文件在真实 Backend/RDM 组合上运行真实授权路径。
// 由 Agent-System-RDM scripts/iam-u4-role-acceptance.sh 物化冻结候选源码、
// 构建内嵌产品、全新 SQLite 数据根与随机回环端口后驱动;不拦截任何路由。
const LIVE = process.env.IAM_E2E_LIVE === '1'
async function liveLogin(page: Page) {
await page.goto('/')
await page.getByPlaceholder('由管理员分配').fill(process.env.IAM_E2E_OWNER ?? '')
await page.getByPlaceholder('初始密码或常用密码').fill(process.env.IAM_E2E_PASSWORD ?? '')
await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page.locator('.tsb-user')).toBeVisible({ timeout: 15_000 })
}
async function openAdminZoneLive(page: Page) {
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '系统管理…' }).click()
await expect(page.getByRole('dialog', { name: '系统管理' })).toBeVisible()
}
test('LIVE 真实登录加载真实权限目录,创建、配置并停用角色', async ({ page }) => {
test.skip(!LIVE, 'live 层由 iam-u4-role-acceptance.sh 驱动')
test.setTimeout(90_000)
await liveLogin(page)
await openAdminZoneLive(page)
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
await page.getByRole('button', { name: '搜索与过滤' }).click()
await expect(page.getByLabel('新角色名称')).toBeVisible({ timeout: 15_000 })
// 真实空库无角色:先经真实 Backend 创建,选中后权限表才渲染真实目录。
await page.getByLabel('新角色名称').fill('数据工程师')
await page.getByLabel('新角色描述').fill('活体验收职位')
await page.getByRole('button', { name: '创建角色', exact: true }).click()
await expect(page.getByRole('status')).toContainText('已创建角色 数据工程师', { timeout: 15_000 })
// 真实目录的首页可能全是受保护系统授权(复选框禁用):逐组翻到可编辑动作。
const firstDefault = page.locator('input[aria-label$="默认允许"]:not([disabled])').first()
for (let pageIdx = 0; (await firstDefault.count()) === 0 && pageIdx < 15; pageIdx++) {
await page.getByRole('button', { name: '下一组', exact: true }).click()
await page.waitForTimeout(150)
}
await expect(firstDefault).toBeVisible({ timeout: 15_000 })
await firstDefault.check()
await page.getByRole('button', { name: '保存权限', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色权限已保存', { timeout: 15_000 })
await page.getByRole('button', { name: '停用角色', exact: true }).click()
await expect(page.getByRole('alertdialog')).toContainText('数据工程师')
await page.getByRole('alertdialog').getByRole('button', { name: '确认停用', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色已停用,现有绑定不再授予权限', { timeout: 15_000 })
})
test('LIVE DR13 非成员账号被拒读取项目业务内容;创建者放行', async ({ request }) => {
test.skip(!LIVE, 'live 层由 iam-u4-role-acceptance.sh 驱动')
const base = process.env.PLAYWRIGHT_BASE_URL!
const projectId = process.env.IAM_E2E_PROJECT_ID
test.expect(projectId).toBeTruthy()
const memberList = async (owner: string, password: string) => {
await request.post(`${base}/api/auth/login`, { data: { owner, password } })
const res = await request.post(`${base}/api/commands/rdm.project.member.list`, {
data: { params: { projectId } },
})
const body = await res.json().catch(() => null)
return { ok: res.ok(), body }
}
const observer = await memberList(process.env.IAM_E2E_OBSERVER_OWNER ?? '', process.env.IAM_E2E_OBSERVER_PASSWORD ?? '')
const denied = !observer.ok || observer.body?.receipt?.state === 'rejected'
expect(denied, `observer 应被拒绝:${JSON.stringify(observer.body).slice(0, 240)}`).toBe(true)
const creator = await memberList(process.env.IAM_E2E_OWNER ?? '', process.env.IAM_E2E_PASSWORD ?? '')
const allowed = creator.ok && creator.body?.receipt?.state !== 'rejected'
expect(allowed, `创建者应放行:${JSON.stringify(creator.body).slice(0, 240)}`).toBe(true)
})