|
|
|
@@ -0,0 +1,154 @@ |
|
|
|
import type { Locator, Page, Route } from '@playwright/test' |
|
|
|
|
|
|
|
import { captureScreenshot, expect, test } from './fixtures' |
|
|
|
|
|
|
|
test.use({ trace: 'off', video: 'off' }) |
|
|
|
|
|
|
|
const loginContext = { |
|
|
|
defaultRoleCode: 'admin', |
|
|
|
roles: [ |
|
|
|
{ code: 'teacher', label: '教员', accountLabel: '教员账号', organizationMode: 'CASCADE', organizationLabels: ['教学系', '教研室'] }, |
|
|
|
{ code: 'admin', label: '管理员', accountLabel: '管理员账号', organizationMode: 'NONE', organizationLabels: [] }, |
|
|
|
], |
|
|
|
departments: [ |
|
|
|
{ |
|
|
|
id: '100', code: 'TEST', name: '测试教学系', parentId: null, |
|
|
|
children: [{ id: '110', code: 'EMPTY', name: '空账号教研室', parentId: '100', children: [] }], |
|
|
|
}, |
|
|
|
], |
|
|
|
} |
|
|
|
|
|
|
|
const forcedPasswordProfile = { |
|
|
|
user: { |
|
|
|
id: '7', username: 'teacher.test', displayName: '测试教员', |
|
|
|
departmentId: '110', departmentName: '空账号教研室', mustChangePassword: true, |
|
|
|
}, |
|
|
|
activeRoleId: '20', |
|
|
|
activeRole: { id: '20', code: 'teacher', name: '教员' }, |
|
|
|
roles: [{ id: '20', code: 'teacher', name: '教员', status: 1 }], |
|
|
|
permissions: [], |
|
|
|
authorizationMode: 'SINGLE_ACTIVE', |
|
|
|
mustChangePassword: true, |
|
|
|
} |
|
|
|
|
|
|
|
const envelope = (data: unknown, message = '成功', code = 200) => JSON.stringify({ |
|
|
|
code, message, data, timestamp: '2026-08-17T12:00:00+08:00', requestId: 'ape2e-zentao-auth', |
|
|
|
}) |
|
|
|
|
|
|
|
const fulfill = (route: Route, data: unknown, status = 200, message = '成功', code = 200) => route.fulfill({ |
|
|
|
status, |
|
|
|
contentType: 'application/json', |
|
|
|
body: envelope(data, message, code), |
|
|
|
}) |
|
|
|
|
|
|
|
async function mockAuth( |
|
|
|
page: Page, |
|
|
|
options: { me?: 'forced' | 'expired'; initialPassword401?: boolean } = {}, |
|
|
|
) { |
|
|
|
let refreshRequests = 0 |
|
|
|
let initialPasswordRequests = 0 |
|
|
|
await page.route('**/api/auth/v1/**', async (route) => { |
|
|
|
const path = new URL(route.request().url()).pathname |
|
|
|
if (path.endsWith('/auth/login-context')) return fulfill(route, loginContext) |
|
|
|
if (path.endsWith('/auth/login-identities')) return fulfill(route, []) |
|
|
|
if (path.endsWith('/system-config/public')) return fulfill(route, {}) |
|
|
|
if (path.endsWith('/menus/navigation')) return fulfill(route, []) |
|
|
|
if (path.endsWith('/auth/me')) { |
|
|
|
if (options.me === 'expired') { |
|
|
|
return fulfill(route, null, 401, '内部会话详情不应展示', 40101) |
|
|
|
} |
|
|
|
return fulfill(route, forcedPasswordProfile) |
|
|
|
} |
|
|
|
if (path.endsWith('/auth/initial-password')) { |
|
|
|
initialPasswordRequests += 1 |
|
|
|
if (options.initialPassword401) { |
|
|
|
return fulfill(route, null, 401, '账号已停用:internal-user-id=7', 40102) |
|
|
|
} |
|
|
|
return fulfill(route, {}) |
|
|
|
} |
|
|
|
if (path.endsWith('/auth/refresh')) { |
|
|
|
refreshRequests += 1 |
|
|
|
return fulfill(route, null, 401, 'refresh-token-internal-detail', 40101) |
|
|
|
} |
|
|
|
return fulfill(route, {}) |
|
|
|
}) |
|
|
|
return { |
|
|
|
refreshRequests: () => refreshRequests, |
|
|
|
initialPasswordRequests: () => initialPasswordRequests, |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
async function selectOption(page: Page, select: Locator, text: string) { |
|
|
|
await select.locator('.el-select__wrapper').click() |
|
|
|
await page.locator('.el-select-dropdown:visible').getByRole('option', { name: text, exact: true }).click() |
|
|
|
} |
|
|
|
|
|
|
|
async function seedSession(page: Page, includeRefresh = false) { |
|
|
|
await page.addInitScript(({ refresh }) => { |
|
|
|
if (sessionStorage.getItem('zentao-auth-seeded') === '1') return |
|
|
|
sessionStorage.setItem('zentao-auth-seeded', '1') |
|
|
|
sessionStorage.setItem('ai-person:web:access-token:v1', 'zentao-access-token') |
|
|
|
if (refresh) sessionStorage.setItem('ai-person:web:refresh-token:v1', 'zentao-refresh-token') |
|
|
|
}, { refresh: includeRefresh }) |
|
|
|
} |
|
|
|
|
|
|
|
test('859:部门没有可登录教员时账号选择器明确禁用且不可展开', async ({ page }, testInfo) => { |
|
|
|
await mockAuth(page) |
|
|
|
await page.goto('/login') |
|
|
|
await page.locator('[data-role-code="teacher"]').click() |
|
|
|
const organizationSelects = page.locator('.login-role-fields .login-picker:not(.login-identity-picker)') |
|
|
|
await selectOption(page, organizationSelects.nth(0), '测试教学系') |
|
|
|
await selectOption(page, organizationSelects.nth(1), '空账号教研室') |
|
|
|
|
|
|
|
const identity = page.locator('.login-identity-picker') |
|
|
|
await expect(page.getByText('当前条件下暂无可登录教员账号,请联系管理员。')).toBeVisible() |
|
|
|
await expect(identity.getByRole('combobox')).toBeDisabled() |
|
|
|
await identity.locator('.el-select__wrapper').click({ force: true }) |
|
|
|
await expect(page.locator('.login-identity-popper:visible')).toHaveCount(0) |
|
|
|
await captureScreenshot(page, testInfo, '859-empty-teacher-account-disabled') |
|
|
|
}) |
|
|
|
|
|
|
|
test('861:首次改密两次输入不一致时立即显示字段提示且不提交', async ({ page }, testInfo) => { |
|
|
|
await seedSession(page) |
|
|
|
const mocked = await mockAuth(page, { me: 'forced' }) |
|
|
|
await page.goto('/change-password') |
|
|
|
const inputs = page.locator('input[autocomplete="new-password"]') |
|
|
|
await inputs.nth(0).fill('Strong#Password8') |
|
|
|
await inputs.nth(1).fill('Different#Pass9') |
|
|
|
|
|
|
|
await expect(page.getByText('两次输入的新密码不一致', { exact: true })).toBeVisible() |
|
|
|
await expect(page.getByRole('button', { name: '保存并进入平台' })).toBeDisabled() |
|
|
|
expect(mocked.initialPasswordRequests()).toBe(0) |
|
|
|
await captureScreenshot(page, testInfo, '861-initial-password-mismatch', [inputs]) |
|
|
|
}) |
|
|
|
|
|
|
|
test('864:首次改密期间账号停用后仅显示一次固定登录提示且不刷新令牌', async ({ page }, testInfo) => { |
|
|
|
await seedSession(page, true) |
|
|
|
const mocked = await mockAuth(page, { me: 'forced', initialPassword401: true }) |
|
|
|
await page.goto('/change-password') |
|
|
|
const inputs = page.locator('input[autocomplete="new-password"]') |
|
|
|
await inputs.nth(0).fill('Strong#Password8') |
|
|
|
await inputs.nth(1).fill('Strong#Password8') |
|
|
|
await page.getByRole('button', { name: '保存并进入平台' }).click() |
|
|
|
|
|
|
|
await expect(page).toHaveURL(/\/login\?redirect=/) |
|
|
|
await expect(page.locator('#login-error')).toHaveText('账号、密码或登录身份不匹配') |
|
|
|
await expect(page.getByText(/internal-user-id|refresh-token-internal-detail/)).toHaveCount(0) |
|
|
|
expect(mocked.initialPasswordRequests()).toBe(1) |
|
|
|
expect(mocked.refreshRequests()).toBe(0) |
|
|
|
await captureScreenshot(page, testInfo, '864-disabled-account-one-time-notice') |
|
|
|
|
|
|
|
await page.reload() |
|
|
|
await expect(page.locator('#login-error')).toHaveText('') |
|
|
|
}) |
|
|
|
|
|
|
|
test('普通会话过期只跳转登录,不展示后端错误详情或停用账号提示', async ({ page }) => { |
|
|
|
await seedSession(page, true) |
|
|
|
const mocked = await mockAuth(page, { me: 'expired' }) |
|
|
|
await page.goto('/overview') |
|
|
|
|
|
|
|
await expect(page).toHaveURL(/\/login\?redirect=/) |
|
|
|
await expect(page.locator('#login-error')).toHaveText('') |
|
|
|
await expect(page.getByText(/内部会话详情|refresh-token-internal-detail|账号、密码或登录身份不匹配/)).toHaveCount(0) |
|
|
|
expect(mocked.refreshRequests()).toBe(1) |
|
|
|
}) |