diff --git a/e2e/iam-role-authority.playwright.ts b/e2e/iam-role-authority.playwright.ts new file mode 100644 index 0000000..23d3136 --- /dev/null +++ b/e2e/iam-role-authority.playwright.ts @@ -0,0 +1,617 @@ +// 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() + const firstDefault = page.locator('input[aria-label$="默认允许"]:not([disabled])').first() + // 真实目录来自被测 Backend:非受保护动作的默认允许复选框已渲染。 + await expect(firstDefault).toBeVisible({ timeout: 15_000 }) + 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 }) + 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) +}) diff --git a/src/components/AccountUsersPanel.tsx b/src/components/AccountUsersPanel.tsx index b95436a..ebfab89 100644 --- a/src/components/AccountUsersPanel.tsx +++ b/src/components/AccountUsersPanel.tsx @@ -181,7 +181,7 @@ export function AccountUsersPanel() { } - {notice &&
{notice}
} + {notice &&
{notice}
} {err &&
{err}
} {selected ?
diff --git a/src/components/AdminZone.tsx b/src/components/AdminZone.tsx index 679d09f..4fc5e37 100644 --- a/src/components/AdminZone.tsx +++ b/src/components/AdminZone.tsx @@ -10,7 +10,7 @@ import { EmptyState, Icon, type IconName } from '@ui' import { ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections' import { RdmAuditWorkspace } from './RdmAuditWorkspace' import { AccountUsersPanel } from './AccountUsersPanel' -import { RolePermissionsPanel, hasSystemAction } from './RolePermissionsPanel' +import { RolePermissionsPanel, roleDraftLeaveGuard, hasSystemAction } from './RolePermissionsPanel' import type { AdminZoneSection } from '../api' interface AdminSectionDef { @@ -85,7 +85,11 @@ export function AdminZone() { return (
- 系统管理 diff --git a/src/components/RolePermissionsPanel.tsx b/src/components/RolePermissionsPanel.tsx index b5825bc..a70acac 100644 --- a/src/components/RolePermissionsPanel.tsx +++ b/src/components/RolePermissionsPanel.tsx @@ -7,6 +7,9 @@ import { import { Collapsible, Icon } from '@ui' import { useStore } from '../store' +/** DR9 离开管理区守卫:面板有未保存权限草稿时注册;AdminZone「返回工作台」消费。 */ +export const roleDraftLeaveGuard: { current: (() => boolean) | null } = { current: null } + export function filterRoles( roles: RoleRecord[], query: string, mode: 'exact' | 'fuzzy', status: 'all' | 'enabled' | 'disabled', ): RoleRecord[] { @@ -55,7 +58,8 @@ export function RolePermissionsPanel() { const [roles, setRoles] = useState(null) const [catalog, setCatalog] = useState([]) const [selectedId, setSelectedId] = useState('') - const [draft, setDraft] = useState(null) + const [drafts, setDrafts] = useState>({}) + const [confirmDisable, setConfirmDisable] = useState(false) const [name, setName] = useState('') const [description, setDescription] = useState('') const [query, setQuery] = useState('') @@ -76,14 +80,18 @@ export function RolePermissionsPanel() { const [err, setErr] = useState('') const requestSequence = useRef(0) const selectedIdRef = useRef('') + const savePermsRef = useRef(null) selectedIdRef.current = selectedId const selected = roles?.find(role => role.roleId === selectedId) ?? null + const draft = selected ? drafts[selected.roleId] ?? null : null + const allEditableIds = useMemo(() => catalog.filter(item => !item.protected).map(item => item.actionId), [catalog]) + const dirty = !!draft && visiblePatch(draft, allEditableIds).length > 0 const groups = useMemo(() => [...new Set(catalog.map(item => item.group))], [catalog]) const filtered = roles ?? [] const pageItems = useMemo(() => groupPermissionPage(catalog, group, page, 6), [catalog, group, page]) const editablePageItems = pageItems.filter(item => !item.protected) - const changes = draft ? visiblePatch(draft, catalog.filter(item => !item.protected).map(item => item.actionId)) : [] + const changes = draft ? visiblePatch(draft, allEditableIds) : [] const load = async (pageCursor = roleCursor, refreshCatalog = false, preferredId = selectedId, filters = { query, mode, status, action: actionFilter }) => { @@ -103,13 +111,14 @@ export function RolePermissionsPanel() { setErr(String(roleResult.reason)) return } - if (catalogResult.status === 'rejected') setErr(`权限目录加载失败:${catalogResult.reason}`) const rolePage = roleResult.value setRoles(rolePage.roles ?? []) setNextRoleCursor(rolePage.nextCursor ?? '') const nextSelected = rolePage.roles.find(role => role.roleId === preferredId) ?? rolePage.roles[0] ?? null if (nextSelected) selectRole(nextSelected, descriptors, sequence) - else { setSelectedId(''); setDraft(null) } + else setSelectedId('') + // DR10:目录失败是部分失败态,需在角色选中(selectRole 会清空提示)之后仍可见。 + if (catalogResult.status === 'rejected') setErr(prev => prev || `权限目录加载失败:${catalogResult.reason}`) } const selectRole = (role: RoleRecord, descriptors = catalog, sequence?: number) => { @@ -118,11 +127,28 @@ export function RolePermissionsPanel() { setSelectedId(role.roleId) setName(role.name) setDescription(role.description) - setDraft(createRolePermissionDraft(roleValues(role, descriptors), nextSequence)) + // DR9:草稿按 roleId 隔离保存;翻页、过滤、视图切换回到该角色时保留, + // 切换角色永不转移他人草稿。仅首次选中时以服务端值为基线。 + setDrafts(prev => prev[role.roleId] ? prev + : { ...prev, [role.roleId]: createRolePermissionDraft(roleValues(role, descriptors), nextSequence) }) setNotice('') setErr('') } + // DR9:离开管理区须显式决定;带未保存草稿时刷新走浏览器原生确认。 + useEffect(() => { + if (!dirty || !selected) { roleDraftLeaveGuard.current = null; return } + const message = `角色「${selected.name}」有未保存的权限修改,离开将放弃这些修改。确定离开吗?` + roleDraftLeaveGuard.current = () => window.confirm(message) + return () => { roleDraftLeaveGuard.current = null } + }, [dirty, selected?.roleId, selected?.name]) + useEffect(() => { + if (!dirty) return + const handler = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' } + window.addEventListener('beforeunload', handler) + return () => window.removeEventListener('beforeunload', handler) + }, [dirty]) + useEffect(() => { setNextRoleCursor(''); setRoleLoading(true) const timer = window.setTimeout(() => { void load(roleCursor) }, 180) @@ -195,7 +221,8 @@ export function RolePermissionsPanel() { if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return setRoles(current => current?.some(role => role.roleId === roleId) ? current.map(role => role.roleId === roleId ? fresh : role) : [fresh, ...(current ?? [])]) - setDraft(rebaseRoleDraft(currentDraft, roleValues(fresh, catalog))) + // DR9:409 只重放同一角色的草稿;基于最新服务端值回放本地相对基线的变化。 + setDrafts(prev => ({ ...prev, [roleId]: rebaseRoleDraft(currentDraft, roleValues(fresh, catalog)) })) setErr('权限版本冲突;已基于最新版本重放本地草稿,请核对后再次保存') } @@ -210,27 +237,39 @@ export function RolePermissionsPanel() { setBusy(true); setErr(''); setNotice('') try { await api.replaceRolePermissions(selected.roleId, selected.roleVersion, full, crypto.randomUUID()) + setDrafts(current => { + if (!(roleId in current)) return current + const next = { ...current }; delete next[roleId]; return next + }) await load(roleCursor, true, selected.roleId); setNotice('角色权限已保存') } catch (error) { if (String(error).includes('409')) { try { await rebasePermissionConflict(roleId, draft, operationSequence) } catch (refreshError) { setErr(String(refreshError)) } } else setErr(String(error)) } - finally { setBusy(false) } + finally { + setBusy(false) + // DR10:保存/冲突/失败后焦点稳定——若焦点因按钮短暂 disabled 掉回 body,则归还。 + requestAnimationFrame(() => { + if (savePermsRef.current && !savePermsRef.current.disabled && document.activeElement === document.body) savePermsRef.current.focus() + }) + } } const editValue = (actionId: string, field: 'allowed' | 'capAllowed', checked: boolean) => { - if (!draft) return - const current = draft.values[actionId] ?? { allowed: false, capAllowed: false } + const current = drafts[selectedId] + if (!current) return + const value0 = current.values[actionId] ?? { allowed: false, capAllowed: false } const value = field === 'allowed' - ? { allowed: checked, capAllowed: checked ? true : current.capAllowed } - : { allowed: checked ? current.allowed : false, capAllowed: checked } - setDraft(updateRolePermission(draft, actionId, value)) + ? { allowed: checked, capAllowed: checked ? true : value0.capAllowed } + : { allowed: checked ? value0.allowed : false, capAllowed: checked } + setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: updateRolePermission(prev[selectedId], actionId, value) } : prev) } const editVisible = (field: 'allowed' | 'capAllowed', checked: boolean) => { - if (!draft) return - let next = draft + const start = drafts[selectedId] + if (!start) return + let next = start for (const item of editablePageItems) { const current = next.values[item.actionId] ?? { allowed: false, capAllowed: false } const value = field === 'allowed' @@ -238,7 +277,7 @@ export function RolePermissionsPanel() { : { allowed: checked ? current.allowed : false, capAllowed: checked } next = updateRolePermission(next, item.actionId, value) } - setDraft(next) + setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: next } : prev) } const matrix = () =>
@@ -301,7 +340,7 @@ export function RolePermissionsPanel() { }
- {notice &&
{notice}
} + {notice &&
{notice}
} {err &&
{err}
} {view === 'matrix' ? matrix() :
+ {confirmDisable && selected.enabled &&
+ 停用角色「{selected.name}」?该角色现有 {selected.bindingCount} 个用户绑定,停用后这些绑定将不再授予权限。 + + +
} {canViewPermissions ? <>{pageItems.map(item => { const value = draft.values[item.actionId] ?? { allowed: false, capAllowed: false } @@ -331,7 +377,7 @@ export function RolePermissionsPanel() { {changes.length === 0 ?

没有权限变化

:
    {changes.map(change =>
  • {change.actionId}:默认 {change.allowed ? '允许' : '拒绝'},上限 {change.capAllowed ? '允许' : '拒绝'}
  • )}
} - {canManagePermissions && } + {canManagePermissions && } :

当前账号没有查看角色权限目录的系统权限

} :

请选择角色

} diff --git a/src/styles.css b/src/styles.css index 0aa8b5e..a3eae5e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -522,6 +522,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; } .rp-view-toggle { justify-content: flex-end; } .rp-view-toggle .ad-mini.on { background: var(--bg-active); color: var(--text); } .rp-single { display: grid; grid-template-columns: minmax(180px, 240px) minmax(0, 1fr); gap: 16px; align-items: start; } +.rp-disable-confirm { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 8px; font-size: var(--fs-ui-sm); color: var(--warn-text); background: var(--warn-soft); border: 1px solid color-mix(in srgb, var(--warn) 35%, transparent); border-radius: var(--radius); padding: 8px 11px; } .rp-role-list { display: flex; flex-direction: column; gap: 2px; padding: 8px; border: 1px solid var(--border-soft); border-radius: var(--radius-lg); background: var(--bg-panel); } .rp-role-list .az-nav-item { justify-content: space-between; width: 100%; } .rp-editor { min-width: 0; display: flex; flex-direction: column; gap: 12px; }
权限默认允许角色上限说明