import type { APIRequestContext, APIResponse, Page, TestInfo } from '@playwright/test' import { attachJson, captureScreenshot, expect, runPrefix, test } from './fixtures' import { adminAccessToken, confirmMessageBox, envelopeData } from './helpers' type JsonRecord = Record interface DepartmentNode extends JsonRecord { id: string name: string children: DepartmentNode[] } interface LoginRoleCase { code: 'administrative' | 'teacher' label: string displayName: string username: string password: string department: DepartmentNode organizationPath: DepartmentNode[] } interface CreatedUser { id: string username: string dataVersion: number } test.use({ trace: 'off', video: 'off' }) const suffix = runPrefix.replace(/[^A-Za-z0-9]/g, '').slice(-10).toLowerCase() || 'manual' const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' ? value as JsonRecord : {} const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : [] async function body(response: APIResponse) { return response.json().catch(() => ({})) } 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) } function normalizeDepartment(value: unknown): DepartmentNode { const item = asRecord(value) return { ...item, id: String(item.id ?? ''), name: String(item.name ?? ''), children: Array.isArray(item.children) ? item.children.map(normalizeDepartment) : [], } } async function createRoleUser( request: APIRequestContext, headers: Record, role: LoginRoleCase, ) { const rolesResponse = await request.get('/api/auth/v1/roles/all', { headers }) expect(rolesResponse.status(), '读取正式角色目录').toBe(200) const rolesData = envelopeData(await body(rolesResponse)) const roles = Array.isArray(rolesData) ? asRecords(rolesData) : pageRecords(rolesData) const targetRole = roles.find((item) => String(item.code ?? item.roleCode) === role.code && item.enabled !== false) expect(targetRole, `测试环境必须存在启用的 ${role.code} 角色`).toBeTruthy() const response = await request.post('/api/auth/v1/users', { headers, data: { username: role.username, displayName: role.displayName, departmentId: Number(role.department.id), roleIds: [Number(targetRole!.id)], defaultRoleId: Number(targetRole!.id), enabled: true, password: role.password, remark: `${runPrefix} ${role.label}身份真实登录测试,结束自动删除`, version: 0, }, }) expect(response.status(), `创建临时${role.label}账号`).toBe(200) const created = asRecord(envelopeData(await body(response))) return { id: String(created.id ?? ''), username: role.username, dataVersion: Number(created.dataVersion ?? created.version ?? 0), } satisfies CreatedUser } async function completeInitialPassword( request: APIRequestContext, role: LoginRoleCase, user: CreatedUser, ) { const login = await request.post('/api/auth/v1/auth/login', { data: { roleCode: role.code, userId: Number(user.id), departmentId: Number(role.department.id), password: role.password, rememberMe: false, }, }) expect(login.status(), `${role.label}初始密码登录`).toBe(200) const loginData = asRecord(envelopeData(await body(login))) const accessToken = String(loginData.accessToken ?? asRecord(loginData.tokens).accessToken ?? '') expect(accessToken).not.toBe('') const changed = await request.put('/api/auth/v1/auth/initial-password', { headers: { Authorization: `Bearer ${accessToken}` }, data: { newPassword: role.password.replace('Start', 'Ready') }, }) expect(changed.status(), `${role.label}完成首次改密`).toBe(200) role.password = role.password.replace('Start', 'Ready') } async function selectVisibleOption(page: Page, text: string) { const dropdown = page.locator('.el-select-dropdown:visible').last() await expect(dropdown).toBeVisible() const option = dropdown.locator('.el-select-dropdown__item').filter({ hasText: text }).first() await expect(option).toBeVisible() await option.click() } async function loginRoleInBrowser(page: Page, role: LoginRoleCase, testInfo: TestInfo) { await page.goto('/login') await page.locator(`[data-role-code="${role.code}"]`).click() const organizationSelects = page.locator('.login-role-fields > .login-field .el-select:not(.login-identity-picker)') for (let index = 0; index < role.organizationPath.length; index += 1) { await organizationSelects.nth(index).click() await selectVisibleOption(page, role.organizationPath[index].name) } const identityPicker = page.locator('.login-identity-picker') await expect(identityPicker).toBeEnabled() await identityPicker.click() await selectVisibleOption(page, role.displayName) await page.locator('input[name="password"]').fill(role.password) const loginResponse = page.waitForResponse((response) => ( response.request().method() === 'POST' && new URL(response.url()).pathname === '/api/auth/v1/auth/login' )) await page.getByRole('button', { name: '登录系统' }).click() expect((await loginResponse).status(), `${role.label}浏览器登录`).toBe(200) await expect(page).toHaveURL(/\/overview(?:\?|$)/) await expect(page.locator('.app-shell')).toBeVisible() await expect(page.locator('.user-menu-trigger')).toContainText(role.displayName) await captureScreenshot(page, testInfo, `${role.code}-real-account-login`) await page.locator('.user-menu-trigger').click() await page.getByRole('menuitem', { name: /退出登录/ }).click() await confirmMessageBox(page, '退出登录') await expect(page).toHaveURL(/\/login(?:\?|$)/) } async function cleanupUser( request: APIRequestContext, headers: Record, user: CreatedUser, ) { const lookup = await request.get('/api/auth/v1/users', { headers, params: { keyword: user.username, page: 1, size: 100 }, }) if (lookup.status() !== 200) return false const current = pageRecords(await body(lookup)).find((item) => String(item.username ?? '') === user.username) if (!current) return true const deleted = await request.delete(`/api/auth/v1/users/${encodeURIComponent(String(current.id))}`, { headers, data: { version: Number(current.dataVersion ?? current.version ?? 0) }, }) return [200, 204, 404].includes(deleted.status()) } test('行政和教员必须通过组织与账号选择完成真实登录', async ({ page, request }, testInfo) => { const token = await adminAccessToken(request) const headers = { Authorization: `Bearer ${token}` } const contextResponse = await request.get('/api/auth/v1/auth/login-context') expect(contextResponse.status()).toBe(200) const context = asRecord(envelopeData(await body(contextResponse))) const roots = (Array.isArray(context.departments) ? context.departments : []).map(normalizeDepartment) const administrativeDepartment = roots.find((item) => item.id && item.name) const teacherRoot = roots.find((item) => item.children.length > 0) const teacherDepartment = teacherRoot?.children.find((item) => item.id && item.name) expect(administrativeDepartment, '行政登录需要至少一个一级部门').toBeTruthy() expect(teacherRoot && teacherDepartment, '教员登录需要至少一个二级部门').toBeTruthy() const roles: LoginRoleCase[] = [ { code: 'administrative', label: '行政', displayName: `${runPrefix}-行政登录员`, username: `ape_adm_${suffix}`.slice(0, 32), password: `Start@Aa1${suffix.slice(-4)}`, department: administrativeDepartment!, organizationPath: [administrativeDepartment!], }, { code: 'teacher', label: '教员', displayName: `${runPrefix}-教员登录员`, username: `ape_tch_${suffix}`.slice(0, 32), password: `Start@Bb2${suffix.slice(-4)}`, department: teacherDepartment!, organizationPath: [teacherRoot!, teacherDepartment!], }, ] const created: Array<{ role: LoginRoleCase; user: CreatedUser }> = [] const cleanup: Array<{ role: string; result: 'CLEANED' | 'CLEANUP_FAILED' }> = [] try { for (const role of roles) { const user = await createRoleUser(request, headers, role) created.push({ role, user }) await completeInitialPassword(request, role, user) await loginRoleInBrowser(page, role, testInfo) } } finally { for (const item of [...created].reverse()) { cleanup.push({ role: item.role.code, result: await cleanupUser(request, headers, item.user) ? 'CLEANED' : 'CLEANUP_FAILED', }) } await attachJson(testInfo, 'role-login-cleanup', cleanup) } expect(cleanup.every((item) => item.result === 'CLEANED'), '临时身份账号必须全部清理').toBeTruthy() })