You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

172 line
7.9 KiB

  1. import type { Locator, Page, Route } from '@playwright/test'
  2. import { captureScreenshot, expect, test } from './fixtures'
  3. test.use({ trace: 'off', video: 'off' })
  4. const loginContext = {
  5. defaultRoleCode: 'admin',
  6. roles: [
  7. { code: 'teacher', label: '教员', accountLabel: '教员账号', organizationMode: 'CASCADE', organizationLabels: ['教学系', '教研室'] },
  8. { code: 'admin', label: '管理员', accountLabel: '管理员账号', organizationMode: 'NONE', organizationLabels: [] },
  9. ],
  10. departments: [
  11. {
  12. id: '100', code: 'TEST', name: '测试教学系', parentId: null,
  13. children: [{ id: '110', code: 'EMPTY', name: '空账号教研室', parentId: '100', children: [] }],
  14. },
  15. ],
  16. }
  17. const forcedPasswordProfile = {
  18. user: {
  19. id: '7', username: 'teacher.test', displayName: '测试教员',
  20. departmentId: '110', departmentName: '空账号教研室', mustChangePassword: true,
  21. },
  22. activeRoleId: '20',
  23. activeRole: { id: '20', code: 'teacher', name: '教员' },
  24. roles: [{ id: '20', code: 'teacher', name: '教员', status: 1 }],
  25. permissions: [],
  26. authorizationMode: 'SINGLE_ACTIVE',
  27. mustChangePassword: true,
  28. }
  29. const envelope = (data: unknown, message = '成功', code = 200) => JSON.stringify({
  30. code, message, data, timestamp: '2026-08-17T12:00:00+08:00', requestId: 'ape2e-zentao-auth',
  31. })
  32. const fulfill = (route: Route, data: unknown, status = 200, message = '成功', code = 200) => route.fulfill({
  33. status,
  34. contentType: 'application/json',
  35. body: envelope(data, message, code),
  36. })
  37. async function mockAuth(
  38. page: Page,
  39. options: { me?: 'forced' | 'expired'; initialPassword401?: boolean } = {},
  40. ) {
  41. let refreshRequests = 0
  42. let initialPasswordRequests = 0
  43. await page.route('**/api/auth/v1/**', async (route) => {
  44. const path = new URL(route.request().url()).pathname
  45. if (path.endsWith('/auth/login-context')) return fulfill(route, loginContext)
  46. if (path.endsWith('/auth/login-identities')) return fulfill(route, [])
  47. if (path.endsWith('/system-config/public')) return fulfill(route, {})
  48. if (path.endsWith('/menus/navigation')) return fulfill(route, [])
  49. if (path.endsWith('/auth/me')) {
  50. if (options.me === 'expired') {
  51. return fulfill(route, null, 401, '内部会话详情不应展示', 40101)
  52. }
  53. return fulfill(route, forcedPasswordProfile)
  54. }
  55. if (path.endsWith('/auth/initial-password')) {
  56. initialPasswordRequests += 1
  57. if (options.initialPassword401) {
  58. return fulfill(route, null, 401, '账号已停用:internal-user-id=7', 40102)
  59. }
  60. return fulfill(route, {})
  61. }
  62. if (path.endsWith('/auth/refresh')) {
  63. refreshRequests += 1
  64. return fulfill(route, null, 401, 'refresh-token-internal-detail', 40101)
  65. }
  66. return fulfill(route, {})
  67. })
  68. return {
  69. refreshRequests: () => refreshRequests,
  70. initialPasswordRequests: () => initialPasswordRequests,
  71. }
  72. }
  73. async function selectOption(page: Page, select: Locator, text: string) {
  74. await select.locator('.el-select__wrapper').click()
  75. await page.locator('.el-select-dropdown:visible').getByRole('option', { name: text, exact: true }).click()
  76. }
  77. async function seedSession(page: Page, includeRefresh = false) {
  78. await page.addInitScript(({ refresh }) => {
  79. if (sessionStorage.getItem('zentao-auth-seeded') === '1') return
  80. sessionStorage.setItem('zentao-auth-seeded', '1')
  81. sessionStorage.setItem('ai-person:web:access-token:v1', 'zentao-access-token')
  82. if (refresh) sessionStorage.setItem('ai-person:web:refresh-token:v1', 'zentao-refresh-token')
  83. }, { refresh: includeRefresh })
  84. }
  85. test('859:部门没有可登录教员时账号选择器明确禁用且不可展开', async ({ page }, testInfo) => {
  86. await mockAuth(page)
  87. await page.goto('/login')
  88. await page.locator('[data-role-code="teacher"]').click()
  89. const organizationSelects = page.locator('.login-role-fields .login-picker:not(.login-identity-picker)')
  90. await selectOption(page, organizationSelects.nth(0), '测试教学系')
  91. await selectOption(page, organizationSelects.nth(1), '空账号教研室')
  92. const identity = page.locator('.login-identity-picker')
  93. await expect(page.getByText('当前条件下暂无可登录教员账号,请联系管理员。')).toBeVisible()
  94. await expect(identity.getByRole('combobox')).toBeDisabled()
  95. await identity.locator('.el-select__wrapper').click({ force: true })
  96. await expect(page.locator('.login-identity-popper:visible')).toHaveCount(0)
  97. await captureScreenshot(page, testInfo, '859-empty-teacher-account-disabled')
  98. })
  99. test('861:首次改密两次输入不一致时立即显示字段提示且不提交', async ({ page }, testInfo) => {
  100. await seedSession(page)
  101. const mocked = await mockAuth(page, { me: 'forced' })
  102. await page.goto('/change-password')
  103. await expect(page.getByText('首次登录安全验证', { exact: true })).toBeVisible()
  104. await expect(page.getByRole('heading', { name: '请先设置新密码' })).toBeVisible()
  105. await expect(page.getByText('为保护账号安全,初始密码不可继续使用。此步骤仅需完成一次。')).toBeVisible()
  106. const inputs = page.locator('input[autocomplete="new-password"]')
  107. await inputs.nth(0).fill('Strong#Password8')
  108. await inputs.nth(1).fill('Different#Pass9')
  109. await expect(page.locator('.password-requirements .is-passed')).toHaveCount(4)
  110. await expect(page.getByText('两次输入的新密码不一致', { exact: true })).toBeVisible()
  111. await expect(page.getByRole('button', { name: '保存新密码并进入平台' })).toBeDisabled()
  112. expect(mocked.initialPasswordRequests()).toBe(0)
  113. await captureScreenshot(page, testInfo, '861-initial-password-mismatch', [inputs])
  114. })
  115. test('首次登录改密页在 375px 窄屏保持可读且无横向滚动', async ({ page }, testInfo) => {
  116. await page.setViewportSize({ width: 375, height: 812 })
  117. await seedSession(page)
  118. await mockAuth(page, { me: 'forced' })
  119. await page.goto('/change-password')
  120. await expect(page.getByRole('heading', { name: '请先设置新密码' })).toBeVisible()
  121. await expect(page.getByText('首次登录 · 安全设置', { exact: true })).toBeVisible()
  122. const horizontalOverflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)
  123. expect(horizontalOverflow).toBeLessThanOrEqual(1)
  124. await captureScreenshot(page, testInfo, 'initial-password-mobile-375', [page.locator('input[autocomplete="new-password"]')])
  125. })
  126. test('864:首次改密期间账号停用后仅显示一次固定登录提示且不刷新令牌', async ({ page }, testInfo) => {
  127. await seedSession(page, true)
  128. const mocked = await mockAuth(page, { me: 'forced', initialPassword401: true })
  129. await page.goto('/change-password')
  130. const inputs = page.locator('input[autocomplete="new-password"]')
  131. await inputs.nth(0).fill('Strong#Password8')
  132. await inputs.nth(1).fill('Strong#Password8')
  133. await page.getByRole('button', { name: '保存新密码并进入平台' }).click()
  134. await expect(page).toHaveURL(/\/login\?redirect=/)
  135. await expect(page.locator('#login-error')).toHaveText('账号、密码或登录身份不匹配')
  136. await expect(page.getByText(/internal-user-id|refresh-token-internal-detail/)).toHaveCount(0)
  137. expect(mocked.initialPasswordRequests()).toBe(1)
  138. expect(mocked.refreshRequests()).toBe(0)
  139. await captureScreenshot(page, testInfo, '864-disabled-account-one-time-notice')
  140. await page.reload()
  141. await expect(page.locator('#login-error')).toHaveText('')
  142. })
  143. test('普通会话过期只跳转登录,不展示后端错误详情或停用账号提示', async ({ page }) => {
  144. await seedSession(page, true)
  145. const mocked = await mockAuth(page, { me: 'expired' })
  146. await page.goto('/overview')
  147. await expect(page).toHaveURL(/\/login\?redirect=/)
  148. await expect(page.locator('#login-error')).toHaveText('')
  149. await expect(page.getByText(/内部会话详情|refresh-token-internal-detail|账号、密码或登录身份不匹配/)).toHaveCount(0)
  150. expect(mocked.refreshRequests()).toBe(1)
  151. })