diff --git a/tests/zentao-auth-859-861-864.spec.ts b/tests/zentao-auth-859-861-864.spec.ts new file mode 100644 index 0000000..97053f9 --- /dev/null +++ b/tests/zentao-auth-859-861-864.spec.ts @@ -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) +}) diff --git a/tests/zentao-user-regression.spec.ts b/tests/zentao-user-regression.spec.ts new file mode 100644 index 0000000..7643f63 --- /dev/null +++ b/tests/zentao-user-regression.spec.ts @@ -0,0 +1,79 @@ +import type { Page, Route } from '@playwright/test' + +import { captureScreenshot, expect, test } from './fixtures' + +const json = (route: Route, data: unknown) => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ code: 0, message: '成功', data, timestamp: Date.now(), requestId: 'zentao-user-regression' }), +}) + +const roles = [ + { id: '1', code: 'admin', name: '管理员', shortName: '管', enabled: true, builtIn: true, superAdmin: true, dataScope: 'ALL', dataVersion: 1 }, + { id: '2', code: 'teacher', name: '教员', shortName: '教', enabled: true, builtIn: true, superAdmin: false, dataScope: 'SYSTEM', dataVersion: 1 }, +] + +async function mockUsers(page: Page) { + let userRequests = 0 + let statsRequests = 0 + await page.route(/\/api\/(?:auth\/v1|v1)\//, async (route) => { + const path = new URL(route.request().url()).pathname + if (path.endsWith('/auth/me')) return json(route, { + userId: '1', username: 'admin', displayName: '系统管理员', departmentId: '100', departmentName: '数字人平台', + activeRoleId: '1', roles, authorizationMode: 'SINGLE_ACTIVE', mustChangePassword: false, + permissions: ['system.users', 'system.users.update', 'system.users.reset-password'], + }) + if (path.endsWith('/departments/tree')) return json(route, [{ + id: '100', code: 'ORG-100', name: '数字人平台', parentId: null, enabled: true, isRoot: true, + sortOrder: 10, directUserCount: 1, directChildCount: 0, dataVersion: 1, children: [], + }]) + if (path.endsWith('/roles/all')) return json(route, roles) + if (path.endsWith('/users/stats')) { + statsRequests += 1 + const pending = statsRequests === 1 ? 2 : 1 + return json(route, { total: 12, enabled: 12, disabled: 0, passwordPending: pending, mustChangePasswordCount: pending }) + } + if (path.endsWith('/users')) { + userRequests += 1 + return json(route, { + records: [{ + id: '9', username: 'teacher09', displayName: '测试教员', departmentId: '100', departmentName: '数字人平台', + roleIds: ['2', '1'], defaultRoleId: '1', enabled: true, isProtected: false, + credentialConfigured: true, mustChangePassword: userRequests === 1, lastLoginAt: null, dataVersion: 1, + }], + total: 1, page: 1, size: 20, + }) + } + if (path.endsWith('/menus/navigation')) return json(route, []) + if (path.endsWith('/system-config/public')) return json(route, { systemName: '数字人平台' }) + return json(route, {}) + }) + await page.addInitScript(() => { + window.sessionStorage.setItem('ai-person:web:access-token:v1', 'zentao-user-regression-token') + }) + return { + statsRequests: () => statsRequests, + } +} + +test('Bug 860、862:默认角色置顶且搜索同步刷新全局待改密统计', async ({ page }, testInfo) => { + const requests = await mockUsers(page) + await page.goto('/organization/users') + + const row = page.locator('.users-table .el-table__row').filter({ hasText: '测试教员' }) + const roleTags = row.locator('.role-tags .el-tag') + await expect(roleTags).toHaveCount(2) + await expect(roleTags.first()).toContainText('管理员') + await expect(roleTags.first()).toContainText('默认') + await expect(page.getByTestId('user-inline-statistics')).toContainText('全局待改密 2') + + const searchInput = page.getByPlaceholder('请输入姓名、账号、部门或角色') + await searchInput.fill('teacher09') + await searchInput.press('Enter') + await expect.poll(requests.statsRequests).toBe(2) + await expect(page.getByTestId('user-inline-statistics')).toContainText('全局待改密 1') + await expect(row.getByText('待修改密码', { exact: true })).toHaveCount(0) + await expect(page).toHaveURL(/\/organization\/users(?:[?#].*)?$/) + await captureScreenshot(page, testInfo, 'bugs-860-862-user-role-and-pending-statistics') + await expect(page).toHaveURL(/\/organization\/users(?:[?#].*)?$/) +})