diff --git a/e2e/changepw-form.playwright.ts b/e2e/changepw-form.playwright.ts index 2f9f947..5f8ee9a 100644 --- a/e2e/changepw-form.playwright.ts +++ b/e2e/changepw-form.playwright.ts @@ -17,16 +17,18 @@ async function login(page: Page) { await page.getByPlaceholder('由管理员分配').fill(OWNER) await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD) await page.getByRole('button', { name: '登录', exact: true }).click() - const gate = page.getByText('修改密码') + // 受限设密门(2026-10-13 合同):heading 唯一定位(标题与按钮同名), + // 门挂载后登录卡已卸载,fill 不再与登录卡竞态。 + const gate = page.getByRole('heading', { name: '设置新密码' }) const gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false) if (gated) { - // 受限设密门(2026-10-13 合同):只有新密码/确认两框,无当前密码输入。 const pwInputs = page.locator('.lg-card input[type="password"]') await pwInputs.nth(0).fill(PASSWORD2) await pwInputs.nth(1).fill(PASSWORD2) await page.getByRole('button', { name: '设置新密码', exact: true }).click() - // 设密成功后服务端销毁会话:完成页唯一出口是回密码登录页重新登录。 - await page.getByRole('button', { name: '返回登录' }).click() + // 设密成功后服务端销毁会话:等成功页再回登录(在途点取消会被会话复验拒绝)。 + await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 }) + await page.getByRole('button', { name: '返回登录', exact: true }).click() await page.getByPlaceholder('由管理员分配').fill(OWNER) await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2) await page.getByRole('button', { name: '登录', exact: true }).click() diff --git a/e2e/login-key.playwright.ts b/e2e/login-key.playwright.ts new file mode 100644 index 0000000..04e3a84 --- /dev/null +++ b/e2e/login-key.playwright.ts @@ -0,0 +1,112 @@ +// 登录恢复流程真实浏览器验收(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 }) +})