import type { APIRequestContext, APIResponse, Page, TestInfo } from '@playwright/test' import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures' import { adminAccessToken, confirmMessageBox, envelopeData, loginAsAdmin, tableRowByText, visibleDialog, } from './helpers' type JsonRecord = Record type Headers = Record interface PasswordActivity { module: string action: string result: 'PASS' | 'FAIL' | 'CLEANED' | 'CLEANUP_FAILED' httpStatus?: number resourceId?: string detail?: string } interface LoginSession { accessToken: string refreshToken: string mustChangePassword: boolean } interface StatusResponse { status: () => number } const suffix = runPrefix.replace(/[^A-Za-z0-9]/g, '').slice(-12).toLowerCase() || 'manual' const testUser = { username: `ape_pwd_${suffix}`.slice(0, 32), displayName: `${runPrefix}-密码安全学员`, creationPassword: `Start@Aa1${suffix.slice(-4)}`, baselinePassword: `Base@Bb2${suffix.slice(-4)}`, manualPassword: `Manual@Cc3${suffix.slice(-4)}`, afterManualPassword: `After@Dd4${suffix.slice(-4)}`, afterSystemPassword: `System@Ee5${suffix.slice(-4)}`, profilePassword: `Final@Ff6${suffix.slice(-4)}`, wrongCurrentPassword: `Wrong@Gg7${suffix.slice(-4)}`, mismatchPassword: `Match@Hh8${suffix.slice(-4)}`, mismatchConfirmation: `Other@Ii9${suffix.slice(-4)}`, } const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' ? value as JsonRecord : {} const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : [] const auth = (token: string): Headers => ({ Authorization: `Bearer ${token}` }) async function responseJson(response: APIResponse) { return response.json().catch(() => ({})) } function assertStatus(response: StatusResponse, expected: number | number[], label: string) { const allowed = Array.isArray(expected) ? expected : [expected] expect(allowed, `${label}:HTTP ${response.status()}`).toContain(response.status()) } function recordPass( activities: PasswordActivity[], module: string, action: string, response: StatusResponse, resourceId?: string, detail?: string, ) { activities.push({ module, action, result: 'PASS', httpStatus: response.status(), resourceId, detail }) } function pageRecords(value: unknown) { const data = envelopeData(value) const root = asRecord(data) const items = root.records ?? root.items return Array.isArray(items) ? items.map(asRecord) : asRecords(data) } async function findUser(request: APIRequestContext, headers: Headers) { const response = await request.get('/api/auth/v1/users', { headers, params: { keyword: testUser.username, page: 1, size: 100 }, }) if (response.status() !== 200) return null return pageRecords(await responseJson(response)) .find((item) => String(item.username ?? '') === testUser.username) ?? null } async function cleanupUser( request: APIRequestContext, headers: Headers, activities: PasswordActivity[], cleanupErrors: string[], ) { try { let user = await findUser(request, headers) if (!user) return let response = await request.delete(`/api/auth/v1/users/${encodeURIComponent(String(user.id))}`, { headers, data: { version: Number(user.dataVersion ?? user.version ?? 0) }, }) if (response.status() === 409) { user = await findUser(request, headers) if (!user) return response = await request.delete(`/api/auth/v1/users/${encodeURIComponent(String(user.id))}`, { headers, data: { version: Number(user.dataVersion ?? user.version ?? 0) }, }) } if (![200, 204, 404].includes(response.status())) { throw new Error(`HTTP ${response.status()}`) } activities.push({ module: '账号安全', action: '临时用户逆序清理', result: 'CLEANED', httpStatus: response.status(), resourceId: String(user.id), }) } catch (error) { const detail = `临时用户清理失败:${error instanceof Error ? error.message : String(error)}` cleanupErrors.push(detail) activities.push({ module: '账号安全', action: '临时用户逆序清理', result: 'CLEANUP_FAILED', detail }) } } async function createTemporaryStudent( request: APIRequestContext, headers: Headers, activities: PasswordActivity[], ) { const departmentsResponse = await request.get('/api/auth/v1/departments', { headers }) assertStatus(departmentsResponse, 200, '读取可用部门') const departments = asRecords(envelopeData(await responseJson(departmentsResponse))) const department = departments.find((item) => item.enabled !== false && item.status !== 'disabled') expect(department, '测试环境必须至少存在一个启用部门').toBeTruthy() const rolesResponse = await request.get('/api/auth/v1/roles/all', { headers }) assertStatus(rolesResponse, 200, '读取可用角色') const rolesValue = envelopeData(await responseJson(rolesResponse)) const roles = Array.isArray(rolesValue) ? asRecords(rolesValue) : pageRecords(rolesValue) const studentRole = roles.find((item) => String(item.code ?? item.roleCode) === 'student' && item.enabled !== false) expect(studentRole, '测试环境必须存在启用的学员角色').toBeTruthy() const response = await request.post('/api/auth/v1/users', { headers, data: { username: testUser.username, displayName: testUser.displayName, departmentId: Number(department!.id), roleIds: [Number(studentRole!.id)], defaultRoleId: Number(studentRole!.id), enabled: true, password: testUser.creationPassword, remark: `${runPrefix} 账号安全真实提交测试,运行结束自动删除`, version: 0, }, }) assertStatus(response, 200, '创建临时学员账号') const created = asRecord(envelopeData(await responseJson(response))) const userId = String(created.id ?? '') expect(userId).not.toBe('') recordPass(activities, '账号安全', '管理员创建唯一临时学员账号', response, userId) return userId } async function formalStudentLogin( request: APIRequestContext, userId: string, password: string, expectedStatus = 200, ): Promise<{ response: APIResponse; session: LoginSession | null }> { const response = await request.post('/api/auth/v1/auth/login', { data: { userId: Number(userId), roleCode: 'student', password, rememberMe: false, }, }) assertStatus(response, expectedStatus, expectedStatus === 200 ? '学员身份登录' : '旧密码应被拒绝') if (response.status() !== 200) return { response, session: null } const data = asRecord(envelopeData(await responseJson(response))) const user = asRecord(data.user) const session = { accessToken: String(data.accessToken ?? ''), refreshToken: String(data.refreshToken ?? ''), mustChangePassword: Boolean(user.mustChangePassword ?? data.mustChangePassword), } expect(session.accessToken).not.toBe('') expect(session.refreshToken).not.toBe('') return { response, session } } async function completeInitialPassword( request: APIRequestContext, session: LoginSession, newPassword: string, ) { const response = await request.put('/api/auth/v1/auth/initial-password', { headers: auth(session.accessToken), data: { newPassword }, }) assertStatus(response, 200, '完成首次登录改密') const data = asRecord(envelopeData(await responseJson(response))) const user = asRecord(data.user) expect(Boolean(user.mustChangePassword)).toBeFalsy() const replacement: LoginSession = { accessToken: String(data.accessToken ?? ''), refreshToken: String(data.refreshToken ?? ''), mustChangePassword: false, } expect(replacement.accessToken).not.toBe('') return { response, session: replacement } } async function expectSessionRevoked(request: APIRequestContext, session: LoginSession, label: string) { const response = await request.get('/api/auth/v1/auth/me', { headers: auth(session.accessToken) }) assertStatus(response, 401, label) return response } async function logoutCurrentUser(page: Page) { await page.locator('.user-menu-trigger').click() await page.getByRole('menuitem', { name: '退出登录' }).click() await confirmMessageBox(page, '退出登录') await expect(page).toHaveURL(/\/login(?:\?|$)/) } async function selectStudentOnLogin(page: Page) { await page.locator('[data-role-code="student"]').click() const picker = page.locator('.login-identity-picker') await expect(picker).toBeVisible() await expect(picker).not.toHaveClass(/is-disabled/) await picker.click() const option = page.locator('.login-identity-popper .el-select-dropdown__item') .filter({ hasText: testUser.displayName }) .first() await expect(option).toBeVisible() await option.click() } async function loginStudentInBrowser(page: Page, password: string, expectedPath: RegExp) { await page.goto('/login') await selectStudentOnLogin(page) await page.locator('input[name="password"]').fill(password) const loginResponsePromise = page.waitForResponse((response) => ( response.request().method() === 'POST' && new URL(response.url()).pathname === '/api/auth/v1/auth/login' )) await page.getByRole('button', { name: '登录系统' }).click() const response = await loginResponsePromise assertStatus(response, 200, '浏览器学员身份登录') await expect(page).toHaveURL(expectedPath) return response } async function completeInitialPasswordInBrowser( page: Page, testInfo: TestInfo, newPassword: string, screenshotLabel: string, ) { await expect(page.getByRole('heading', { name: '设置新密码' })).toBeVisible() await page.getByPlaceholder('请输入新密码').fill(newPassword) await page.getByPlaceholder('请再次输入').fill(newPassword) await captureScreenshot(page, testInfo, screenshotLabel, [ page.locator('input[autocomplete="new-password"]'), ]) const changedResponsePromise = page.waitForResponse((response) => ( response.request().method() === 'PUT' && new URL(response.url()).pathname === '/api/auth/v1/auth/initial-password' )) await page.getByRole('button', { name: '保存并进入平台' }).click() const response = await changedResponsePromise assertStatus(response, 200, '浏览器完成首次改密') await expect(page).toHaveURL(/\/overview(?:\?|$)/) return response } async function openUserRow(page: Page) { await page.goto('/organization/users') const keyword = page.getByPlaceholder('请输入姓名、账号、部门或角色') await expect(keyword).toBeVisible() await keyword.fill(testUser.username) await page.getByRole('button', { name: '搜索', exact: true }).click() const row = tableRowByText(page, testUser.username) await expect(row).toBeVisible() return row } async function resetPasswordFromUserPage( page: Page, testInfo: TestInfo, userId: string, mode: 'MANUAL' | 'SYSTEM', manualPassword?: string, ) { const row = await openUserRow(page) await row.getByRole('button', { name: '重置密码' }).click() const resetDialog = visibleDialog(page) await expect(resetDialog.getByRole('heading', { name: '重置登录密码' })).toBeVisible() if (mode === 'MANUAL') { await resetDialog.getByText('手动重置', { exact: true }).click() const input = resetDialog.locator([ 'input[data-testid="manual-reset-password-input"]', '[data-testid="manual-reset-password-input"] input', ].join(', ')).first() await input.fill(manualPassword ?? '') await expect(resetDialog.getByTestId('confirm-password-reset')).toBeEnabled() } else { await expect(resetDialog.getByTestId('password-reset-system-panel')).toBeVisible() } const resetResponsePromise = page.waitForResponse((response) => ( response.request().method() === 'PUT' && new URL(response.url()).pathname === `/api/auth/v1/users/${userId}/password` )) await resetDialog.getByTestId('confirm-password-reset').click() const response = await resetResponsePromise assertStatus(response, 200, mode === 'MANUAL' ? '手动重置登录密码' : '系统随机重置登录密码') const resultDialog = visibleDialog(page) await expect(resultDialog.getByRole('heading', { name: '密码重置成功' })).toBeVisible() await expect(resultDialog.getByTestId('password-result-mode')) .toContainText(mode === 'MANUAL' ? '手动设置' : '系统生成') const temporaryPassword = (await resultDialog.getByTestId('generated-password').textContent())?.trim() ?? '' expect(temporaryPassword).not.toBe('') if (mode === 'MANUAL') expect(temporaryPassword).toBe(manualPassword) await captureScreenshot( page, testInfo, mode === 'MANUAL' ? '01-manual-reset-success-masked' : '04-system-reset-success-masked', [resultDialog.getByTestId('password-result-secret')], ) await resultDialog.getByRole('button', { name: '我已妥善保存' }).click() await expect(resultDialog).toBeHidden() return { response, temporaryPassword } } async function attachLifecycleReport( testInfo: TestInfo, activities: PasswordActivity[], cleanupErrors: string[], ) { await attachJson(testInfo, '密码重置与个人中心生命周期及清理', { runId, runPrefix, activities, cleanupComplete: cleanupErrors.length === 0, cleanupErrors, security: '测试密码、一次性临时密码、访问令牌和刷新令牌仅保存在测试进程内存中;截图已遮罩敏感控件,报告未记录敏感值。', }) } test.describe.configure({ mode: 'serial' }) test.use({ trace: 'off', video: 'off' }) test('手动与系统密码重置、首次改密、会话撤销和个人中心改密真实闭环', async ({ page, request }, testInfo) => { test.setTimeout(180_000) const activities: PasswordActivity[] = [] const cleanupErrors: string[] = [] const adminToken = await adminAccessToken(request) const adminHeaders = auth(adminToken) let userId = '' let primaryError: unknown await cleanupUser(request, adminHeaders, activities, cleanupErrors) try { userId = await createTemporaryStudent(request, adminHeaders, activities) const initialLogin = await formalStudentLogin(request, userId, testUser.creationPassword) expect(initialLogin.session?.mustChangePassword).toBeTruthy() recordPass(activities, '账号安全', '初始密码建立受限会话', initialLogin.response, userId) const baseline = await completeInitialPassword(request, initialLogin.session!, testUser.baselinePassword) recordPass(activities, '账号安全', '建立重置前正常密码与会话', baseline.response, userId) const baselineSession = baseline.session await loginAsAdmin(page) const manualReset = await resetPasswordFromUserPage( page, testInfo, userId, 'MANUAL', testUser.manualPassword, ) recordPass(activities, '用户管理', '页面真实提交手动密码重置', manualReset.response, userId) const baselineRevoked = await expectSessionRevoked(request, baselineSession, '手动重置后旧会话必须失效') recordPass(activities, '账号安全', '手动重置撤销旧会话', baselineRevoked, userId) const oldBaselineLogin = await formalStudentLogin(request, userId, testUser.baselinePassword, 401) recordPass(activities, '账号安全', '手动重置后旧密码被拒绝', oldBaselineLogin.response, userId) const manualLogin = await formalStudentLogin(request, userId, manualReset.temporaryPassword) expect(manualLogin.session?.mustChangePassword).toBeTruthy() recordPass(activities, '账号安全', '手动临时密码仅建立首次改密会话', manualLogin.response, userId) await logoutCurrentUser(page) const manualBrowserLogin = await loginStudentInBrowser(page, manualReset.temporaryPassword, /\/change-password(?:\?|$)/) recordPass(activities, '登录与首次改密', '手动临时密码从身份选择页登录', manualBrowserLogin, userId) const manualInitialChange = await completeInitialPasswordInBrowser( page, testInfo, testUser.afterManualPassword, '02-manual-reset-first-change-masked', ) recordPass(activities, '登录与首次改密', '手动重置后页面完成首次改密', manualInitialChange, userId) const manualTemporarySessionRevoked = await expectSessionRevoked( request, manualLogin.session!, '首次改密后临时密码会话必须失效', ) recordPass(activities, '账号安全', '手动临时密码会话在首次改密后失效', manualTemporarySessionRevoked, userId) await captureScreenshot(page, testInfo, '03-manual-reset-normal-session-restored') const beforeSystemReset = await formalStudentLogin(request, userId, testUser.afterManualPassword) expect(beforeSystemReset.session?.mustChangePassword).toBeFalsy() await logoutCurrentUser(page) await loginAsAdmin(page) const systemReset = await resetPasswordFromUserPage(page, testInfo, userId, 'SYSTEM') recordPass(activities, '用户管理', '页面真实提交系统随机密码重置', systemReset.response, userId) const beforeSystemSessionRevoked = await expectSessionRevoked( request, beforeSystemReset.session!, '系统重置后旧会话必须失效', ) recordPass(activities, '账号安全', '系统随机重置撤销旧会话', beforeSystemSessionRevoked, userId) const oldManualLogin = await formalStudentLogin(request, userId, testUser.afterManualPassword, 401) recordPass(activities, '账号安全', '系统随机重置后旧密码被拒绝', oldManualLogin.response, userId) const systemLogin = await formalStudentLogin(request, userId, systemReset.temporaryPassword) expect(systemLogin.session?.mustChangePassword).toBeTruthy() recordPass(activities, '账号安全', '系统随机临时密码仅建立首次改密会话', systemLogin.response, userId) await logoutCurrentUser(page) const systemBrowserLogin = await loginStudentInBrowser(page, systemReset.temporaryPassword, /\/change-password(?:\?|$)/) recordPass(activities, '登录与首次改密', '系统随机临时密码从身份选择页登录', systemBrowserLogin, userId) const systemInitialChange = await completeInitialPasswordInBrowser( page, testInfo, testUser.afterSystemPassword, '05-system-reset-first-change-masked', ) recordPass(activities, '登录与首次改密', '系统随机重置后页面完成首次改密', systemInitialChange, userId) const systemTemporarySessionRevoked = await expectSessionRevoked( request, systemLogin.session!, '系统首次改密后临时密码会话必须失效', ) recordPass(activities, '账号安全', '系统临时密码会话在首次改密后失效', systemTemporarySessionRevoked, userId) await test.step('个人中心验证错误原密码、弱密码和确认不一致,再真实成功改密', async () => { await page.goto('/profile') await expect(page.getByRole('heading', { name: '个人中心' })).toBeVisible() const form = page.locator('.profile-password-form') const currentPassword = form.locator('input[autocomplete="current-password"]') const newPasswords = form.locator('input[autocomplete="new-password"]') const newPassword = newPasswords.nth(0) const confirmation = newPasswords.nth(1) const submit = form.getByRole('button', { name: '确认修改' }) const masks = [currentPassword, newPasswords] await currentPassword.fill(testUser.wrongCurrentPassword) await newPassword.fill(testUser.profilePassword) await confirmation.fill(testUser.profilePassword) const wrongResponsePromise = page.waitForResponse((response) => ( response.request().method() === 'PUT' && new URL(response.url()).pathname === '/api/auth/v1/auth/password' )) await submit.click() const wrongResponse = await wrongResponsePromise assertStatus(wrongResponse, 400, '个人中心错误原密码应被服务端拒绝') await expect(page.locator('.el-message').filter({ hasText: '原密码错误' }).last()).toBeVisible() recordPass(activities, '个人中心', '错误原密码真实提交并被服务端拒绝', wrongResponse, userId) await captureScreenshot(page, testInfo, '06-profile-wrong-current-password-masked', masks) let passwordRequestCount = 0 const countPasswordRequest = (requestValue: { method: () => string; url: () => string }) => { if (requestValue.method() === 'PUT' && new URL(requestValue.url()).pathname === '/api/auth/v1/auth/password') { passwordRequestCount += 1 } } page.on('request', countPasswordRequest) await currentPassword.fill(testUser.afterSystemPassword) await newPassword.fill('weak123') await confirmation.fill('weak123') const beforeWeak = passwordRequestCount await submit.click() await expect(page.locator('.el-message').filter({ hasText: '新密码至少 8 位' }).last()).toBeVisible() expect(passwordRequestCount, '弱密码应在页面校验阶段拦截').toBe(beforeWeak) activities.push({ module: '个人中心', action: '弱密码提交在页面校验阶段拦截', result: 'PASS', resourceId: userId }) await captureScreenshot(page, testInfo, '07-profile-weak-password-masked', masks) await currentPassword.fill(testUser.afterSystemPassword) await newPassword.fill(testUser.mismatchPassword) await confirmation.fill(testUser.mismatchConfirmation) const beforeMismatch = passwordRequestCount await submit.click() await expect(page.locator('.el-message').filter({ hasText: '两次输入的新密码不一致' }).last()).toBeVisible() expect(passwordRequestCount, '确认密码不一致应在页面校验阶段拦截').toBe(beforeMismatch) activities.push({ module: '个人中心', action: '确认密码不一致在页面校验阶段拦截', result: 'PASS', resourceId: userId }) await captureScreenshot(page, testInfo, '08-profile-confirmation-mismatch-masked', masks) const separateSession = await formalStudentLogin(request, userId, testUser.afterSystemPassword) expect(separateSession.session?.mustChangePassword).toBeFalsy() await currentPassword.fill(testUser.afterSystemPassword) await newPassword.fill(testUser.profilePassword) await confirmation.fill(testUser.profilePassword) const successResponsePromise = page.waitForResponse((response) => ( response.request().method() === 'PUT' && new URL(response.url()).pathname === '/api/auth/v1/auth/password' )) await submit.click() const successResponse = await successResponsePromise assertStatus(successResponse, 200, '个人中心成功修改密码') await expect(page).toHaveURL(/\/login(?:\?|$)/) recordPass(activities, '个人中心', '正确原密码真实提交并修改成功', successResponse, userId) page.off('request', countPasswordRequest) const separateSessionRevoked = await expectSessionRevoked( request, separateSession.session!, '个人中心改密后其他会话必须失效', ) recordPass(activities, '账号安全', '个人中心改密撤销其他会话', separateSessionRevoked, userId) const previousLogin = await formalStudentLogin(request, userId, testUser.afterSystemPassword, 401) recordPass(activities, '账号安全', '个人中心改密后旧密码被拒绝', previousLogin.response, userId) const restoredLogin = await formalStudentLogin(request, userId, testUser.profilePassword) expect(restoredLogin.session?.mustChangePassword).toBeFalsy() recordPass(activities, '账号安全', '个人中心新密码恢复正常可登录状态', restoredLogin.response, userId) const browserRestoredLogin = await loginStudentInBrowser(page, testUser.profilePassword, /\/overview(?:\?|$)/) recordPass(activities, '登录与首次改密', '最终新密码从身份选择页正常登录', browserRestoredLogin, userId) await captureScreenshot(page, testInfo, '09-profile-password-login-restored') }) } catch (error) { primaryError = error } finally { await cleanupUser(request, adminHeaders, activities, cleanupErrors) await attachLifecycleReport(testInfo, activities, cleanupErrors) } if (primaryError) throw primaryError expect(cleanupErrors, '临时用户必须完成无残留清理').toEqual([]) expect(await findUser(request, adminHeaders), '临时用户清理后不应残留').toBeNull() })