104 lines
6.1 KiB
TypeScript
104 lines
6.1 KiB
TypeScript
// 防再发守卫:管理员建号路径(UsersPanel)必须持续符合后端待初始化契约。
|
||
// 背景:backend 8dfc223 将 POST /api/admin/users 改为严格 {owner, profile} 解码,
|
||
// 前端未跟进导致部署后 UI 建号 400(json: unknown field "name")——单测与 mock
|
||
// 都没有覆盖这条组合路径,本场景以真实浏览器把「填用户名 → 创建账号 →
|
||
// 确认『待初始化』提示与用户表行」钉进组合验收线。
|
||
// 驱动方:Dev 仓 scripts/e2e.sh——构建 RDM 二进制 + frontend dist,起 serve(auth on)
|
||
// 与 vite preview;种子账号 e2e-auditor 为基座管理员(bootstrap-admin)。
|
||
// 环境契约(缺省值与 e2e.sh 一致;PLAYWRIGHT_BASE_URL 未设置时整组跳过):
|
||
// PLAYWRIGHT_BASE_URL / E2E_OWNER / E2E_PASSWORD / E2E_PASSWORD2
|
||
import { test, expect, type Page } from '@playwright/test'
|
||
|
||
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
|
||
const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026'
|
||
const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
|
||
|
||
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (scripts/e2e.sh drives this suite)')
|
||
|
||
async function passwordLogin(page: Page, password: string) {
|
||
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible()
|
||
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||
await page.getByPlaceholder('初始密码或常用密码').fill(password)
|
||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||
}
|
||
|
||
/** 强制改密门:设新密码后服务端销毁会话,回到登录页。 */
|
||
async function completePasswordGate(page: Page) {
|
||
// 门模式异步加载:current_required 渲染 #forced-current,其余模式渲染受限设密提示。
|
||
const current = page.locator('#forced-current')
|
||
await expect(current.or(page.locator('.lg-card .lg-hint'))).toBeVisible()
|
||
if (await current.isVisible()) await current.fill(PASSWORD)
|
||
// 「新密码(≥8 位)」「确认新密码」label 未关联输入框,getByLabel 无法命中,按门内顺序定位。
|
||
const pw = page.locator('.lg-card input[type="password"]:not(#forced-current)')
|
||
await pw.nth(0).fill(PASSWORD2)
|
||
await pw.nth(1).fill(PASSWORD2)
|
||
await page.getByRole('button', { name: '设置新密码', exact: true }).click()
|
||
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
|
||
await page.getByRole('button', { name: '返回登录', exact: true }).click()
|
||
}
|
||
|
||
async function login(page: Page) {
|
||
await page.goto('/')
|
||
await passwordLogin(page, PASSWORD)
|
||
// 三种落点:直接进工作台(密码已生效)|强制改密门 → 设新密码后重登|初始密码已失效(种子二次运行)→ 改用新密码重登。
|
||
const entered = await page.locator('.tsb-user').waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||
if (!entered) {
|
||
// 门标题与提交按钮同名(设置新密码):用 heading 角色唯一定位。
|
||
const gated = await page.getByRole('heading', { name: '设置新密码' }).waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||
if (gated) {
|
||
await completePasswordGate(page)
|
||
await passwordLogin(page, PASSWORD2)
|
||
} else {
|
||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
|
||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||
}
|
||
}
|
||
await expect(page.locator('.tsb-user')).toBeVisible({ timeout: 15_000 })
|
||
}
|
||
|
||
test('管理员建号:UI 请求新契约并给出待初始化提示', async ({ page }) => {
|
||
await login(page)
|
||
|
||
// 账户菜单 → 系统管理…(全屏独立视图)→ 用户管理分区。
|
||
await page.locator('.tsb-user').click()
|
||
const adminEntry = page.getByText('系统管理…', { exact: true })
|
||
await expect(adminEntry).toBeVisible()
|
||
await adminEntry.click()
|
||
await expect(page.locator('.az')).toBeVisible()
|
||
const usersSection = page.getByText('用户管理', { exact: true }).first()
|
||
if (await usersSection.count()) await usersSection.click()
|
||
|
||
// 建号表单:用户名每次运行唯一(重复运行不因 409 假红),显示名可空。
|
||
const newOwner = `e2e-ui-${Date.now().toString(36)}`
|
||
const userInput = page.getByPlaceholder('用户名(登录名)')
|
||
await expect(userInput).toBeVisible()
|
||
await userInput.fill(newOwner)
|
||
await page.getByPlaceholder('显示名(可空)').fill('组合验收')
|
||
|
||
// 创建 → 契约断言一:请求体是严格 {owner, profile:{name}},无 password/isAdmin。
|
||
const createBody = page.waitForRequest(
|
||
req => req.url().endsWith('/api/admin/users') && req.method() === 'POST',
|
||
).then(req => req.postDataJSON())
|
||
await page.getByRole('button', { name: '创建账号', exact: true }).click()
|
||
const body = await createBody
|
||
expect(body).toEqual({ owner: newOwner, profile: { name: '组合验收' } })
|
||
|
||
// 契约断言二:页面创建后账号可直接使用,不再提示 CLI 初始化。
|
||
await expect(page.locator('.ad-notice')).toContainText(`已创建 ${newOwner}`)
|
||
|
||
// 契约断言三:请求不带密码时服务端生成一次性初始密码(响应 initialPassword,
|
||
// 账号 hasPassword=true 且 mustChangePassword=true)——页面一次性交付该密码,
|
||
// 用户表该账号状态为待改密而非未设密码,且未被授予管理员。
|
||
await expect(page.locator('.ad-reset .ad-hint')).toContainText(`初始密码仅显示一次,请立即安全交付给 ${newOwner}`)
|
||
await expect(page.locator('.ad-reset input[aria-label="一次性密码"]')).not.toHaveValue('')
|
||
const row = page.locator('.ad-table tbody tr', { hasText: newOwner })
|
||
await expect(row).toBeVisible()
|
||
await expect(row).toContainText('待改密')
|
||
await expect(row).not.toContainText('未设密码')
|
||
await expect(row).not.toContainText('ADMIN')
|
||
|
||
// 界面说明与后端流程一致(激活 Key 由服务器本地 CLI 发放)。
|
||
await expect(page.locator('.ad-create .ad-hint').filter({ hasText: '初始密码' })).toContainText('初始密码')
|
||
await expect(page.locator('.ad-create .ad-hint').filter({ hasText: 'user initialize-account' })).toHaveCount(0)
|
||
})
|