修复密码输入可访问性并统一新手机号为中国大陆区号

This commit is contained in:
cieanyau committed 2026-11-06 04:53:45 +08:00
1 parent 990b802553
commit f6ab3c9087
15 files changed
+592 -362

No files matched your search

+452
View File
@@ -0,0 +1,452 @@
/** 认证与手机号回归仅经生产页面和本地 HTTP 边界验证,不请求真实短信或修改线上账号。 */
import assert from "node:assert/strict";
import { test } from "node:test";
import {
appOrigin,
backendOrigin,
eventually,
fixtureRequest,
login,
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 };
}),
);
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 };
}),
);