|
- 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<string, unknown>
- type Headers = Record<string, string>
-
- 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()
- })
|