Files
lingxi/e2e/a1-identity.spec.ts
Stev_Wang f849e443d7 refactor(web): 侧栏「用户与组织」更名为「用户管理」
背景:
- 管理中心侧栏菜单名为「用户与组织」,与「角色管理」「部门管理」的命名方式不一致;
- 页面标题、面包屑仍沿用旧名,三处展示不统一;
- A1 文档与代码注释中该功能域本来即称「用户管理」。

改动:
- nav-config:菜单 label 改为「用户管理」,description 改为「成员账号、部门归属与登录状态」;
- breadcrumbs:用户新建 / 详情子路由的面包屑 2 处同步;
- UsersPage:页面 h1 同步;
- nav-config.spec / breadcrumbs.spec / e2e a1-identity 的标签与 heading 断言同步;
- 11-前端路由与目录规范、AppLayout 注释同步;PRD P-01 功能名保持「用户与组织管理」不变。

影响面:
- 仅前端文案与对应断言;路由、权限点、接口、数据均不变。

回滚方式:
- 回滚本提交即恢复原「用户与组织」文案与断言。

验证证据:
- pnpm format:check / lint / typecheck 通过;
- pnpm test:web 120/120 通过;
- Chrome 1440×900 实机:侧栏、面包屑、页面标题均显示「用户管理」。
2026-11-03 15:54:13 +08:00

722 lines
26 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" />
/**
* A1-QA-01 · 账号生命周期端到端用例(真实 API + 真实 PG/Redis + 真实浏览器)
*
* 覆盖:
* ① 登录 → 首登强制改密 → 重新登录 → /users 正常
* ② 刷新页面仍在线:access 失效后保留 refresh,触发单飞刷新(/auth/refresh 旋转)
* ③ 登出回登录页、本地会话清空、旧 refresh 失效
* ④ 停用后旧会话被拒(40301 / 刷新 401),再登录被拒(账号已停用)
* ⑤ 管理员调整角色后,下一次请求权限立即生效(/me + /users 403 页)
* ⑥ 无 user:view 的账号访问 /users 显示 403 页
*
* 运行前置(本地):
* - API:http://127.0.0.1:3000/api/v1(真实 PG + Redis)
* - Web:http://127.0.0.1:5173(`pnpm --filter @lingxi/web dev`;非 demo 会话)
* - 门控:仅在 A1_E2E=1 时执行,否则整文件 skip,不影响 CI 与 e2e/smoke.spec.ts
*
* 运行命令:
* A1_E2E=1 pnpm exec playwright test e2e/a1-identity.spec.ts --reporter=list
*
* 测试数据:用管理员 token 通过 API 创建 4 个 `qa.*` 用户,用例结束统一停用(自建自清);
* 不修改 admin 密码。管理员 actor 默认使用种子超管 `jiang.lan`:
* 首次运行会把它的初始密码 lingxi2026 改为 QaE2eAdmin2026(首登强改属一次性),
* 后续运行直接用改后口令;可用 A1_E2E_ACTOR_ACCOUNT / A1_E2E_ACTOR_PASSWORD 覆盖。
* 本地 admin 密码已被改过时不要用 admin,避免锁定。
*/
import { mkdir } from 'node:fs/promises';
import { resolve } from 'node:path';
import {
type APIRequestContext,
type APIResponse,
expect,
type Page,
request,
test,
} from '@playwright/test';
const API_BASE = process.env.A1_E2E_API_BASE_URL ?? 'http://127.0.0.1:3000/api/v1';
const WEB_BASE = process.env.A1_E2E_BASE_URL ?? 'http://127.0.0.1:5173';
const ENABLED = process.env.A1_E2E === '1';
const ACTOR_ACCOUNT = process.env.A1_E2E_ACTOR_ACCOUNT ?? 'jiang.lan';
const ACTOR_PASSWORD = process.env.A1_E2E_ACTOR_PASSWORD ?? 'QaE2eAdmin2026';
const SEED_PASSWORD = process.env.A1_E2E_SEED_PASSWORD ?? 'lingxi2026';
const USER_INITIAL_PASSWORD = 'QaInit2026x';
const USER_PASSWORD = 'QaUser2026x';
const SESSION_KEY = 'lingxi.auth.session';
const SCREENSHOT_DIR = resolve('design/a1-ui-v1/shots/impl/e2e');
test.skip(!ENABLED, 'A1_E2E=1 才运行账号生命周期 e2e(依赖 API :3000 + web :5173 + PG/Redis)');
test.use({ baseURL: WEB_BASE, viewport: { width: 1440, height: 900 } });
// ---------------------------------------------------------------------------
// 类型与断言辅助(仅依赖 @playwright/test,避免 e2e 引入额外运行时依赖)
// ---------------------------------------------------------------------------
interface LoginResult {
status: number;
accessToken: string;
refreshToken: string;
mustChangePassword: boolean;
permissions: string[];
}
interface MeResult {
status: number;
code: number;
message: string;
permissions: string[];
mustChangePassword: boolean;
}
interface QaUser {
id: string;
account: string;
name: string;
}
interface SessionSnapshot {
accessToken: string;
refreshToken: string;
}
interface ApiResult {
status: number;
body: unknown;
}
let apiContext: APIRequestContext | null = null;
let actorToken: string | null = null;
let bootstrapError: string | null = null;
let qaFirst: QaUser | null = null;
let qaMain: QaUser | null = null;
let qaNoPerm: QaUser | null = null;
let qaDisable: QaUser | null = null;
let platformAdminRoleId = '';
let materialOperatorRoleId = '';
let groupDepartmentId = '';
const createdUserIds: string[] = [];
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 requireUser(user: QaUser | null, label: string): QaUser {
if (user === null) {
throw new Error(`${label} 测试用户未创建`);
}
return user;
}
/** 发送 API 请求:body JSON 字符串化,避免把 unknown 直接塞进 Playwright 的 Serializable 类型 */
async function apiCall(
method: 'GET' | 'POST' | 'PATCH',
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 {
response = await context.patch(url, { headers, data: body });
}
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 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),
};
}
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 };
}
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);
}
async function getMe(token: string): Promise<MeResult> {
const result = await apiCall('GET', '/me', { token });
const info = errorInfo(result.body);
const permissions =
result.status === 200 ? stringArray(requireRecord(result.body, '/me').permissions) : [];
const mustChangePassword =
result.status === 200 ? requireRecord(result.body, '/me').mustChangePassword === true : false;
return {
status: result.status,
code: info.code ?? 0,
message: info.message,
permissions,
mustChangePassword,
};
}
/** 管理员 actor:优先用已改密口令,其次用种子初始口令完成一次性首登改密 */
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} 取得管理员会话:admin 本地已改密时请设置 A1_E2E_ACTOR_ACCOUNT / A1_E2E_ACTOR_PASSWORD 指向可用超管`,
);
}
async function fetchRoleIds(token: string): Promise<void> {
const result = await apiCall('GET', '/roles', { token });
expect(result.status, 'GET /roles').toBe(200);
const record = requireRecord(result.body, '角色列表');
const items = record.items;
if (!Array.isArray(items)) {
throw new Error('角色列表 items 不是数组');
}
const byCode = new Map<string, string>();
for (const item of items) {
if (!isRecord(item)) {
continue;
}
const { code, id } = item;
if (typeof code === 'string' && typeof id === 'string') {
byCode.set(code, id);
}
}
const platformAdmin = byCode.get('platform_admin');
const materialOperator = byCode.get('material_operator');
if (platformAdmin === undefined || materialOperator === undefined) {
throw new Error('内置角色 platform_admin / material_operator 缺失');
}
platformAdminRoleId = platformAdmin;
materialOperatorRoleId = materialOperator;
}
async function fetchGroupDepartmentId(token: string): Promise<void> {
const result = await apiCall('GET', '/departments/tree', { token });
expect(result.status, 'GET /departments/tree').toBe(200);
const record = requireRecord(result.body, '部门树');
const items = record.items;
if (!Array.isArray(items) || items.length === 0) {
throw new Error('部门树为空');
}
const root = items.at(0);
const id = isRecord(root) ? root.id : undefined;
const children = isRecord(root) ? root.children : undefined;
if (Array.isArray(children) && children.length > 0) {
const leaf = children.at(0);
const leafId = isRecord(leaf) ? leaf.id : undefined;
if (typeof leafId === 'string') {
groupDepartmentId = leafId;
return;
}
}
if (typeof id !== 'string') {
throw new Error('无法从部门树确定部门 ID');
}
groupDepartmentId = 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 `139${tail}`;
}
async function createQaUser(name: string, accountPrefix: string, roleId: string): Promise<QaUser> {
const account = `${accountPrefix}.${randomSuffix()}`;
const result = await apiCall('POST', '/users', {
token: requireActor(),
data: {
name,
phone: randomPhone(),
account,
password: USER_INITIAL_PASSWORD,
departmentId: groupDepartmentId,
roleIds: [roleId],
},
});
expect(result.status, `创建用户 ${account}(HTTP ${result.status})`).toBe(201);
const record = requireRecord(result.body, '创建用户响应');
const id = requireString(record, 'id', '创建用户响应');
createdUserIds.push(id);
return { id, account, name };
}
/** 把测试用户的初始密码改成固定测试口令(首登强改属于一次性动作,只为后续用例准备好可用会话) */
async function preparePassword(user: QaUser): Promise<void> {
const first = await loginRaw(user.account, USER_INITIAL_PASSWORD);
expect(first.status, `首登 ${user.account}`).toBe(200);
if (!first.mustChangePassword) {
return;
}
await changePassword(first.accessToken, USER_INITIAL_PASSWORD, USER_PASSWORD);
}
async function disableUser(userId: string): Promise<void> {
await apiCall('POST', `/users/${encodeURIComponent(userId)}/disable`, {
token: requireActor(),
data: { reason: 'A1 e2e 自动清理' },
});
}
async function updateRoles(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 screenshot(page: Page, name: string): Promise<void> {
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 expectUsersPage(page: Page): Promise<void> {
await expect(page.getByTestId('app-shell')).toBeVisible();
await expect(page.getByRole('heading', { name: '用户管理' })).toBeVisible();
}
async function readSession(page: Page): Promise<SessionSnapshot> {
const raw = await page.evaluate((key: string) => window.localStorage.getItem(key), SESSION_KEY);
if (raw === null) {
throw new Error('localStorage 中没有会话(可能未登录或被登出)');
}
const record = requireRecord(JSON.parse(raw) as unknown, '本地会话');
return {
accessToken: requireString(record, 'accessToken', '本地会话'),
refreshToken: requireString(record, 'refreshToken', '本地会话'),
};
}
/** 直接写入本地会话(模拟“旧标签页带着过期 access 回到 /users”) */
async function writeSession(page: Page, session: SessionSnapshot): Promise<void> {
await page.evaluate(
(input: { key: string; snapshot: SessionSnapshot }) => {
window.localStorage.setItem(
input.key,
JSON.stringify({
accessToken: input.snapshot.accessToken,
refreshToken: input.snapshot.refreshToken,
user: null,
permissions: [],
mustChangePassword: false,
}),
);
},
{ key: SESSION_KEY, snapshot: session },
);
}
// ---------------------------------------------------------------------------
// 测试数据编排
// ---------------------------------------------------------------------------
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 fetchGroupDepartmentId(actorToken);
qaFirst = await createQaUser('QA 首登用户', 'qa.first', platformAdminRoleId);
qaMain = await createQaUser('QA 主流程用户', 'qa.main', platformAdminRoleId);
qaNoPerm = await createQaUser('QA 越权用户', 'qa.noperm', materialOperatorRoleId);
qaDisable = await createQaUser('QA 停用用户', 'qa.disable', materialOperatorRoleId);
await preparePassword(qaMain);
await preparePassword(qaNoPerm);
await preparePassword(qaDisable);
} catch (error) {
bootstrapError = error instanceof Error ? error.message : String(error);
}
});
test.afterAll(async () => {
if (!ENABLED) {
return;
}
for (const userId of createdUserIds) {
try {
await disableUser(userId);
} catch {
// 清理尽力而为:用例可能已把用户停用,重复停用会返回 40903。
}
}
await apiContext?.dispose();
apiContext = null;
});
test.beforeEach(() => {
test.skip(bootstrapError !== null, bootstrapError ?? '测试环境未就绪');
});
// ---------------------------------------------------------------------------
// ① 登录 → 首登强改 → 重新登录 → /users
// ---------------------------------------------------------------------------
test('① 登录 → 首登强制改密 → 重新登录 → /users 正常', async ({ page }) => {
const user = requireUser(qaFirst, 'qaFirst');
await fillLogin(page, user.account, USER_INITIAL_PASSWORD);
await submitLogin(page);
await expect(page.getByText('首次登录,请修改初始密码')).toBeVisible();
await screenshot(page, '01-first-login-modal');
await page.getByPlaceholder('请输入初始密码').fill(USER_INITIAL_PASSWORD);
await page.getByPlaceholder('请输入新密码').fill(USER_PASSWORD);
await page.getByTestId('change-password-submit').click();
await expect(page.getByText('初始密码已更新,请使用新密码重新登录').first()).toBeVisible();
await expect(page.getByPlaceholder('请输入新密码')).toHaveCount(0);
await screenshot(page, '01-first-login-changed');
// 改密后必须用新密码重新登录
await page.getByPlaceholder('请输入账号或手机号').fill(user.account);
await page.getByPlaceholder('请输入密码').fill(USER_PASSWORD);
await submitLogin(page);
await expect(page).toHaveURL(/\/system\/users/);
await expectUsersPage(page);
await screenshot(page, '01-users-after-relogin');
});
// ---------------------------------------------------------------------------
// ② 刷新页面仍在线:access 失效 + refresh 单飞刷新
// ---------------------------------------------------------------------------
test('② 刷新页面仍在线:access 失效后保留 refresh 触发单飞刷新', async ({ page }) => {
const user = requireUser(qaMain, 'qaMain');
await fillLogin(page, user.account, USER_PASSWORD);
await submitLogin(page);
await expect(page).toHaveURL(/\/system\/users/);
await expectUsersPage(page);
const before = await readSession(page);
// 模拟 access 过期:仅破坏 accessToken,保留 refreshToken
await page.evaluate((key: string) => {
const raw = window.localStorage.getItem(key);
if (raw !== null) {
const parsed: unknown = JSON.parse(raw);
if (typeof parsed === 'object' && parsed !== null) {
const session = parsed as Record<string, unknown>;
session.accessToken = 'expired-access-token-for-e2e';
window.localStorage.setItem(key, JSON.stringify(session));
}
}
}, SESSION_KEY);
const [refreshResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/auth/refresh') && response.request().method() === 'POST',
),
page.reload(),
]);
expect(refreshResponse.status(), '/auth/refresh 响应状态').toBe(200);
await expect(page).toHaveURL(/\/system\/users/);
await expectUsersPage(page);
const after = await readSession(page);
// 注:access token 的 JWT 载荷只含 sub/typ/iat,若刷新与登录落在同一秒,
// 新 access 字符串可能与旧值相同;此处断言“已被刷新结果替换”而非字符串必变。
expect(after.accessToken, 'accessToken 不应仍是被破坏的值').not.toBe(
'expired-access-token-for-e2e',
);
expect(after.accessToken.length, 'accessToken 不应为空').toBeGreaterThan(20);
expect(after.refreshToken, 'refreshToken 应随旋转更新(jti 随机)').not.toBe(before.refreshToken);
await screenshot(page, '02-refresh-online');
});
// ---------------------------------------------------------------------------
// ③ 登出后回登录页,旧 refresh 失效
// ---------------------------------------------------------------------------
test('③ 登出后回登录页,本地会话清空且旧 refresh 失效', async ({ page }) => {
const user = requireUser(qaMain, 'qaMain');
await fillLogin(page, user.account, USER_PASSWORD);
await submitLogin(page);
await expectUsersPage(page);
const session = await readSession(page);
await page.locator('.lx-user__trigger').click();
await page.getByRole('menuitem', { name: '退出登录' }).click();
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('heading', { name: '登录灵犀' })).toBeVisible();
await expect(page.getByTestId('app-shell')).toHaveCount(0);
const stored = await page.evaluate(
(key: string) => window.localStorage.getItem(key),
SESSION_KEY,
);
expect(stored, '登出后 localStorage 会话应被清空').toBeNull();
await screenshot(page, '03-after-logout');
// 旧 refreshToken 在服务端应已被吊销
const refresh = await apiCall('POST', '/auth/refresh', {
data: { refreshToken: session.refreshToken },
});
expect(refresh.status, '登出后 refresh 应失效').toBe(401);
expect(errorInfo(refresh.body).code, 'refresh 失效错误码').toBe(40101);
});
// ---------------------------------------------------------------------------
// ④ 停用后旧会话失效、再登录被拒
// ---------------------------------------------------------------------------
test('④ 停用后旧会话被拒(40301),再登录提示「账号已停用」', async ({ page }) => {
const user = requireUser(qaDisable, 'qaDisable');
const session = await loginRaw(user.account, USER_PASSWORD);
expect(session.status, '停用前可正常登录').toBe(200);
expect(session.mustChangePassword, '测试用户应已完成首登改密').toBe(false);
await disableUser(user.id);
const me = await getMe(session.accessToken);
expect(me.status, '停用后旧 access 访问 /me').toBe(403);
expect(me.code, '停用错误码').toBe(40301);
expect(me.message).toContain('账号已停用');
const refresh = await apiCall('POST', '/auth/refresh', {
data: { refreshToken: session.refreshToken },
});
expect(refresh.status, '停用后 refresh 应失效').toBe(401);
// 浏览器侧:再次登录被拒,停留登录页并显示停用提示
await fillLogin(page, user.account, USER_PASSWORD);
await submitLogin(page);
await expect(page).toHaveURL(/\/login/);
await expect(page.locator('[role="alert"]', { hasText: '账号已停用' })).toBeVisible();
await screenshot(page, '04-disabled-login-rejected');
// 旧标签页带着停用前会话回到 /users:应被拒(当前实现显示错误态而非回登录页)
await page.goto('/login');
await writeSession(page, session);
await page.goto('/system/users');
await expect(page).toHaveURL(/\/login/);
await expect(page.locator('.lx-login__notice')).toContainText('账号已被停用');
await expect(page.getByRole('heading', { name: '用户管理' })).toHaveCount(0);
await expect
.poll(
async () =>
(await page.evaluate((key) => window.localStorage.getItem(key), SESSION_KEY)) ?? '',
)
.toBe('');
await screenshot(page, '04-disabled-old-session');
});
// ---------------------------------------------------------------------------
// ⑤ 角色变更后下一次请求生效
// ---------------------------------------------------------------------------
test('⑤ 管理员调整角色后,下一次请求权限立即生效', async ({ page, browser }) => {
const user = requireUser(qaMain, 'qaMain');
const session = await loginRaw(user.account, USER_PASSWORD);
expect(session.status, '角色调整前登录').toBe(200);
const before = await getMe(session.accessToken);
expect(before.status).toBe(200);
expect(before.permissions, '平台管理员应包含 user:view').toContain('user:view');
try {
await updateRoles(user.id, [materialOperatorRoleId]);
// 同一个 access token,下一次请求即按新角色解析权限
const after = await getMe(session.accessToken);
expect(after.status).toBe(200);
expect(after.permissions, '改为素材运营后不应再有 user:view').not.toContain('user:view');
const users = await apiCall('GET', '/users', { token: session.accessToken });
expect(users.status, '无 user:view 访问 /users').toBe(403);
expect(errorInfo(users.body).code).toBe(40301);
// UI:重新登录后目标 /users 被权限守卫拦到 403 页
await fillLogin(page, user.account, USER_PASSWORD);
await submitLogin(page);
await expect(page).toHaveURL(/\/403/, { timeout: 15_000 });
await expect(page.getByTestId('error-403')).toBeVisible();
await expect(page.getByRole('heading', { name: '没有访问权限' })).toBeVisible();
await screenshot(page, '05-role-change-403');
} finally {
await updateRoles(user.id, [platformAdminRoleId]);
}
// 恢复角色后应可重新进入 /users:用全新 context 模拟干净浏览器
const restored = await browser.newContext({
baseURL: WEB_BASE,
viewport: { width: 1440, height: 900 },
});
try {
const restoredPage = await restored.newPage();
await fillLogin(restoredPage, user.account, USER_PASSWORD);
await submitLogin(restoredPage);
await expect(restoredPage).toHaveURL(/\/system\/users/);
await expectUsersPage(restoredPage);
await screenshot(restoredPage, '05-role-restored');
} finally {
await restored.close();
}
});
// ---------------------------------------------------------------------------
// ⑥ 无 user:view 的账号访问 /users → 403 页
// ---------------------------------------------------------------------------
test('⑥ 无 user:view 的账号访问 /users 显示 403 页', async ({ page }) => {
const user = requireUser(qaNoPerm, 'qaNoPerm');
await fillLogin(page, user.account, USER_PASSWORD);
await submitLogin(page);
await expect(page).toHaveURL(/\/403/, { timeout: 15_000 });
await expect(page.getByTestId('error-403')).toBeVisible();
await expect(page.getByRole('heading', { name: '没有访问权限' })).toBeVisible();
await expect(page.getByTestId('app-shell')).toHaveCount(0);
await screenshot(page, '06-forbidden');
});