修复密码输入可访问性并统一新手机号为中国大陆区号
This commit is contained in:
1 parent
990b802553
commit
f6ab3c9087
15 files changed
+592
-362
No files matched your search
@@ -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 };
|
||||
}),
|
||||
);
|
||||
Reference in new issue
Block a user