Files
lingxi/e2e/a2-roles.spec.ts
Stev_Wang f5a8afe835
ci / 质量门禁 (push) Canceled after 0s
refactor(web): 侧栏与页面模块名由角色权限改为角色管理
背景:角色权限只描述模块的一部分能力,侧栏同级均为模块级命名(用户与组织 / 客户 / 行业树);角色页实际覆盖角色 CRUD、权限点、数据范围、特殊权限与 SoD。

改动:
- 侧栏、面包屑、页面标题统一为「角色管理」,页面内权限点 / 数据范围 / 特殊权限分区名不变;
- 用户详情与超管授予弹窗中的模块引用同步改为「角色管理」;
- 开发计划页面清单与 A2 实施报告同步;
- 更新导航与面包屑单测、A2 e2e 标题断言。

验证:format:check / lint / typecheck / test 全绿;5173 实测侧栏、面包屑、页标题均为「角色管理」。
2026-11-03 14:03:13 +08:00

712 lines
27 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// <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');
});