/// /** * 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 { return typeof value === 'object' && value !== null && !Array.isArray(value); } function requireRecord(value: unknown, label: string): Record { if (!isRecord(value)) { throw new Error(`${label} 不是 JSON 对象`); } return value; } function requireString(source: Record, 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, 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 { const context = requireContext(); const headers: Record = {}; 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 { 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 { 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 { 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 { 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 { 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 { 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(); 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 { 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 { 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 { 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 { await apiCall('POST', `/users/${encodeURIComponent(userId)}/disable`, { token: requireActor(), data: { reason: 'A1 e2e 自动清理' }, }); } async function updateRoles(userId: string, roleIds: string[]): Promise { 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 { await page.screenshot({ path: resolve(SCREENSHOT_DIR, `${name}.png`), fullPage: false }); } async function fillLogin(page: Page, account: string, password: string): Promise { await page.goto('/login'); await page.getByPlaceholder('请输入账号或手机号').fill(account); await page.getByPlaceholder('请输入密码').fill(password); } async function submitLogin(page: Page): Promise { await page.getByTestId('login-submit').click(); } async function expectUsersPage(page: Page): Promise { await expect(page.getByTestId('app-shell')).toBeVisible(); await expect(page.getByRole('heading', { name: '用户管理' })).toBeVisible(); } async function readSession(page: Page): Promise { 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 { 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; 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'); });