Files
Agent-System-Frontend/e2e/login-key.playwright.ts
T
weichen 44b964682c Add login-key browser e2e and harden changepw gate sync
Why: The login recovery contract requires a real-browser acceptance
spec covering admin issue -> key-only redeem -> forced set-password ->
relogin, and the changepw spec needed the same deterministic gate
synchronization.

Evidence: Both specs pass against an isolated auth-on instance
(serve --auth on, binary backend=467718a+frontend=9fc7da2); receipts
be7ae9a2/b3a9e691 bound to Dev HEAD 268ef29 (SELF). changepw-form also
passes (5.9s) alongside login-key (12.2s) on a reused instance,
proving idempotency.

Impact: New e2e/login-key.playwright.ts (skip-guarded like existing
suites); changepw login() waits for the gate heading and success page
before returning to login, fixing a stale-fill race that submitted
empty gate fields.

Refs: Dev docs/svc-deploy/2026-10-13-login-recovery-contract.md; Dev PR #504.
2026-10-13 04:33:35 +00:00

113 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 登录恢复流程真实浏览器验收(2026-10-13 合同,docs/svc-deploy/
// 2026-10-13-login-recovery-contract.md):管理员签发一次性登录 Key →
// 用户在登录页只输 Key 兑换 → 受限设密门(无当前密码框)→ 设密成功回登录页 →
// 新密码登录进工作台;重复兑换同一 Key 被拒。
// 驱动:任意 auth-on 实例(PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE;
// scripts/e2e.sh 第 4 阶段顺路覆盖,或对隔离实例直连——见 Dev 任务卡
// feature/svc-login-recovery-flow 的真实浏览器验收记录)。
import { test, expect, type Page } from '@playwright/test'
const ADMIN = process.env.E2E_ADMIN || 'e2e-admin'
const ADMIN_BOOT = process.env.E2E_ADMIN_PASSWORD || 'E2eAdmin#2026'
const ADMIN_PW = process.env.E2E_ADMIN_PASSWORD2 || 'E2eAdmin#2026b'
const USER = process.env.E2E_LOGIN_KEY_USER || 'e2e-user'
const USER_PW = process.env.E2E_LOGIN_KEY_PASSWORD2 || 'E2eUser#2026b'
test.skip(!(process.env.PLAYWRIGHT_BASE_URL && process.env.E2E_PLAIN_BASE),
'set PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE (auth-on instance required)')
async function passwordLogin(page: Page, owner: string, password: string) {
await page.goto('/')
await page.getByPlaceholder('由管理员分配').fill(owner)
await page.getByPlaceholder('初始密码或常用密码').fill(password)
await page.getByRole('button', { name: '登录', exact: true }).click()
}
/** 管理员登录;初始密码(must-change)则先完成受限设密再以新密码重登。
* 初始密码单次有效:已跑过本用例的环境(boot 已被改掉)自动改用新密码。 */
async function adminLogin(page: Page) {
await passwordLogin(page, ADMIN, ADMIN_BOOT)
// 门标题与提交按钮同名(设置新密码):用 heading 角色唯一定位,
// getByText 双命中会触发 strict-mode 抛错被 catch 吞掉、误判 gated=false。
let gate = page.getByRole('heading', { name: '设置新密码' })
let gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
if (!gated) {
// boot 密码失败(此前运行已完成改密):换正式密码重登。
const loginErr = await page.locator('.lg-err').waitFor({ state: 'visible', timeout: 3_000 }).then(() => true).catch(() => false)
if (!loginErr) throw new Error('admin login neither gated nor errored')
await passwordLogin(page, ADMIN, ADMIN_PW)
gate = page.getByRole('heading', { name: '设置新密码' })
gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
}
if (gated) {
const pw = page.locator('.lg-card input[type="password"]')
await pw.nth(0).fill(ADMIN_PW)
await pw.nth(1).fill(ADMIN_PW)
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()
await passwordLogin(page, ADMIN, ADMIN_PW)
}
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
}
test('登录恢复全流程:签发 → 兑换 → 免旧密码设密 → 新密码登录;重复兑换被拒', async ({ page }) => {
// 全流程含多次 argon2 登录与两道门,30s 默认预算不够。
test.setTimeout(120_000)
// 1) 管理员进入用户管理,为目标用户生成一次性登录 Key(明文仅显示一次)。
await adminLogin(page)
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '系统管理…' }).click()
const zone = page.getByRole('dialog', { name: '系统管理' })
await expect(zone).toBeVisible()
// 每个用户行都有同款按钮:限定目标用户所在行。
await zone.locator('tr', { hasText: USER }).getByRole('button', { name: '生成登录 Key' }).click()
await zone.getByRole('button', { name: '生成 Key', exact: true }).click()
const keyInput = zone.locator('input[aria-label="一次性登录 Key(仅显示一次)"]')
await expect(keyInput).toBeVisible({ timeout: 10_000 })
const key = await keyInput.inputValue()
expect(key).toMatch(/^alk_/)
await expect(zone.getByText(/单次有效/)).toBeVisible()
// 2) 管理员退出系统管理与登录态,回到登录页。
//(Key 面板内也有「关闭」;退出全屏层要点头部的「返回工作台」。)
await zone.getByRole('button', { name: '返回工作台' }).click()
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '退出登录' }).click()
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
// 3) 用户只输 Key 兑换;身份由服务端绑定确定(页面不出现用户名输入框)。
await page.getByRole('tab', { name: '一次性登录 Key' }).click()
await page.getByPlaceholder('alk_…').fill(key)
await page.getByRole('button', { name: '兑换并设置新密码' }).click()
// 4) 受限设密门:服务端确认用户名;无当前密码输入框。
const gate = page.locator('.lg-card')
await expect(gate.getByText(`账号 ${USER}(服务端确认)`)).toBeVisible({ timeout: 10_000 })
await expect(gate.locator('input[autocomplete="current-password"]')).toHaveCount(0)
const pw = gate.locator('input[type="password"]')
await pw.nth(0).fill(USER_PW)
await pw.nth(1).fill(USER_PW)
await gate.getByRole('button', { name: '设置新密码', exact: true }).click()
// 5) 成功页:提示用新密码登录,唯一出口回登录页。
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: '返回登录', exact: true }).click()
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible()
// 6) 新密码登录进工作台(旧初始密码已不可用一并验证)。
await passwordLogin(page, USER, USER_PW)
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
// 7) 同一 Key 重复兑换被拒,错误文案指向管理员重签。
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '退出登录' }).click()
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
await page.getByRole('tab', { name: '一次性登录 Key' }).click()
await page.getByPlaceholder('alk_…').fill(key)
await page.getByRole('button', { name: '兑换并设置新密码' }).click()
await expect(page.getByText('该 Key 已被使用过,请联系管理员重新签发')).toBeVisible({ timeout: 10_000 })
})