feat(identity): 完成 A1 身份认证与用户管理全栈落地
ci / 质量门禁 (push) Canceled after 0s

背景:
- 开发计划 A1 批次要求登录/刷新/登出、用户生命周期、路由与按钮权限、
  权限三态用例;视觉已选定方案 B(主从分栏 + 分屏登录)。

改动:
- packages/shared:新增 identity 契约(User/Me/Login/Refresh/Department/Role)
  与 user:view 权限点;写接口 strict 校验。
- apps/api:新增 identity 模块与 Prisma expand 迁移(departments/users/roles/
  user_roles/role_permissions/refresh_tokens);Argon2id、JWT access+refresh
  旋转与 jti 吊销、登录失败锁定、首登强改、守卫链、/me、@Audit 上下文、幂等种子。
- apps/web:按方案 B 落地 /users、/users/new、/users/:id、分屏登录与 403;
  usePermission/<Can>、路由守卫、401 单飞刷新、停用会话兜底、Semi + token。
- e2e:新增 A1-QA-01 六类端到端用例(A1_E2E 门控)。
- openapi 与前端生成类型同步;.env.example 登记认证变量;实施报告入库。

影响面:
- 新增运行时依赖 @nestjs/jwt、argon2(计划 A1-BE-02 已批准,版本精确锁定)。
- 迁移为 expand,不改既有表;本地需 prisma migrate 后重新 seed。
- 平台导航「用户与组织」由 /platform/users 调整为 /users。

回滚方式:
- 代码整体回滚本提交;迁移为 expand,回滚代码不需删表;
- 本地如需彻底回退按实施报告 §6 处理。

验证证据:
- pnpm format:check / lint / typecheck 通过;
- pnpm test:shared 95 / api 60 / web 55;
- pnpm test:int:7 个(真实 PG/Redis);
- A1_E2E=1 playwright test e2e/a1-identity.spec.ts:6/6;
- 报告:docs/开发计划/灵犀_A1身份认证与用户管理_实施报告_V0.1.md
This commit is contained in:
Stev_Wang committed 2026-11-03 00:03:33 +08:00
1 parent f0d218bd99
commit 4d9eb980af
120 files changed
+13112 -314

No files matched your search

+721
View File
@@ -0,0 +1,721 @@
/// <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(/\/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(/\/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(/\/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('/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(/\/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');
});