/** 角色授权界面验收;合成数据只验证交互,不改真实账号或权限。 */ import assert from 'node:assert/strict' import fs from 'node:fs' import { createRequire } from 'node:module' const require = createRequire(import.meta.url) const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'C:/Users/Administrator/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright') const base = process.env.AUTH_UI_URL || 'http://127.0.0.1:5173' const output = process.env.AUTH_UI_OUTPUT || '.ai' const browser = await chromium.launch({ headless: true, channel: 'msedge' }) const page = await browser.newPage({ viewport: { width: 1366, height: 1000 } }) const checks = [], errors = [], saves = [] let revision = 1, failPreview = false, conflict = false const names = { view: '查看', create: '新增', update: '编辑', regions: '维护区域', plans: '维护计划', prepare: '准备核算资料', calculate: '核算与重算', allocation: '修改工资分配', generate: '生成工资', export: '导出' } const definitions = [ ['system:users', '系统管理', '用户管理', ['view', 'create', 'update']], ['system:roles', '系统管理', '角色管理', ['view', 'create', 'update']], ['base:personnel', '基础资料', '人员管理', ['view', 'create', 'update', 'regions', 'plans']], ['payroll', '绩效工资', '正式核算与分配', ['view', 'prepare', 'calculate', 'allocation']], ['settlements', '绩效工资', '月度工资', ['view', 'generate']], ['reports:business', '报表统计', '业务统计', ['view', 'export']], ] const catalog = definitions.flatMap(([module, group, module_name, actions]) => actions.map(action => ({ code: `${module}:${action}`, module, group, module_name, name: names[action], action, view_code: `${module}:view`, admin_only: module.startsWith('system:'), maker_required: false }))) const roleCodes = { admin: catalog.map(item => item.code), finance: ['payroll:view'], payroll_maker: ['payroll:view', 'payroll:calculate', 'settlements:view', 'settlements:generate'] } const roles = [{ id: 1, role_code: 'admin', role_name: '系统管理员', description: '系统管理', status: 1, user_count: 1 }, { id: 2, role_code: 'finance', role_name: '财务', status: 1, user_count: 1 }, { id: 3, role_code: 'payroll_maker', role_name: '工资经办', status: 1, user_count: 0 }] let assigned = ['finance'] function effective(codes) { return [...new Set(codes.flatMap(code => roleCodes[code] ?? []))] } function result(codes) { const permissions = effective(codes); return { permissions, roles: codes, sources: Object.fromEntries(catalog.map(item => [item.code, { roles: codes.filter(code => roleCodes[code]?.includes(item.code)), override: null, effective: permissions.includes(item.code) }])) } } await page.addInitScript(() => localStorage.setItem('ys_token', 'synthetic-rbac-ui')) page.on('pageerror', error => errors.push(error.message)) await page.route(url => url.pathname.startsWith('/api/'), async route => { const request = route.request(), url = new URL(request.url()), path = url.pathname, method = request.method() let data = {}, status = 200 if (path.endsWith('/auth/me')) data = { id: 1, username: 'synthetic-admin', real_name: '授权界面演练', roles: ['admin'], permissions: roleCodes.admin, can_write_payroll: true, is_payroll_maker: true, maker_required_permissions: [] } else if (path.endsWith('/permissions/catalog')) data = catalog else if (path.endsWith('/system/roles')) data = { list: roles.map(item => ({ ...item, permission_count: roleCodes[item.role_code].length })), total: roles.length, pageNum: 1, pageSize: 200 } else if (path.endsWith('/users/meta/roles')) data = roles else if (/\/roles\/\d+\/permissions$/.test(path)) { const id = Number(path.match(/roles\/(\d+)/)[1]), item = roles.find(role => role.id === id) if (method === 'PUT') { const body = request.postDataJSON(); saves.push({ path, body }) if (conflict || body.expected_revision !== revision) status = 409 else { roleCodes[item.role_code] = body.permission_codes; revision++ } } data = { role_id: id, role_code: item.role_code, role_name: item.role_name, permission_codes: roleCodes[item.role_code], fixed: id === 1, revision } } else if (path.endsWith('/system/users')) data = { list: [{ id: 7, username: 'synthetic-user', real_name: '角色继承演练', roles: assigned, status: 1 }], total: 1, pageNum: 1, pageSize: 20 } else if (path.endsWith('/users/7/authorization/preview')) { if (failPreview) status = 503 data = result(request.postDataJSON().role_codes) } else if (path.endsWith('/users/7/authorization')) { if (method === 'PUT') { const body = request.postDataJSON(); saves.push({ path, body }); assigned = body.role_codes; revision++ } data = { ...result(assigned), id: 7, username: 'synthetic-user', role_codes: assigned, overrides: [], revision, is_payroll_maker: true } } return route.fulfill({ status, json: { code: status, message: status === 409 ? '权限版本已更新' : status === 503 ? '合成预览失败' : 'success', data: status === 200 ? data : null } }) }) try { await page.goto(`${base}/system/roles`) await page.getByRole('button', { name: '查看权限', exact: true }).click() await page.getByText('系统管理员拥有全部权限,仅供查看', { exact: true }).waitFor() assert.equal(await page.getByRole('button', { name: '保存角色权限', exact: true }).count(), 0) assert.equal(await page.locator('.permission-drawer .el-table').count(), 0) for (const width of [1366, 1920, 768]) { await page.setViewportSize({ width, height: 1000 }) await page.screenshot({ path: `${output}/rbac-admin-${width}.png`, fullPage: true }) assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false) } checks.push('管理员固定只读、模块分组、1366/1920/768 布局无横向溢出') await page.getByRole('button', { name: '关闭', exact: true }).click() await page.setViewportSize({ width: 1366, height: 1000 }) await page.getByRole('row').filter({ hasText: '财务' }).getByRole('button', { name: '配置权限', exact: true }).click() await page.getByRole('navigation', { name: '权限业务分组' }).getByRole('button', { name: /^绩效工资/ }).click() await page.getByText('核算与重算', { exact: true }).click() assert.ok(await page.getByRole('checkbox', { name: '正式核算与分配 查看', exact: true }).isChecked()) assert.ok(await page.getByRole('checkbox', { name: '正式核算与分配 核算与重算', exact: true }).isChecked()) const moduleAll = page.getByRole('checkbox', { name: '正式核算与分配模块全选', exact: true }) assert.equal(await moduleAll.evaluate(element => element.indeterminate), true) await page.getByLabel('正式核算与分配权限', { exact: true }).getByText('全选', { exact: true }).click() assert.ok(await page.getByRole('checkbox', { name: '正式核算与分配 修改工资分配', exact: true }).isChecked()) await page.getByRole('textbox', { name: '搜索权限', exact: true }).fill('人员') await page.getByRole('checkbox', { name: '人员管理模块全选', exact: true }).locator('xpath=ancestor::label[1]').click() await page.getByRole('textbox', { name: '搜索权限', exact: true }).fill('不存在的模块') await page.getByText('没有匹配的权限', { exact: true }).waitFor() await page.getByRole('textbox', { name: '搜索权限', exact: true }).fill('') await page.getByRole('textbox', { name: '授权修改原因', exact: true }).fill('合成验收:财务经办职责调整') await page.screenshot({ path: `${output}/rbac-role-editor.png`, fullPage: true }) await page.getByRole('button', { name: '保存角色权限', exact: true }).click() await page.getByRole('dialog').waitFor({ state: 'hidden' }) assert.ok(saves[0].body.permission_codes.includes('payroll:view') && saves[0].body.permission_codes.includes('payroll:calculate')) checks.push('操作联动查看、模块全选与半选、搜索空态、原因必填及保存实际载荷') await page.goto(`${base}/system/users`) await page.getByRole('button', { name: '分配角色', exact: true }).click() await page.getByRole('heading', { name: '分配角色', exact: true }).waitFor() assert.equal(await page.getByText('个人设置', { exact: true }).count(), 0) assert.equal(await page.getByText('额外允许', { exact: true }).count(), 0) await page.getByRole('checkbox', { name: '分配工资经办角色', exact: true }).locator('xpath=ancestor::label[1]').click() await page.getByText('已选:财务、工资经办', { exact: false }).waitFor() await page.getByRole('textbox', { name: '授权修改原因', exact: true }).fill('合成验收:增加工资经办角色') await page.screenshot({ path: `${output}/rbac-user-role-assignment.png`, fullPage: true }) await page.getByRole('button', { name: '保存角色分配', exact: true }).click() await page.getByRole('dialog').waitFor({ state: 'hidden' }) assert.deepEqual(saves.at(-1).body.role_codes, ['finance', 'payroll_maker']) assert.deepEqual(saves.at(-1).body.overrides, []) checks.push('用户只分配角色、继承权限只读预览、无个人权限覆盖入口') await page.getByRole('button', { name: '分配角色', exact: true }).click() failPreview = true await page.getByRole('checkbox', { name: '分配工资经办角色', exact: true }).locator('xpath=ancestor::label[1]').click() await page.getByText('继承权限预览失败,请重试后再保存。', { exact: true }).waitFor() assert.ok(await page.getByRole('button', { name: '保存角色分配', exact: true }).isDisabled()) failPreview = false await page.getByRole('button', { name: '重试预览', exact: true }).click() await page.getByRole('textbox', { name: '授权修改原因', exact: true }).fill('角色变更演练') await page.getByRole('button', { name: '取消', exact: true }).click() await page.getByRole('button', { name: '放弃修改', exact: true }).click() checks.push('预览失败禁止保存、重试恢复及未保存修改关闭保护') await page.goto(`${base}/system/authorization`) await page.waitForURL('**/system/roles') assert.equal(await page.getByText('权限与工资制作人', { exact: true }).count(), 0) checks.push('旧授权页跳转角色管理,导航无重复入口') assert.deepEqual(errors, []) fs.writeFileSync(`${output}/rbac-browser-result.json`, JSON.stringify({ checks, errors, saves: saves.length }, null, 2)) console.log(JSON.stringify({ checks, errors, saves: saves.length })) } catch (error) { await page.screenshot({ path: `${output}/rbac-browser-failure.png`, fullPage: true }) fs.writeFileSync(`${output}/rbac-browser-failure.txt`, String(error) + '\n' + await page.locator('body').innerText()) throw error } finally { await browser.close() }