ci / 质量门禁 (push) Canceled after 0s
背景:角色权限只描述模块的一部分能力,侧栏同级均为模块级命名(用户与组织 / 客户 / 行业树);角色页实际覆盖角色 CRUD、权限点、数据范围、特殊权限与 SoD。 改动: - 侧栏、面包屑、页面标题统一为「角色管理」,页面内权限点 / 数据范围 / 特殊权限分区名不变; - 用户详情与超管授予弹窗中的模块引用同步改为「角色管理」; - 开发计划页面清单与 A2 实施报告同步; - 更新导航与面包屑单测、A2 e2e 标题断言。 验证:format:check / lint / typecheck / test 全绿;5173 实测侧栏、面包屑、页标题均为「角色管理」。
712 lines
27 KiB
TypeScript
712 lines
27 KiB
TypeScript
/// <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');
|
||
});
|