585 lines
20 KiB
JavaScript
585 lines
20 KiB
JavaScript
/** 认证与手机号回归仅经生产页面和本地 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 };
|
|
}),
|
|
);
|