// 防再发守卫:管理员建号路径(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) })