ci / 质量门禁 (push) Canceled after 0s
背景: - 落实 11-前端路由与目录规范:URL、导航、面包屑、页面目录一一对应; - 「平台」更名「管理中心」,模块 key 由 platform 改为 system; - 原导航模块 key 与 URL 首段不一致(script vs /scripts、asset vs /assets), 占位页按 URL 解析不到模块,出现「页面不存在」。 改动: - nav-config:模块 key 改为 URL 首段(scripts / assets / system),平台更名 管理中心,管理中心全部路径收敛到 /system/*;用户页为 /system/users; - routes / router:ROUTES 按模块分组;新增 /system/users、/system/users/new、 /system/users/:id;旧 /users* 与 /platform* 加前端重定向;/system 落到用户列表; - 页面目录迁移:pages/users → pages/system/users,pages/home → pages/dashboard, 懒加载与所有引用同步; - 面包屑、AppLayout、登录回跳、通用占位页(按路径 + 权限解析,无权限进 403) 与单测 / e2e 路径同步; - 顺带修复 /scripts/library、/assets/library 等占位页因模块 key 不一致导致的 404。 影响面: - 仅前端路由与目录;后端 /api/v1/users 等接口与权限点不变; - 旧书签 /users*、/platform* 由前端重定向兼容一个版本。 回滚方式: - 回滚本提交即恢复 /users 与 /platform/* 路由、原页面目录与导航命名。 验证证据: - pnpm --filter @lingxi/web lint / typecheck / test 通过(15 files / 55 tests); - pnpm build 通过; - 浏览器:登录后落在 /system/users,导航显示「管理中心」;/users → /system/users, /platform/roles → /system/roles;有权限的 /scripts/library、/assets/library 显示占位页,无权限显示 403; - A1 e2e 6/6 通过(真实 API + 浏览器)。
722 lines
26 KiB
TypeScript
722 lines
26 KiB
TypeScript
/// <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');
|
||
});
|