/** 认证与手机号回归仅经生产页面和本地 HTTP 边界验证,不请求真实短信或修改线上账号。 */ import assert from "node:assert/strict"; import { test } from "node:test"; import { appOrigin, backendOrigin, eventually, fixtureRequest, login, loginAccount, loginPassword, scenario, } from "./helpers/console-browser.mjs"; async function authBoundary(page, overrides = {}) { // 外部响应使用既有接口信封;只捕获本组声明的接口,不给缺失契约统一返回成功。 const observed = []; const replies = { "/register": {}, "/account/password_reset": {}, "/oauth/token": { url: "/oauth/bind" }, "/oauth/client/bind": { jwt: "e2e-session-token" }, "/country": { list: [ { id: 1, name: "中国", name_zh: "中国", phone_code: 86 }, { id: 2, name: "美国", name_zh: "美国", phone_code: 1 }, ], }, "/account/phone/old": {}, "/account/phone": {}, ...overrides, }; await page .context() .route(`${backendOrigin}/console/v1/**`, async (route) => { const request = route.request(); const path = new URL(request.url()).pathname.replace( /^\/console\/v1/, "", ); if (!Object.hasOwn(replies, path)) return route.fallback(); observed.push({ path, method: request.method(), body: request.postDataJSON(), }); return route.fulfill({ json: { status: 200, msg: "", data: replies[path] }, headers: { "Access-Control-Allow-Origin": "*" }, }); }); return observed; } async function fixedPhonePrefix(page) { // 固定区号必须是文本,而非可编辑、可选择、可触发国家列表的控件。 assert.equal( await page.locator('input[autocomplete="tel-country-code"]').count(), 0, ); assert.equal( await page.getByRole("combobox", { name: /国际区号/ }).count(), 0, ); await page.getByText("中国大陆 +86", { exact: true }).waitFor(); } async function passwordKeyboard(page, field) { await field.fill("local-only-password"); await field.focus(); await page.keyboard.press("Tab"); const owner = field.locator(".."); const show = owner.getByRole("button", { name: "显示密码", exact: true }); assert.equal(await show.evaluate((e) => e === document.activeElement), true); assert.equal(await show.getAttribute("aria-pressed"), "false"); await page.keyboard.press("Space"); assert.equal(await field.getAttribute("type"), "text"); const hide = owner.getByRole("button", { name: "隐藏密码", exact: true }); assert.equal(await hide.getAttribute("aria-pressed"), "true"); await page.keyboard.press("Enter"); assert.equal(await field.getAttribute("type"), "password"); assert.equal(await field.inputValue(), "local-only-password"); } for (const [path, labels, autocomplete] of [ ["/login", ["密码"], "current-password"], ["/register", ["密码 *", "确认密码 *"], "new-password"], ["/forgot-password", ["新密码 *", "确认新密码 *"], "new-password"], ]) { test( `password manager semantics reach native inputs on ${path}`, { timeout: 20000 }, (t) => scenario(t, {}, async (page) => { await authBoundary(page); await page.goto(appOrigin + path); for (const label of labels) { const field = page.getByLabel(label, { exact: true }); await field.waitFor(); assert.equal(await field.getAttribute("autocomplete"), autocomplete); } return { nativePasswordAutocomplete: autocomplete, fields: labels.length, }; }), ); } test( "password visibility is keyboard operable without submitting the form", { timeout: 20000 }, (t) => scenario(t, {}, async (page) => { await page.goto(`${appOrigin}/login`); await passwordKeyboard(page, page.getByLabel("密码", { exact: true })); assert.equal( (await fixtureRequest("requests")).some((r) => r.path === "/login"), false, ); return { keyboardShowHide: true, noAccidentalSubmit: true }; }), ); for (const width of [320, 390]) { test( `password text remains editable beside its visibility button at ${width}px`, { timeout: 25000 }, (t) => scenario(t, {}, async (page) => { await page.setViewportSize({ width, height: 844 }); await page.goto(`${appOrigin}/register`); const field = page.getByLabel("密码 *", { exact: true }); const password = "long-local-password-".repeat(4); const observations = []; for (const direction of ["ltr", "rtl"]) { // dir是浏览器渲染环境,不宣称产品新增了RTL语言入口或全站RTL支持。 await page.locator("html").evaluate((element, dir) => { element.dir = dir; }, direction); await field.fill(password); await field.press("End"); const point = await field.evaluate((input) => { const rect = input.getBoundingClientRect(); const style = getComputedStyle(input); // 长密码滚到末端;点击实际文字编辑区末端,而不是假设按钮应有多少padding。 return { x: style.direction === "rtl" ? rect.left + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft) + 1 : rect.right - parseFloat(style.borderRightWidth) - parseFloat(style.paddingRight) - 1, y: rect.top + rect.height / 2, overflowing: input.scrollWidth > input.clientWidth, }; }); assert.equal( point.overflowing, true, "Exercise a scrolled long password", ); await page.mouse.click(point.x, point.y); assert.equal( await field.getAttribute("type"), "password", `${direction} text editing must not activate the visibility button`, ); assert.equal( await field.evaluate((input) => input === document.activeElement), true, "A real click in the text editor must keep native input focus", ); await page.keyboard.type("Z"); const edited = await field.inputValue(); assert.equal(edited.length, password.length + 1); assert.equal(edited.replace("Z", ""), password); assert.equal( await page.evaluate( () => document.documentElement.scrollWidth <= window.innerWidth, ), true, ); observations.push({ direction, textClickEditsWithoutToggling: true }); } return { viewportWidth: width, editing: observations }; }), ); } test( "pending login disables password editing and visibility then restores native attributes", { timeout: 25000 }, (t) => scenario(t, {}, async (page) => { let release; let received = false; const pending = new Promise((resolve) => { release = resolve; }); await page .context() .route(`${backendOrigin}/console/v1/login`, async (route) => { received = true; await pending; await route.fulfill({ json: { status: 400, msg: "本地验证失败", data: {} }, headers: { "Access-Control-Allow-Origin": "*" }, }); }); try { await page.goto(`${appOrigin}/login`); const field = page.getByLabel("密码", { exact: true }); await page.getByLabel("账号", { exact: true }).fill(loginAccount); await field.fill(loginPassword); const toggle = field.locator("..").getByRole("button", { name: "显示密码", exact: true, }); await page.getByRole("button", { name: "登录", exact: true }).click(); await eventually( () => received, Boolean, "Login must reach the HTTP boundary", ); assert.equal(await field.isDisabled(), true); assert.equal(await toggle.isDisabled(), true); const box = await toggle.boundingBox(); await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); await page.keyboard.type("Z"); assert.equal(await field.getAttribute("type"), "password"); assert.equal(await field.inputValue(), loginPassword); release(); await eventually( () => field.isEnabled(), Boolean, "Rejected login unlocks the form", ); assert.equal(await toggle.isEnabled(), true); assert.equal( await field.getAttribute("aria-describedby"), "login-error", ); return { disabledInputAndToggle: true, nativeAriaRestored: true }; } finally { release(); } }), ); async function contrast(locator) { return locator.evaluate((element) => { const style = getComputedStyle(element); const rgb = (value) => value .match(/[\d.]+/g) .slice(0, 3) .map(Number); const light = (color) => rgb(color) .map((channel) => { const v = channel / 255; return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }) .reduce( (sum, value, i) => sum + value * [0.2126, 0.7152, 0.0722][i], 0, ); const fg = light(style.color), bg = light(style.backgroundColor); return (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05); }); } test( "primary auth actions stay readable in normal hover and focus states across themes", { timeout: 25000 }, (t) => scenario(t, {}, async (page) => { await page.goto(`${appOrigin}/login`); const submit = page.getByRole("button", { name: "登录", exact: true }); await eventually( () => submit.isEnabled(), Boolean, "Auth configuration must finish loading", ); const results = []; for (const theme of ["light", "dark"]) { await page.emulateMedia({ colorScheme: theme }); await page.mouse.move(1, 1); await submit.blur(); await page.waitForTimeout(200); assert.ok( (await contrast(submit)) >= 4.5, `${theme} normal action text contrast`, ); await submit.hover(); await page.waitForTimeout(200); assert.ok( (await contrast(submit)) >= 4.5, `${theme} hovered action text contrast`, ); await page.mouse.move(1, 1); await submit.focus(); await page.waitForTimeout(200); assert.ok( (await contrast(submit)) >= 4.5, `${theme} focused action text contrast`, ); results.push({ theme, normalHoverFocusReadable: true }); } return { themes: results }; }), ); test( "phone registration uses fixed mainland prefix for SMS and registration", { timeout: 25000 }, (t) => scenario(t, {}, async (page) => { const writes = await authBoundary(page); await page.goto(`${appOrigin}/register`); await page.getByLabel("手机号", { exact: true }).waitFor(); await fixedPhonePrefix(page); await page.getByLabel("手机号", { exact: true }).fill("13800138000"); await page .getByRole("button", { name: "获取验证码", exact: true }) .click(); await eventually( () => fixtureRequest("requests"), (r) => r.some((e) => e.path === "/phone/code"), "Registration SMS must reach HTTP", ); const sms = (await fixtureRequest("requests")).find( (r) => r.path === "/phone/code", ); assert.equal(sms.body.phone_code, 86); await page.getByLabel("验证码 *", { exact: true }).fill("123456"); await page .getByLabel("密码 *", { exact: true }) .fill("new-local-password"); await page .getByLabel("确认密码 *", { exact: true }) .fill("new-local-password"); await page.getByRole("button", { name: "创建账户", exact: true }).click(); await page.waitForURL((u) => u.pathname === "/login"); assert.equal( String(writes.find((r) => r.path === "/register").body.phone_code), "86", ); assert.equal( writes.some((r) => r.path === "/country"), false, ); return { smsPrefix: 86, registrationPrefix: 86, countryPickerAbsent: true, }; }), ); test( "phone password recovery uses fixed mainland prefix", { timeout: 25000 }, (t) => scenario(t, {}, async (page) => { const writes = await authBoundary(page); await page.goto(`${appOrigin}/forgot-password`); await page.getByLabel("手机号", { exact: true }).waitFor(); await fixedPhonePrefix(page); await page.getByLabel("手机号", { exact: true }).fill("13800138000"); await page .getByRole("button", { name: "获取验证码", exact: true }) .click(); await eventually( () => fixtureRequest("requests"), (r) => r.some((e) => e.path === "/phone/code"), "Recovery SMS must reach HTTP", ); assert.equal( (await fixtureRequest("requests")).find((r) => r.path === "/phone/code") .body.phone_code, 86, ); await page.getByLabel("验证码 *", { exact: true }).fill("123456"); await page .getByLabel("新密码 *", { exact: true }) .fill("new-local-password"); await page .getByLabel("确认新密码 *", { exact: true }) .fill("new-local-password"); await page.getByRole("button", { name: "重置密码", exact: true }).click(); await page.waitForURL( (u) => u.pathname === "/login" && u.searchParams.get("reset") === "1", ); assert.equal( String( writes.find((r) => r.path === "/account/password_reset").body .phone_code, ), "86", ); return { smsPrefix: 86, resetPrefix: 86 }; }), ); test( "OAuth phone binding uses fixed mainland prefix after external session verification", { timeout: 25000 }, (t) => scenario(t, {}, async (page) => { const writes = await authBoundary(page); await page.goto(`${appOrigin}/oauth/bind?redirect=/security/ssh-keys`); await page.getByLabel("手机号", { exact: true }).waitFor(); await fixedPhonePrefix(page); await page.getByLabel("手机号", { exact: true }).fill("13800138000"); await page .getByRole("button", { name: "获取验证码", exact: true }) .click(); await eventually( () => fixtureRequest("requests"), (r) => r.some((e) => e.path === "/phone/code"), "Binding SMS must reach HTTP", ); assert.equal( (await fixtureRequest("requests")).find((r) => r.path === "/phone/code") .body.phone_code, 86, ); await page.getByLabel("验证码", { exact: true }).fill("123456"); await page.getByRole("checkbox").check(); await page.getByRole("button", { name: "确认绑定", exact: true }).click(); await page.waitForURL((u) => u.pathname === "/security/ssh-keys"); assert.equal( String( writes.find((r) => r.path === "/oauth/client/bind").body.phone_code, ), "86", ); return { authorizedBeforeBind: true, smsPrefix: 86, bindingPrefix: 86 }; }), ); for (const existingPhone of [false, true]) { test( `account ${existingPhone ? "change" : "new"} phone uses fixed prefix without changing stored phone or address countries`, { timeout: 30000 }, (t) => scenario(t, {}, async (page) => { const writes = await authBoundary(page, { "/account": { account: { id: 1, username: "audit-user", email: "audit@example.invalid", phone: existingPhone ? "2025550100" : "", phone_code: 1, country_id: 2, }, }, }); await login(page, "/account?tab=phone"); const panel = page.getByRole("tabpanel", { name: "手机号", exact: true, }); await panel.waitFor(); if (existingPhone) { assert.match(await panel.innerText(), /\+1 /); await panel .getByRole("button", { name: "获取验证码", exact: true }) .click(); await eventually( () => fixtureRequest("requests"), (r) => r.some((e) => e.path === "/phone/code"), "Old-phone SMS must reach HTTP", ); const oldSms = (await fixtureRequest("requests")).find( (r) => r.path === "/phone/code", ); assert.equal(oldSms.body.action, "verify"); assert.equal( "phone_code" in oldSms.body, false, "Old-phone verification must use the saved backend number", ); await panel .getByLabel("原手机验证码", { exact: true }) .fill("123456"); await panel .getByRole("button", { name: "下一步", exact: true }) .click(); } await panel .getByRole("textbox", { name: "新手机号", exact: true }) .waitFor(); await fixedPhonePrefix(page); await panel .getByRole("textbox", { name: "新手机号", exact: true }) .fill("13800138000"); await panel .getByRole("button", { name: "获取验证码", exact: true }) .click(); await eventually( () => fixtureRequest("requests"), (r) => r.some( (e) => e.path === "/phone/code" && e.body.action === "update", ), "New-phone SMS must reach HTTP", ); assert.equal( (await fixtureRequest("requests")).find( (r) => r.path === "/phone/code" && r.body.action === "update", ).body.phone_code, 86, ); await panel.getByLabel("新手机验证码", { exact: true }).fill("123456"); await panel .getByRole("button", { name: "保存变更", exact: true }) .click(); await eventually( () => writes.find((r) => r.path === "/account/phone"), Boolean, "New phone must reach update HTTP", ); assert.equal( writes.find((r) => r.path === "/account/phone").body.phone_code, 86, ); await page.getByRole("tab", { name: "资料", exact: true }).click(); const country = page.getByRole("combobox", { name: "国家/地区", exact: true, }); await country.click(); await page.getByRole("option", { name: "中国", exact: true }).waitFor(); await page.getByRole("option", { name: "美国", exact: true }).waitFor(); return { phonePrefix: 86, oldPhonePreserved: true, addressCountriesStillSelectable: true, }; }), ); } test( "account password editor shares native semantics and keyboard visibility", { timeout: 25000 }, (t) => scenario(t, {}, async (page) => { await authBoundary(page); await login(page, "/account?tab=password"); const panel = page.getByRole("tabpanel", { name: "密码", exact: true }); await panel.waitFor(); const current = panel .getByLabel("当前密码", { exact: true }) .and(panel.locator("input")); assert.equal( await current.getAttribute("autocomplete"), "current-password", ); assert.equal( await panel .getByLabel("新密码", { exact: true }) .getAttribute("autocomplete"), "new-password", ); assert.equal( await panel .getByLabel("确认新密码", { exact: true }) .getAttribute("autocomplete"), "new-password", ); await passwordKeyboard(page, current); return { nativeSemantics: true, sharedKeyboardShowHide: true }; }), );