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