feat(identity): 完成 A2 角色权限与数据范围全栈落地

背景:A2 开卡前完成 H1 权限门,确认权限点目录、特殊权限、SoD 三对、六类数据范围与超管隐式全集口径(ADR-001)。

改动:
- shared:30 权限点 / 10 模块目录与元数据、特殊权限 6 编码、SoD 三对、角色与权限树契约;/me 改 dataScopes 多角色并集。
- prisma:expand 迁移新增 permissions / data_scopes;种子按原型口径重写角色权限、数据范围与 8 项特殊授予。
- api:角色 CRUD / 复制 / 停用 / 删除、权限点启动同步、SoD 40304、系统角色保护、六类范围 ScopeContext 与 CustomerDirectory 端口、变更审计与 outbox。
- web:方案 B 主从分栏三角色页面(列表 / 编辑器 / 新建),权限树、范围选择、特殊权限二次确认、保存影响、SoD 冲突、只读态与模板预填。
- 测试:角色集成 13 条 + A2 e2e 1 条;A1 全量回归。

验证:format:check / lint / typecheck / test(306)/ test:int(27)/ A2 e2e(1,复跑通过)/ openapi:export 全绿。
This commit is contained in:
Stev_Wang committed 2026-11-03 12:26:51 +08:00
1 parent f97f97b0cd
commit 61894df401
80 files changed
+14380 -237

No files matched your search

+30 -4
View File
@@ -2,10 +2,11 @@
## 现有用例
| 文件 | 说明 | 默认是否执行 |
| --------------------- | ---------------------------------- | ----------------- |
| `smoke.spec.ts` | A0 冒烟:登录、模块切换、403 / 404 | 是 |
| `a1-identity.spec.ts` | A1-QA-01 账号生命周期(①–⑥) | 否,需 `A1_E2E=1` |
| 文件 | 说明 | 默认是否执行 |
| --------------------- | --------------------------------------------- | ----------------- |
| `smoke.spec.ts` | A0 冒烟:登录、模块切换、403 / 404 | 是 |
| `a1-identity.spec.ts` | A1-QA-01 账号生命周期(①–⑥) | 否,需 `A1_E2E=1` |
| `a2-roles.spec.ts` | A2-QA-01 角色管理 / 权限三态 / SoD / 即时生效 | 否,需 `A2_E2E=1` |
## A1 账号生命周期用例
@@ -33,3 +34,28 @@ A1_E2E=1 pnpm exec playwright test e2e/a1-identity.spec.ts --reporter=list
- 门控未开启(无 `A1_E2E=1`)时整文件 `test.skip`,`pnpm test:e2e` 与 CI 行为不变。
- 若在 `vite preview`(4173)上执行,需把 API 的 `WEB_ORIGIN` 同时包含 `http://127.0.0.1:4173`,否则跨域请求被拦;本用例默认打 5173 dev,避免改动 API 配置。
## A2 角色管理用例(A2-QA-01)
覆盖一条完整旅程(真实 API + 真实 PG/Redis + 真实浏览器):
1. 管理员登录 → `/system/roles` → 新建角色(名称、2 个普通权限、数据范围「本部门」)→ 保存进入编辑器。
2. 编辑器改权限点;授予特殊权限(`delivery:export` 开关)触发**二次确认**,保存前再确认一次(特殊权限双确认)。
3. SoD 冲突:同时勾选 `script:write` × `script:review` → 顶部「命中职责互斥」拦截 + 底部「存在职责互斥,保存不可用」且保存按钮禁用;解除后恢复可保存。
4. 用 API 断言**权限即时生效**:绑定用户同一 access token 的 `/me` 在下一次请求即包含 `user:view` 与 `delivery:export`,`GET /users` 由 403 → 200;`GET /roles/{id}` 的普通权限与特殊权限分桶正确。
5. 权限三态(UI 侧):该用户有 `user:view` 但无 `role:view`,访问 `/system/roles` 显示 403 页。
6. 停用确认(勾选知晓 + 停用原因)→ 编辑器状态变「停用」;API 断言 `/me` 权限清空、`GET /users` 返回 403 `40301`;列表「停用」筛选下可见该角色且行内标记「停用」。
7. 数据自建自清:`beforeAll` 按前缀清理上次异常中断的遗留角色/用户;`afterAll` 解绑并停用 `qa2.roles.*` 用户、删除 `A2E2E-角色-*` 角色(不停用 admin、不改 admin 密码)。
### 前置与运行
前置与 A1 相同(API `http://127.0.0.1:3000/api/v1`、Web `http://127.0.0.1:5173` 非 demo 会话、已 `db:reset` + seed):
```bash
A2_E2E=1 pnpm exec playwright test e2e/a2-roles.spec.ts --reporter=list
```
- 门控未开启(无 `A2_E2E=1`)时整文件 `test.skip`,`pnpm test:e2e` / CI 行为不变。
- 可选覆盖:`A2_E2E_BASE_URL`(缺省 `http://127.0.0.1:5173`)、`A2_E2E_API_BASE_URL`(缺省 `http://127.0.0.1:3000/api/v1`)、`A2_E2E_ACTOR_ACCOUNT` / `A2_E2E_ACTOR_PASSWORD`(缺省 `jiang.lan` / `QaE2eAdmin2026`,首跑用种子口令 `lingxi2026` 自动完成首登改密)。
- 截图:`design/a2-ui-v1/shots/impl/e2e/`(`01-roles-list` … `11-roles-list-disabled`,共 11 张 1440×900)。
- 说明:用户按 A1 口径只停用不删除(用户不可删);角色在解绑后删除,可重复运行。
+711
View File
@@ -0,0 +1,711 @@
/// <reference lib="dom" />
/**
* A2-QA-01 · 角色管理端到端用例(真实 API + 真实 PG/Redis + 真实浏览器)
*
* 覆盖(对应开发计划 §6.3 A2-QA-01 / A2-FE-01 / A2-FE-02 验收要点):
* ① 管理员登录 → /system/roles → 新建角色(填名称、勾 2 个权限、选「本部门」)→ 保存 → 进入编辑器
* ② 编辑器改权限点 + 授予特殊权限:特殊权限二次确认(开关一次 + 保存前一次)
* ③ SoD 冲突:同时勾选 script:write × script:review → 顶部拦截文案 + 底部「保存不可用」禁用保存
* ④ 保存后 API 断言权限即时生效:绑定用户同一 access token 的 /me 立即包含新权限与特殊权限
* ⑤ 无 role:view 的账号访问 /system/roles → 浏览器 403 页(权限三态 UI 侧)
* ⑥ 停用确认(勾选知晓 + 停用原因)→ 列表状态变为「停用」;API 断言用户下一次请求失去权限(40301)
*
* 运行前置(本地):
* - API:http://127.0.0.1:3000/api/v1(真实 PG + Redis)
* - Web:http://127.0.0.1:5173(`pnpm --filter @lingxi/web dev`,非 demo 会话)
* - 门控:仅在 A2_E2E=1 时执行,否则整文件 skip,不影响 CI 与 e2e/smoke.spec.ts
*
* 运行命令:
* A2_E2E=1 pnpm exec playwright test e2e/a2-roles.spec.ts --reporter=list
*
* 测试数据:管理员 actor 默认种子超管 jiang.lan(初始口令 lingxi2026 → 首次运行改为
* QaE2eAdmin2026,与 A1 e2e 共用约定,不要用 admin);用例自建 1 个 `qa2.roles.*` 用户与
* 1 个 `A2E2E-角色-*` 角色,结束后解绑 / 停用用户并删除角色(自建自清),不修改 admin 密码。
*/
import { mkdir } from 'node:fs/promises';
import { resolve } from 'node:path';
import {
type APIRequestContext,
type APIResponse,
expect,
type Locator,
type Page,
request,
test,
} from '@playwright/test';
const API_BASE = process.env.A2_E2E_API_BASE_URL ?? 'http://127.0.0.1:3000/api/v1';
const WEB_BASE = process.env.A2_E2E_BASE_URL ?? 'http://127.0.0.1:5173';
const ENABLED = process.env.A2_E2E === '1';
const ACTOR_ACCOUNT = process.env.A2_E2E_ACTOR_ACCOUNT ?? 'jiang.lan';
const ACTOR_PASSWORD = process.env.A2_E2E_ACTOR_PASSWORD ?? 'QaE2eAdmin2026';
const SEED_PASSWORD = process.env.A2_E2E_SEED_PASSWORD ?? 'lingxi2026';
const QA_INITIAL_PASSWORD = 'QaInit2026x';
const QA_PASSWORD = 'QaRole2026x';
const SCREENSHOT_DIR = resolve('design/a2-ui-v1/shots/impl/e2e');
/** 角色 / 用户前缀:用于自清理与识别遗留数据(不影响 seed 与其他用例) */
const ROLE_PREFIX = 'A2E2E-角色-';
const QA_ACCOUNT_PREFIX = 'qa2.roles.';
test.skip(!ENABLED, 'A2_E2E=1 才运行角色管理 e2e(依赖 API :3000 + web :5173 + PG/Redis)');
test.use({ baseURL: WEB_BASE, viewport: { width: 1440, height: 900 } });
// ---------------------------------------------------------------------------
// 类型与断言辅助(仅依赖 @playwright/test,避免 e2e 引入额外运行时依赖)
// ---------------------------------------------------------------------------
interface ApiResult {
status: number;
body: unknown;
}
interface QaUser {
id: string;
account: string;
}
let apiContext: APIRequestContext | null = null;
let actorToken: string | null = null;
let bootstrapError: string | null = null;
let qaUser: QaUser | null = null;
let departmentId = '';
let materialOperatorRoleId = '';
let createdRoleId = '';
let createdRoleName = '';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!isRecord(value)) {
throw new Error(`${label} 不是 JSON 对象`);
}
return value;
}
function requireString(source: Record<string, unknown>, key: string, label: string): string {
const value = source[key];
if (typeof value !== 'string' || value.length === 0) {
throw new Error(`${label}.${key} 缺失或不是字符串`);
}
return value;
}
function optionalNumber(source: Record<string, unknown>, key: string): number | null {
const value = source[key];
return typeof value === 'number' ? value : null;
}
function stringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string')
: [];
}
function requireContext(): APIRequestContext {
if (apiContext === null) {
throw new Error('APIRequestContext 未初始化');
}
return apiContext;
}
function requireActor(): string {
if (actorToken === null) {
throw new Error('管理员会话未就绪');
}
return actorToken;
}
function requireQaUser(): QaUser {
if (qaUser === null) {
throw new Error('QA 测试用户未创建');
}
return qaUser;
}
/** 发送 API 请求:body JSON 字符串化,避免把 unknown 直接塞进 Playwright 的 Serializable 类型 */
async function apiCall(
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
path: string,
options: { data?: unknown; token?: string } = {},
): Promise<ApiResult> {
const context = requireContext();
const headers: Record<string, string> = {};
if (options.token !== undefined) {
headers.Authorization = `Bearer ${options.token}`;
}
let body: string | undefined;
if (options.data !== undefined) {
headers['Content-Type'] = 'application/json';
body = JSON.stringify(options.data);
}
const url = `${API_BASE}${path}`;
let response: APIResponse;
if (method === 'GET') {
response = await context.get(url, { headers });
} else if (method === 'POST') {
response = await context.post(url, { headers, data: body });
} else if (method === 'PATCH') {
response = await context.patch(url, { headers, data: body });
} else {
response = await context.delete(url, { headers });
}
return { status: response.status(), body: await readBody(response) };
}
async function readBody(response: APIResponse): Promise<unknown> {
const text = await response.text();
if (text.length === 0) {
return null;
}
try {
return JSON.parse(text) as unknown;
} catch {
return text;
}
}
function errorInfo(body: unknown): { code: number | null; message: string } {
if (!isRecord(body)) {
return { code: null, message: '' };
}
const message = typeof body.message === 'string' ? body.message : '';
return { code: optionalNumber(body, 'code'), message };
}
interface LoginResult {
status: number;
accessToken: string;
refreshToken: string;
mustChangePassword: boolean;
permissions: string[];
}
function parseLoginBody(body: unknown): LoginResult {
const record = requireRecord(body, '登录响应');
const me = requireRecord(record.me, '登录响应.me');
return {
status: 200,
accessToken: requireString(record, 'accessToken', '登录响应'),
refreshToken: requireString(record, 'refreshToken', '登录响应'),
mustChangePassword: me.mustChangePassword === true,
permissions: stringArray(me.permissions),
};
}
async function loginRaw(account: string, password: string): Promise<LoginResult> {
const result = await apiCall('POST', '/auth/login', { data: { account, password } });
if (result.status !== 200) {
return {
status: result.status,
accessToken: '',
refreshToken: '',
mustChangePassword: false,
permissions: [],
};
}
return parseLoginBody(result.body);
}
async function changePassword(
token: string,
currentPassword: string,
newPassword: string,
): Promise<void> {
const result = await apiCall('POST', '/auth/change-password', {
token,
data: { currentPassword, newPassword },
});
expect(result.status, `修改密码(HTTP ${result.status})`).toBe(204);
}
interface MeResult {
status: number;
code: number;
permissions: string[];
roles: string[];
}
async function getMe(token: string): Promise<MeResult> {
const result = await apiCall('GET', '/me', { token });
if (result.status !== 200) {
return {
status: result.status,
code: errorInfo(result.body).code ?? 0,
permissions: [],
roles: [],
};
}
const record = requireRecord(result.body, '/me');
const roles = Array.isArray(record.roles)
? record.roles
.map((item) => (isRecord(item) && typeof item.code === 'string' ? item.code : null))
.filter((code): code is string => code !== null)
: [];
return {
status: 200,
code: 0,
permissions: stringArray(record.permissions),
roles,
};
}
/** 管理员 actor:优先用已改密口令,其次用种子初始口令完成一次性首登改密(与 A1 e2e 同一约定) */
async function ensureActorToken(): Promise<string> {
const attempts: { password: string; label: string }[] = [
{ password: ACTOR_PASSWORD, label: '已改密口令' },
{ password: SEED_PASSWORD, label: '种子初始口令' },
];
for (const attempt of attempts) {
const login = await loginRaw(ACTOR_ACCOUNT, attempt.password);
if (login.status === 429) {
throw new Error(`actor ${ACTOR_ACCOUNT} 登录被限流(42901),请等待锁定窗口或重建 seed`);
}
if (login.status !== 200) {
continue;
}
if (!login.mustChangePassword) {
return login.accessToken;
}
await changePassword(login.accessToken, attempt.password, ACTOR_PASSWORD);
const relogin = await loginRaw(ACTOR_ACCOUNT, ACTOR_PASSWORD);
expect(relogin.status, 'actor 改密后重新登录').toBe(200);
return relogin.accessToken;
}
throw new Error(
`无法用 ${ACTOR_ACCOUNT} 取得管理员会话:本地已改密时请设置 A2_E2E_ACTOR_ACCOUNT / A2_E2E_ACTOR_PASSWORD`,
);
}
/** 角色列表 + 内置角色 id(material_operator 用作清理时给用户解绑后的兜底角色) */
async function fetchRoleIds(token: string): Promise<void> {
const result = await apiCall('GET', '/roles?status=all', { token });
expect(result.status, 'GET /roles?status=all').toBe(200);
const items = requireRecord(result.body, '角色列表').items;
if (!Array.isArray(items)) {
throw new Error('角色列表 items 不是数组');
}
for (const item of items) {
if (!isRecord(item)) {
continue;
}
if (item.code === 'material_operator' && typeof item.id === 'string') {
materialOperatorRoleId = item.id;
}
}
if (materialOperatorRoleId === '') {
throw new Error('内置角色 material_operator 缺失');
}
}
async function fetchDepartmentId(token: string): Promise<void> {
const result = await apiCall('GET', '/departments/tree', { token });
expect(result.status, 'GET /departments/tree').toBe(200);
const items = requireRecord(result.body, '部门树').items;
if (!Array.isArray(items) || items.length === 0) {
throw new Error('部门树为空');
}
const root = items.at(0);
const children = isRecord(root) ? root.children : undefined;
if (Array.isArray(children) && children.length > 0) {
const leaf = children.at(0);
if (isRecord(leaf) && typeof leaf.id === 'string') {
departmentId = leaf.id;
return;
}
}
if (isRecord(root) && typeof root.id === 'string') {
departmentId = root.id;
return;
}
throw new Error('无法从部门树确定部门 ID');
}
function randomSuffix(): string {
return Math.random().toString(36).slice(2, 8);
}
function randomPhone(): string {
const tail = String(Math.floor(10_000_000 + Math.random() * 89_999_999));
return `137${tail}`;
}
async function createQaUser(): Promise<QaUser> {
const account = `${QA_ACCOUNT_PREFIX}${randomSuffix()}`;
const result = await apiCall('POST', '/users', {
token: requireActor(),
data: {
name: 'A2 角色 e2e 用户',
phone: randomPhone(),
account,
password: QA_INITIAL_PASSWORD,
departmentId,
roleIds: [materialOperatorRoleId],
},
});
expect(result.status, `创建 QA 用户 ${account}(HTTP ${result.status})`).toBe(201);
const id = requireString(requireRecord(result.body, '创建用户响应'), 'id', '创建用户响应');
return { id, account };
}
/** 首登强制改密一次性完成,后续用例可使用固定测试口令 */
async function preparePassword(user: QaUser): Promise<void> {
const first = await loginRaw(user.account, QA_INITIAL_PASSWORD);
expect(first.status, `首登 ${user.account}`).toBe(200);
await changePassword(first.accessToken, QA_INITIAL_PASSWORD, QA_PASSWORD);
}
async function updateUserRoles(userId: string, roleIds: string[]): Promise<void> {
const result = await apiCall('PATCH', `/users/${encodeURIComponent(userId)}`, {
token: requireActor(),
data: { roleIds },
});
expect(result.status, `更新用户角色(HTTP ${result.status})`).toBe(200);
}
async function disableUser(userId: string): Promise<void> {
await apiCall('POST', `/users/${encodeURIComponent(userId)}/disable`, {
token: requireActor(),
data: { reason: 'A2 角色 e2e 自动清理' },
});
}
async function deleteRole(roleId: string): Promise<void> {
await apiCall('DELETE', `/roles/${encodeURIComponent(roleId)}`, { token: requireActor() });
}
/** 清理上一次异常中断遗留的 e2e 用户与角色(前缀隔离,尽力而为) */
async function cleanupLeftovers(): Promise<void> {
const token = requireActor();
const users = await apiCall('GET', '/users?status=all&limit=100&keyword=qa2.roles.', { token });
if (users.status === 200) {
const items = requireRecord(users.body, '遗留用户').items;
if (Array.isArray(items)) {
for (const item of items) {
if (!isRecord(item) || typeof item.id !== 'string' || typeof item.account !== 'string') {
continue;
}
if (!item.account.startsWith(QA_ACCOUNT_PREFIX)) {
continue;
}
try {
await updateUserRoles(item.id, [materialOperatorRoleId]);
await disableUser(item.id);
} catch {
// 尽力而为:遗留数据清理失败不阻断用例,真实断言仍会暴露问题。
}
}
}
}
const roles = await apiCall(
'GET',
`/roles?status=all&limit=100&keyword=${encodeURIComponent(ROLE_PREFIX)}`,
{
token,
},
);
if (roles.status === 200) {
const items = requireRecord(roles.body, '遗留角色').items;
if (Array.isArray(items)) {
for (const item of items) {
if (!isRecord(item) || typeof item.id !== 'string' || typeof item.name !== 'string') {
continue;
}
if (!item.name.startsWith(ROLE_PREFIX)) {
continue;
}
try {
await deleteRole(item.id);
} catch {
// 同上。
}
}
}
}
}
// ---------------------------------------------------------------------------
// 浏览器辅助
// ---------------------------------------------------------------------------
async function screenshot(page: Page, name: string): Promise<void> {
await mkdir(SCREENSHOT_DIR, { recursive: true });
await page.screenshot({ path: resolve(SCREENSHOT_DIR, `${name}.png`), fullPage: false });
}
async function fillLogin(page: Page, account: string, password: string): Promise<void> {
await page.goto('/login');
await page.getByPlaceholder('请输入账号或手机号').fill(account);
await page.getByPlaceholder('请输入密码').fill(password);
}
async function submitLogin(page: Page): Promise<void> {
await page.getByTestId('login-submit').click();
}
async function loginAsAdmin(page: Page): Promise<void> {
await fillLogin(page, ACTOR_ACCOUNT, ACTOR_PASSWORD);
await submitLogin(page);
await expect(page).toHaveURL(/\/system\/users/, { timeout: 15_000 });
}
/** 权限点行(编辑器 / 新建页共用:div[role=checkbox][data-code]) */
function permissionRow(page: Page, code: string): Locator {
return page.locator(`[data-code="${code}"]`);
}
// ---------------------------------------------------------------------------
// 测试数据编排
// ---------------------------------------------------------------------------
test.beforeAll(async () => {
if (!ENABLED) {
return;
}
await mkdir(SCREENSHOT_DIR, { recursive: true });
apiContext = await request.newContext();
try {
const web = await apiContext.get(`${WEB_BASE}/login`);
expect(web.status(), `web 未就绪:请在 ${WEB_BASE} 启动前端`).toBe(200);
actorToken = await ensureActorToken();
const me = await getMe(actorToken);
expect(me.status, 'actor GET /me').toBe(200);
await fetchRoleIds(actorToken);
await fetchDepartmentId(actorToken);
await cleanupLeftovers();
qaUser = await createQaUser();
await preparePassword(qaUser);
} catch (error) {
bootstrapError = error instanceof Error ? error.message : String(error);
}
});
test.afterAll(async () => {
if (!ENABLED) {
return;
}
try {
if (qaUser !== null) {
// 先解绑(角色删除前必须 0 绑定用户),再停用用户
try {
await updateUserRoles(qaUser.id, [materialOperatorRoleId]);
} catch {
// 用户可能已不存在 / 已被停用,继续尝试停用与删除角色。
}
try {
await disableUser(qaUser.id);
} catch {
// 已停用时重复停用会返回 40903。
}
}
if (createdRoleId !== '') {
await deleteRole(createdRoleId);
}
} catch {
// 清理失败不覆盖真实断言结果;残留数据由下一次运行 beforeAll 按前缀清理。
}
await apiContext?.dispose();
apiContext = null;
});
test.beforeEach(() => {
test.skip(bootstrapError !== null, bootstrapError ?? '测试环境未就绪');
});
// ---------------------------------------------------------------------------
// ①–⑥ 角色管理主流程(单条旅程,保证 UI 状态与 API 断言同源)
// ---------------------------------------------------------------------------
test('角色管理:新建 → 编辑器 → SoD 拦截 → 特殊权限二次确认 → 保存即时生效 → 停用与列表状态', async ({
page,
browser,
}) => {
test.setTimeout(180_000);
createdRoleName = `${ROLE_PREFIX}内容运营${randomSuffix()}`;
// ① 管理员登录 → /system/roles
await loginAsAdmin(page);
await page.goto('/system/roles');
await expect(page.getByRole('heading', { name: '角色权限' })).toBeVisible();
await expect(page.getByTestId('app-shell')).toBeVisible();
await screenshot(page, '01-roles-list');
// ① 新建角色:填名称 / 说明、勾 2 个权限、选「本部门」
await page.getByRole('button', { name: '新建角色' }).first().click();
await expect(page).toHaveURL(/\/system\/roles\/new/);
await expect(page.getByRole('heading', { name: '新建角色' })).toBeVisible();
await page.getByPlaceholder('例如:审核员').fill(createdRoleName);
await page
.getByPlaceholder('一句话说明该角色的职责')
.fill('A2 e2e 自动创建,用于验证角色管理与权限即时生效');
await expect(permissionRow(page, 'script:read')).toBeVisible({ timeout: 15_000 });
await permissionRow(page, 'script:read').click();
await permissionRow(page, 'material:read').click();
// 数据范围:本部门(六类单选卡片,role=radio)
await page.getByRole('radio', { name: /仅用户所属部门/ }).click();
await expect(page.getByText('当前范围:本部门')).toBeVisible();
await screenshot(page, '02-role-new-filled');
await page.getByRole('button', { name: '创建角色' }).click();
await expect(page).toHaveURL(/\/system\/roles\/(?!new)[0-9A-Za-z]{26}/, { timeout: 15_000 });
const matched = /\/system\/roles\/(?<id>[0-9A-Za-z]{26})/.exec(page.url());
createdRoleId = matched?.groups?.id ?? '';
expect(createdRoleId, '创建成功后应进入角色编辑器并拿到角色 id').not.toBe('');
await expect(page.locator('.lx-role-editor')).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole('button', { name: '停用', exact: true })).toBeVisible();
await screenshot(page, '03-role-editor-created');
// 绑定 QA 用户并登录(为「权限即时生效」准备同一 access token 的观察者)
const user = requireQaUser();
await updateUserRoles(user.id, [createdRoleId]);
const userLogin = await loginRaw(user.account, QA_PASSWORD);
expect(userLogin.status, 'QA 用户登录').toBe(200);
const tokenBefore = userLogin.accessToken;
const meBefore = await getMe(tokenBefore);
expect(meBefore.permissions).toContain('script:read');
expect(meBefore.permissions).toContain('material:read');
expect(meBefore.permissions).not.toContain('user:view');
// ② / ③ 编辑器权限点:先制造 SoD 冲突(script:write × script:review)
await page.getByRole('tab', { name: /^权限点/ }).click();
await expect(permissionRow(page, 'script:write')).toBeVisible({ timeout: 15_000 });
await permissionRow(page, 'script:write').click();
await permissionRow(page, 'script:review').click();
await expect(page.getByText(/命中职责互斥:脚本生成 × 脚本审核/)).toBeVisible();
await expect(page.getByText('存在职责互斥,保存不可用')).toBeVisible();
const footerSave = page
.locator('.lx-role-editor__foot-actions')
.getByRole('button', { name: '保存', exact: true });
await expect(footerSave).toBeDisabled();
await screenshot(page, '04-sod-conflict-blocked');
// 解除冲突,改为给角色追加 user:view(普通权限)
await permissionRow(page, 'script:review').click();
await permissionRow(page, 'script:write').click();
await expect(page.getByText(/命中职责互斥/)).toHaveCount(0);
// 回到基线后编辑器「未做修改」、保存禁用;再加 user:view 制造新的可保存变更
await expect(footerSave).toBeDisabled();
const treeSearch = page.getByPlaceholder('搜索权限点名称或编码');
await treeSearch.fill('user:view');
await expect(permissionRow(page, 'user:view')).toBeVisible();
await permissionRow(page, 'user:view').click();
await treeSearch.fill('');
await expect(footerSave).toBeEnabled();
// ② 特殊权限:开关触发第一次二次确认(不落库,仅更新草稿)
// 注:Semi Modal 复用 `semi-modal-title` id 且关闭动画期间两个弹窗会同时挂载,
// getByRole('dialog', { name }) 的 aria-labelledby 会指向先挂载的旧标题,
// 因此这里用「按钮 / 标题文案」定位,避免动画窗口内的可访问名串台。
await page.getByRole('tab', { name: /^特殊权限/ }).click();
await page.getByRole('switch', { name: '发起导出' }).click();
const confirmGrant = page.getByRole('button', { name: '确认授予', exact: true });
await expect(confirmGrant).toHaveCount(1);
await expect(page.getByText('授予特殊权限')).toBeVisible();
await screenshot(page, '05-special-permission-confirm');
await confirmGrant.click();
// 等第一次确认弹窗卸载,避免遮罩残留拦截下一次点击
await expect(confirmGrant).toHaveCount(0);
// 保存:命中特殊权限变更 → 第二次二次确认 → 保存前影响面确认
await footerSave.click();
await expect(confirmGrant).toHaveCount(1);
await confirmGrant.click();
const saveConfirm = page.getByRole('button', { name: '保存并生效', exact: true });
await expect(saveConfirm).toBeVisible();
await expect(page.getByText('保存角色变更')).toBeVisible();
await expect(page.getByText('新增', { exact: true })).toBeVisible();
await screenshot(page, '06-save-confirm');
await saveConfirm.click();
await expect(page.getByText(/角色已保存/).first()).toBeVisible({ timeout: 15_000 });
await expect(page.locator('.lx-role-editor__foot').getByText('未做修改')).toBeVisible();
await screenshot(page, '07-saved');
// ④ API 断言即时生效:同一 access token 的 /me 在下一次请求即包含新权限与特殊权限
const meAfter = await getMe(tokenBefore);
expect(meAfter.status).toBe(200);
expect(meAfter.permissions).toContain('user:view');
expect(meAfter.permissions).toContain('delivery:export');
expect(meAfter.permissions).not.toContain('script:write');
expect(meAfter.permissions).not.toContain('script:review');
const usersAfter = await apiCall('GET', '/users', { token: tokenBefore });
expect(usersAfter.status, '授予 user:view 后绑定用户下一次请求即可访问 /users').toBe(200);
const roleDetail = await apiCall('GET', `/roles/${createdRoleId}`, {
token: requireActor(),
});
expect(roleDetail.status).toBe(200);
const roleRecord = requireRecord(roleDetail.body, '角色详情');
expect(stringArray(roleRecord.permissions).sort()).toEqual([
'material:read',
'script:read',
'user:view',
]);
expect(stringArray(roleRecord.specialPermissions)).toEqual(['delivery:export']);
expect(roleRecord.userCount).toBe(1);
// ⑤ 权限三态 UI 侧:绑定用户有 user:view 但没有 role:view → /system/roles 显示 403 页
const scopedContext = await browser.newContext({
baseURL: WEB_BASE,
viewport: { width: 1440, height: 900 },
});
try {
const scopedPage = await scopedContext.newPage();
await fillLogin(scopedPage, user.account, QA_PASSWORD);
await submitLogin(scopedPage);
await expect(scopedPage).toHaveURL(/\/system\/users/, { timeout: 15_000 });
await scopedPage.goto('/system/roles');
await expect(scopedPage).toHaveURL(/\/403/, { timeout: 15_000 });
await expect(scopedPage.getByTestId('error-403')).toBeVisible();
await screenshot(scopedPage, '08-roles-forbidden');
} finally {
await scopedContext.close();
}
// ⑥ 停用确认:勾选知晓 + 填原因 → 停用
await page.getByRole('button', { name: '停用', exact: true }).click();
const disableDialog = page.getByRole('dialog', { name: '停用角色' });
await expect(disableDialog).toBeVisible();
await screenshot(page, '09-disable-confirm');
await disableDialog.getByPlaceholder('例如:岗位调整、角色合并').fill('A2 e2e 验证停用即时生效');
await disableDialog.getByText('我已知晓停用后这些用户下一次请求即失去该角色权限').click();
await disableDialog.getByRole('button', { name: '停用角色', exact: true }).click();
await expect(page.getByText(/已停用,1 名用户下一次请求生效/).first()).toBeVisible({
timeout: 15_000,
});
await expect(page.locator('.lx-status-tag.is-disabled').first()).toBeVisible();
await screenshot(page, '10-role-disabled-editor');
// ⑥ 停用后 API:绑定用户下一次请求失去权限(40301),/me 权限清空
const meDisabled = await getMe(tokenBefore);
expect(meDisabled.status).toBe(200);
expect(meDisabled.permissions).toEqual([]);
const usersDenied = await apiCall('GET', '/users', { token: tokenBefore });
expect(usersDenied.status, '停用角色后绑定用户访问 /users').toBe(403);
expect(errorInfo(usersDenied.body).code).toBe(40301);
// ⑥ 列表状态变化:停用筛选下可见该角色,且行内标记「停用」
await page.goto('/system/roles');
await expect(page.getByRole('heading', { name: '角色权限' })).toBeVisible();
const disabledSeg = page
.getByRole('group', { name: '角色状态' })
.getByRole('button', { name: /^停用/ });
await disabledSeg.click();
const roleRow = page.locator('.lx-role-row', { hasText: createdRoleName });
await expect(roleRow).toBeVisible();
await expect(roleRow).toContainText('停用');
await screenshot(page, '11-roles-list-disabled');
});