// 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(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 { 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 { 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 = { 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 = {} const calls: { method: string; path: string; query: Record; body?: Record }[] = [] const holds = new Map; release: ReturnType }>() const failNext = new Map() 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 : 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 } 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 async function openAdmin(page: Page, me: Me): Promise { 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) })