1079 lines
38 KiB
JavaScript
1079 lines
38 KiB
JavaScript
/**
|
|
* 通过真实生产构建和独立本地 HTTP 边界验收认证、SSH 与付款。
|
|
* 各业务回归文件复用同一套浏览器启动、凭证脱敏、外网拦截和结果清单协议。
|
|
*/
|
|
import assert from "node:assert/strict";
|
|
import { createDecipheriv } from "node:crypto";
|
|
import { test } from "node:test";
|
|
import {
|
|
appOrigin,
|
|
backendOrigin,
|
|
eventually,
|
|
fixtureRequest,
|
|
login,
|
|
loginAccount,
|
|
loginPassword,
|
|
scenario,
|
|
} from "./helpers/console-browser.mjs";
|
|
|
|
async function visibleKeyIds(page) {
|
|
// 从用户实际看到的表格读取 ID,而不是访问组件状态或请求客户端的内部缓存。
|
|
const rows = await page
|
|
.getByRole("row")
|
|
.filter({ hasText: /#\d+/ })
|
|
.allTextContents();
|
|
return rows.map((row) => Number(row.match(/#(\d+)/)[1]));
|
|
}
|
|
|
|
async function visibleLoginForm(page) {
|
|
// 地址跳转不代表登录页已挂载:必须能看到并操作真实表单,避免白屏被误报通过。
|
|
await page.getByLabel("账号", { exact: true }).waitFor();
|
|
const password = page.getByLabel("密码", { exact: true });
|
|
await password.waitFor();
|
|
assert.equal(await password.getAttribute("type"), "password");
|
|
const submit = page.getByRole("button", { name: "登录", exact: true });
|
|
await submit.waitFor();
|
|
assert.equal(await submit.isEnabled(), true);
|
|
}
|
|
|
|
for (const destination of [
|
|
"/",
|
|
"/security/ssh-keys?m=7#details",
|
|
"/servers/server?id=5",
|
|
"/products/123?edit_position=0",
|
|
]) {
|
|
test(
|
|
`auth guard renders the login form after opening ${destination}`,
|
|
{ timeout: 20000 },
|
|
(t) =>
|
|
scenario(t, {}, async (page) => {
|
|
// 覆盖首次打开根入口、列表与嵌套详情;不能只检查地址而漏掉登录表单的挂载死锁。
|
|
await page.goto(`${appOrigin}${destination}`);
|
|
await page.waitForURL((url) => url.pathname === "/login");
|
|
await visibleLoginForm(page);
|
|
assert.equal(
|
|
new URL(page.url()).searchParams.get("redirect"),
|
|
destination === "/" ? null : destination,
|
|
);
|
|
assert.equal(
|
|
await page.evaluate(() => localStorage.getItem("jwt")),
|
|
null,
|
|
);
|
|
const requests = await fixtureRequest("requests");
|
|
assert.equal(
|
|
requests.some((request) =>
|
|
["/ssh_key", "/host/5", "/menu", "/account"].includes(request.path),
|
|
),
|
|
false,
|
|
"The redirected login page must not mount the protected page or layout",
|
|
);
|
|
return {
|
|
loginFormVisible: true,
|
|
destinationPreserved: true,
|
|
protectedApiNotCalled: true,
|
|
};
|
|
}),
|
|
);
|
|
}
|
|
|
|
test(
|
|
"public catalog switches from guest to member layout after real login",
|
|
{ timeout: 25000 },
|
|
(t) =>
|
|
scenario(t, {}, async (page) => {
|
|
// 分类是公开 HTTP 边界;空分类契约足以验证入口布局,不引入测试专用产品参数。
|
|
await page
|
|
.context()
|
|
.route(`${backendOrigin}/console/v1/product/group/*`, (route) =>
|
|
route.fulfill({
|
|
json: { status: 200, msg: "", data: { list: [] } },
|
|
headers: { "Access-Control-Allow-Origin": "*" },
|
|
}),
|
|
);
|
|
await page.goto(`${appOrigin}/products`);
|
|
await page
|
|
.getByRole("heading", { name: "订购产品", exact: true })
|
|
.waitFor();
|
|
await page
|
|
.getByRole("navigation", { name: "产品导航", exact: true })
|
|
.waitFor();
|
|
assert.equal(
|
|
await page.getByRole("navigation", { name: "会员中心导航" }).count(),
|
|
0,
|
|
);
|
|
assert.equal(
|
|
(await fixtureRequest("requests")).some((request) =>
|
|
["/index", "/account", "/menu"].includes(request.path),
|
|
),
|
|
false,
|
|
);
|
|
await page.getByRole("link", { name: "登录", exact: true }).click();
|
|
await visibleLoginForm(page);
|
|
await page.getByLabel("账号", { exact: true }).fill(loginAccount);
|
|
await page.getByLabel("密码", { exact: true }).fill(loginPassword);
|
|
await page.getByRole("button", { name: "登录", exact: true }).click();
|
|
await page.waitForURL((url) => url.pathname === "/products");
|
|
await page.getByRole("navigation", { name: "会员中心导航" }).waitFor();
|
|
await page
|
|
.getByRole("heading", { name: "订购产品", exact: true })
|
|
.waitFor();
|
|
assert.equal(
|
|
await page
|
|
.getByRole("navigation", { name: "产品导航", exact: true })
|
|
.count(),
|
|
0,
|
|
);
|
|
return { guestCatalogVisible: true, memberLayoutAfterLogin: true };
|
|
}),
|
|
);
|
|
|
|
test(
|
|
"password login uses the deployed encrypted field contract",
|
|
{ timeout: 25000 },
|
|
(t) =>
|
|
scenario(t, {}, async (page) => {
|
|
// 从受保护入口跳出的表单继续登录,不能用 helper 重新打开 /login 掩盖首次导航缺陷。
|
|
await page.goto(`${appOrigin}/security/ssh-keys?m=1`);
|
|
await page.waitForURL((url) => url.pathname === "/login");
|
|
await visibleLoginForm(page);
|
|
await page.getByLabel("账号", { exact: true }).fill(loginAccount);
|
|
await page.getByLabel("密码", { exact: true }).fill(loginPassword);
|
|
await page.getByRole("button", { name: "登录", exact: true }).click();
|
|
await page
|
|
.getByRole("heading", { name: "SSH 密钥", exact: true })
|
|
.waitFor();
|
|
const requests = await fixtureRequest("requests");
|
|
const loginRequest = requests.find(
|
|
(request) => request.path === "/login",
|
|
);
|
|
assert.equal(loginRequest.method, "POST");
|
|
assert.equal(loginRequest.body.type, "password");
|
|
assert.equal(loginRequest.body.account, loginAccount);
|
|
assert.equal(loginRequest.body.remember_password, "0");
|
|
assert.equal(
|
|
"phone_code" in loginRequest.body,
|
|
false,
|
|
"Email accounts must not carry a phone prefix",
|
|
);
|
|
assert.notEqual(loginRequest.body.password, loginPassword);
|
|
// 固定 AES 参数是既有接口协议;这里核对发送字段,不把它误当安全的密码存储方案。
|
|
const decipher = createDecipheriv(
|
|
"aes-128-cbc",
|
|
Buffer.from("idcsmart.finance"),
|
|
Buffer.from("9311019310287172"),
|
|
);
|
|
const decrypted = Buffer.concat([
|
|
decipher.update(Buffer.from(loginRequest.body.password, "base64")),
|
|
decipher.final(),
|
|
]).toString();
|
|
assert.equal(decrypted, loginPassword);
|
|
assert.equal(new URL(page.url()).searchParams.get("m"), "1");
|
|
assert.equal(
|
|
await page.evaluate(() => localStorage.getItem("jwt")),
|
|
"e2e-session-token",
|
|
);
|
|
return {
|
|
passwordMasked: true,
|
|
encryptedContractValid: true,
|
|
destinationPreserved: true,
|
|
};
|
|
}),
|
|
);
|
|
|
|
test(
|
|
"SMS only login honors site settings and the verification countdown contract",
|
|
{ timeout: 30000 },
|
|
(t) =>
|
|
scenario(t, { loginMode: "sms-only" }, async (page) => {
|
|
// 外部浏览器时钟推进验证码倒计时,不向产品增加测试时间开关。
|
|
await page.clock.install();
|
|
await page.goto(
|
|
`${appOrigin}/login?redirect=${encodeURIComponent("/security/ssh-keys")}`,
|
|
);
|
|
const codeMode = page.getByRole("button", {
|
|
name: "验证码登录",
|
|
exact: true,
|
|
});
|
|
await eventually(
|
|
() => codeMode.getAttribute("aria-pressed"),
|
|
(pressed) => pressed === "true",
|
|
"SMS-only settings must select the available login method",
|
|
);
|
|
assert.equal(
|
|
await page
|
|
.getByRole("button", { name: "密码登录", exact: true })
|
|
.isDisabled(),
|
|
true,
|
|
);
|
|
assert.equal(await page.getByLabel("密码", { exact: true }).count(), 0);
|
|
const accountInput = page.getByLabel("账号", { exact: true });
|
|
await accountInput.fill(loginAccount);
|
|
await page
|
|
.getByRole("button", { name: "获取验证码", exact: true })
|
|
.click();
|
|
await page.getByRole("alert").waitFor();
|
|
assert.equal(
|
|
(await fixtureRequest("requests")).some(
|
|
(request) => request.path === "/phone/code",
|
|
),
|
|
false,
|
|
"An email account must not enter the phone verification protocol",
|
|
);
|
|
await accountInput.fill("13800138000");
|
|
await page
|
|
.getByRole("button", { name: "获取验证码", exact: true })
|
|
.click();
|
|
await page.getByRole("button", { name: "60s", exact: true }).waitFor();
|
|
assert.equal(
|
|
await page
|
|
.getByRole("button", { name: "60s", exact: true })
|
|
.isDisabled(),
|
|
true,
|
|
);
|
|
await page.clock.runFor(1000);
|
|
assert.equal(
|
|
await page
|
|
.getByRole("button", { name: "59s", exact: true })
|
|
.isDisabled(),
|
|
true,
|
|
);
|
|
await page.clock.runFor(59000);
|
|
assert.equal(
|
|
await page
|
|
.getByRole("button", { name: "获取验证码", exact: true })
|
|
.isEnabled(),
|
|
true,
|
|
);
|
|
const beforeLogin = await fixtureRequest("requests");
|
|
const sends = beforeLogin.filter(
|
|
(request) => request.path === "/phone/code",
|
|
);
|
|
assert.equal(
|
|
sends.length,
|
|
1,
|
|
"The countdown must not send another code automatically",
|
|
);
|
|
assert.deepEqual(sends[0].body, {
|
|
action: "login",
|
|
captcha: "",
|
|
token: "",
|
|
phone_code: 86,
|
|
phone: "13800138000",
|
|
});
|
|
await page.getByLabel("验证码", { exact: true }).fill("246810");
|
|
await page.getByRole("button", { name: "登录", exact: true }).click();
|
|
await page.waitForURL((url) => url.pathname === "/security/ssh-keys");
|
|
await eventually(
|
|
() => visibleKeyIds(page),
|
|
(ids) => ids.length === 10,
|
|
"SMS login must open the authenticated SSH page",
|
|
);
|
|
const loginRequest = (await fixtureRequest("requests")).find(
|
|
(request) => request.path === "/login",
|
|
);
|
|
assert.deepEqual(loginRequest.body, {
|
|
type: "code",
|
|
account: "13800138000",
|
|
phone_code: "86",
|
|
code: "246810",
|
|
password: "",
|
|
remember_password: "0",
|
|
captcha: "",
|
|
token: "",
|
|
});
|
|
return {
|
|
disabledPasswordModeRespected: true,
|
|
emailCannotRequestSms: true,
|
|
countdownSeconds: 60,
|
|
countdownPreventsDuplicateSend: true,
|
|
smsLoginContractValid: true,
|
|
};
|
|
}),
|
|
);
|
|
|
|
test(
|
|
"disabled SMS login cannot override the available password method",
|
|
{ timeout: 25000 },
|
|
(t) =>
|
|
scenario(t, { loginMode: "password-only" }, async (page) => {
|
|
await page.goto(
|
|
`${appOrigin}/login?redirect=${encodeURIComponent("/security/ssh-keys")}`,
|
|
);
|
|
const passwordMode = page.getByRole("button", {
|
|
name: "密码登录",
|
|
exact: true,
|
|
});
|
|
await eventually(
|
|
() => passwordMode.getAttribute("aria-pressed"),
|
|
(pressed) => pressed === "true",
|
|
"A disabled configured default must fall back to the enabled password method",
|
|
);
|
|
assert.equal(
|
|
await page
|
|
.getByRole("button", { name: "验证码登录", exact: true })
|
|
.isDisabled(),
|
|
true,
|
|
);
|
|
assert.equal(await page.getByLabel("验证码", { exact: true }).count(), 0);
|
|
await page.getByLabel("账号", { exact: true }).fill(loginAccount);
|
|
await page.getByLabel("密码", { exact: true }).fill(loginPassword);
|
|
await page.getByRole("button", { name: "登录", exact: true }).click();
|
|
await page.waitForURL((url) => url.pathname === "/security/ssh-keys");
|
|
await eventually(
|
|
() => visibleKeyIds(page),
|
|
(ids) => ids.length === 10,
|
|
"The available password method must still permit login",
|
|
);
|
|
const requests = await fixtureRequest("requests");
|
|
assert.equal(
|
|
requests.some((request) => request.path === "/phone/code"),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
requests.find((request) => request.path === "/login").body.type,
|
|
"password",
|
|
);
|
|
return {
|
|
disabledSmsCannotBecomeInitialMode: true,
|
|
passwordLoginAvailable: true,
|
|
noSmsSent: true,
|
|
};
|
|
}),
|
|
);
|
|
|
|
test(
|
|
"an authoritative empty menu does not expose fallback links",
|
|
{ timeout: 25000 },
|
|
(t) =>
|
|
scenario(t, { emptyMenu: true }, async (page) => {
|
|
const menu = page.waitForResponse(
|
|
(response) => new URL(response.url()).pathname === "/console/v1/menu",
|
|
);
|
|
await login(page);
|
|
await menu;
|
|
await page
|
|
.getByRole("heading", { name: "SSH 密钥", exact: true })
|
|
.waitFor();
|
|
const navigation = page.getByRole("navigation", { name: "会员中心导航" });
|
|
await navigation.waitFor();
|
|
assert.equal(
|
|
await navigation
|
|
.getByRole("link", { name: "SSH 密钥", exact: true })
|
|
.count(),
|
|
0,
|
|
);
|
|
assert.equal(
|
|
await navigation
|
|
.getByRole("link", { name: "订单", exact: true })
|
|
.count(),
|
|
0,
|
|
);
|
|
return { backendControlledLinksHidden: true, directPageUsable: true };
|
|
}),
|
|
);
|
|
|
|
test(
|
|
"SSH search paginates 25 matches without repeating all rows",
|
|
{ timeout: 30000 },
|
|
(t) =>
|
|
scenario(t, {}, async (page) => {
|
|
await login(page);
|
|
await eventually(
|
|
() => visibleKeyIds(page),
|
|
(ids) => ids.length === 10,
|
|
"Initial SSH page must contain ten rows",
|
|
);
|
|
await page.getByRole("button").filter({ hasText: /^2$/ }).click();
|
|
const ordinarySecond = await eventually(
|
|
() => visibleKeyIds(page),
|
|
(ids) => ids.length === 10 && ids[0] === 11,
|
|
"Ordinary backend pagination must not slice page two a second time",
|
|
);
|
|
assert.deepEqual(
|
|
ordinarySecond,
|
|
Array.from({ length: 10 }, (_, index) => index + 11),
|
|
);
|
|
// 普通后端分页与前端搜索分页分别验证,防止二次切片或每页重复显示全部匹配项。
|
|
await page.getByRole("textbox", { name: "搜索 SSH 密钥" }).fill("audit");
|
|
await page.getByRole("button", { name: "搜索", exact: true }).click();
|
|
const first = await eventually(
|
|
() => visibleKeyIds(page),
|
|
(ids) => ids.length === 10 && ids[0] === 1,
|
|
"Search page one must contain ten rows",
|
|
);
|
|
assert.deepEqual(
|
|
first,
|
|
Array.from({ length: 10 }, (_, index) => index + 1),
|
|
);
|
|
await page.getByRole("button").filter({ hasText: /^2$/ }).click();
|
|
const second = await eventually(
|
|
() => visibleKeyIds(page),
|
|
(ids) => ids.length === 10 && ids[0] === 11,
|
|
"Search page two must contain the next ten rows",
|
|
);
|
|
assert.deepEqual(
|
|
second,
|
|
Array.from({ length: 10 }, (_, index) => index + 11),
|
|
);
|
|
await page.getByRole("button").filter({ hasText: /^3$/ }).click();
|
|
const third = await eventually(
|
|
() => visibleKeyIds(page),
|
|
(ids) => ids.length === 5,
|
|
"Search page three must contain the remaining five rows",
|
|
);
|
|
assert.deepEqual(third, [21, 22, 23, 24, 25]);
|
|
await page.getByRole("textbox", { name: "搜索 SSH 密钥" }).fill("other");
|
|
await page.getByRole("button", { name: "搜索", exact: true }).click();
|
|
const changedSearch = await eventually(
|
|
() => visibleKeyIds(page),
|
|
(ids) => ids.length === 3 && ids[0] === 26,
|
|
"A new search from the last page must reset to page one",
|
|
);
|
|
assert.deepEqual(changedSearch, [26, 27, 28]);
|
|
await page.getByRole("textbox", { name: "搜索 SSH 密钥" }).fill("audit");
|
|
await page.getByRole("button", { name: "搜索", exact: true }).click();
|
|
await eventually(
|
|
() => visibleKeyIds(page),
|
|
(ids) => ids.length === 10 && ids[0] === 1,
|
|
"Returning to a previous search must start on its first page",
|
|
);
|
|
await page.getByRole("button").filter({ hasText: /^3$/ }).click();
|
|
await eventually(
|
|
() => visibleKeyIds(page),
|
|
(ids) => ids.length === 5 && ids[0] === 21,
|
|
"The final search page must remain reachable after switching filters",
|
|
);
|
|
await page.getByRole("button", { name: "清除筛选", exact: true }).click();
|
|
await eventually(
|
|
() => visibleKeyIds(page),
|
|
(ids) => ids.length === 10 && ids[0] === 1,
|
|
"Clearing search must return to page one",
|
|
);
|
|
const firstRow = page.getByRole("row").filter({
|
|
has: page.getByText("audit-01", { exact: true }),
|
|
});
|
|
await firstRow.getByRole("button", { name: "编辑", exact: true }).click();
|
|
const editor = page.getByRole("dialog", {
|
|
name: "编辑密钥",
|
|
exact: true,
|
|
});
|
|
await editor.waitFor();
|
|
assert.equal(
|
|
await editor.getByRole("textbox", { name: /^名称/ }).inputValue(),
|
|
"audit-01",
|
|
);
|
|
await editor.getByRole("button", { name: "取消", exact: true }).click();
|
|
await editor.waitFor({ state: "hidden" });
|
|
return {
|
|
searchedRows: 25,
|
|
pageSizes: [first.length, second.length, third.length],
|
|
distinctRows: new Set([...first, ...second, ...third]).size,
|
|
ordinaryBackendPageCorrect: true,
|
|
newSearchResetsPage: true,
|
|
clearResetsPage: true,
|
|
settledRowsEditable: true,
|
|
};
|
|
}),
|
|
);
|
|
|
|
test(
|
|
"SSH create edit and delete return the final page to a valid page",
|
|
{ timeout: 30000 },
|
|
(t) =>
|
|
scenario(t, { sshLastPageSingleRow: true }, async (page) => {
|
|
// 通过真实新增/编辑/删除制造末页消失,验证列表能回到合法页,而不只验证按钮关闭。
|
|
await login(page);
|
|
await eventually(
|
|
() => visibleKeyIds(page),
|
|
(ids) => ids.length === 10,
|
|
"SSH initial page must finish loading",
|
|
);
|
|
await page.getByRole("button", { name: "添加密钥", exact: true }).click();
|
|
const createDialog = page.getByRole("dialog", {
|
|
name: "添加密钥",
|
|
exact: true,
|
|
});
|
|
await createDialog.waitFor();
|
|
const createdPublicKey =
|
|
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICreateFixturePublicKey e2e-create";
|
|
await createDialog
|
|
.getByRole("textbox", { name: /^名称/ })
|
|
.fill("e2e-new");
|
|
await createDialog
|
|
.getByRole("textbox", { name: /^公钥内容/ })
|
|
.fill(createdPublicKey);
|
|
await createDialog
|
|
.getByRole("button", { name: "保存", exact: true })
|
|
.click();
|
|
await createDialog.waitFor({ state: "hidden" });
|
|
await page.getByRole("button").filter({ hasText: /^2$/ }).click();
|
|
await eventually(
|
|
() => visibleKeyIds(page),
|
|
(ids) => ids.length === 2 && ids[1] === 12,
|
|
"The newly created key must be readable from the backend list",
|
|
);
|
|
const createdRow = page
|
|
.getByRole("row")
|
|
.filter({ has: page.getByText("e2e-new", { exact: true }) });
|
|
assert.ok((await createdRow.textContent()).includes(createdPublicKey));
|
|
await createdRow
|
|
.getByRole("button", { name: "编辑", exact: true })
|
|
.click();
|
|
const editDialog = page.getByRole("dialog", {
|
|
name: "编辑密钥",
|
|
exact: true,
|
|
});
|
|
await editDialog.waitFor();
|
|
assert.equal(
|
|
await editDialog.getByRole("textbox", { name: /^名称/ }).inputValue(),
|
|
"e2e-new",
|
|
);
|
|
assert.equal(
|
|
await editDialog
|
|
.getByRole("textbox", { name: /^公钥内容/ })
|
|
.inputValue(),
|
|
createdPublicKey,
|
|
);
|
|
const editedPublicKey =
|
|
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEditedFixturePublicKey e2e-edit";
|
|
await editDialog
|
|
.getByRole("textbox", { name: /^名称/ })
|
|
.fill("e2e-edited");
|
|
await editDialog
|
|
.getByRole("textbox", { name: /^公钥内容/ })
|
|
.fill(editedPublicKey);
|
|
await editDialog
|
|
.getByRole("button", { name: "保存", exact: true })
|
|
.click();
|
|
await editDialog.waitFor({ state: "hidden" });
|
|
const editedRow = page
|
|
.getByRole("row")
|
|
.filter({ has: page.getByText("e2e-edited", { exact: true }) });
|
|
await editedRow.waitFor();
|
|
assert.ok((await editedRow.textContent()).includes(editedPublicKey));
|
|
await editedRow
|
|
.getByRole("button", { name: "删除密钥", exact: true })
|
|
.click();
|
|
await eventually(
|
|
() => visibleKeyIds(page),
|
|
(ids) => ids.length === 1 && ids[0] === 11,
|
|
"Deleting the created key must preserve the remaining last-page row",
|
|
);
|
|
await page
|
|
.getByRole("row")
|
|
.filter({ has: page.getByText("audit-11", { exact: true }) })
|
|
.getByRole("button", { name: "删除密钥", exact: true })
|
|
.click();
|
|
await eventually(
|
|
() => visibleKeyIds(page),
|
|
(ids) => ids.length === 10 && ids[0] === 1,
|
|
"Deleting the last row on the final page must return to the valid first page",
|
|
);
|
|
assert.equal(
|
|
await page.getByRole("button").filter({ hasText: /^2$/ }).count(),
|
|
0,
|
|
);
|
|
const writes = (await fixtureRequest("requests")).filter(
|
|
(request) =>
|
|
["POST", "PUT", "DELETE"].includes(request.method) &&
|
|
request.path.startsWith("/ssh_key"),
|
|
);
|
|
assert.deepEqual(
|
|
writes.map(({ method, path, body }) => ({
|
|
method,
|
|
path,
|
|
...(body ? { body } : {}),
|
|
})),
|
|
[
|
|
{
|
|
method: "POST",
|
|
path: "/ssh_key",
|
|
body: { name: "e2e-new", public_key: createdPublicKey },
|
|
},
|
|
{
|
|
method: "PUT",
|
|
path: "/ssh_key/12",
|
|
body: { id: 12, name: "e2e-edited", public_key: editedPublicKey },
|
|
},
|
|
{ method: "DELETE", path: "/ssh_key/12" },
|
|
{ method: "DELETE", path: "/ssh_key/11" },
|
|
],
|
|
);
|
|
return {
|
|
createPersisted: true,
|
|
editPersisted: true,
|
|
deletedRows: 2,
|
|
emptyLastPageRecovered: true,
|
|
finalVisibleRows: 10,
|
|
};
|
|
}),
|
|
);
|
|
|
|
test(
|
|
"the default request deadline aborts an unfinished JSON body and allows retry",
|
|
{ timeout: 25000 },
|
|
(t) =>
|
|
scenario(t, { hangSshBody: true }, async (page) => {
|
|
// HTTP 头已返回但 JSON 体挂起,证明请求截止时间覆盖了解码阶段而非只覆盖 fetch。
|
|
await page.clock.install();
|
|
const headers = page.waitForResponse(
|
|
(response) =>
|
|
new URL(response.url()).pathname === "/console/v1/ssh_key",
|
|
);
|
|
await login(page);
|
|
assert.equal((await headers).status(), 200);
|
|
await page
|
|
.getByRole("heading", { name: "SSH 密钥", exact: true })
|
|
.waitFor();
|
|
await page.clock.runFor(31000);
|
|
await page.getByRole("alert").filter({ hasText: /超时/ }).waitFor();
|
|
const retry = page.getByRole("button", { name: "重试", exact: true });
|
|
await retry.waitFor();
|
|
assert.equal(await retry.isEnabled(), true);
|
|
await fixtureRequest("options", { hangSshBody: false });
|
|
await retry.click();
|
|
await eventually(
|
|
() => visibleKeyIds(page),
|
|
(ids) => ids.length === 10,
|
|
"A timed-out response body must release loading so retry can display its rows",
|
|
);
|
|
assert.equal(
|
|
await page.getByRole("alert").filter({ hasText: /超时/ }).count(),
|
|
0,
|
|
);
|
|
return {
|
|
headersReceived: true,
|
|
unfinishedBodyBoundedByDefaultDeadline: true,
|
|
loadingReleased: true,
|
|
retryRecoveredRows: 10,
|
|
};
|
|
}),
|
|
);
|
|
|
|
test(
|
|
"logout clears local access while its API request remains pending",
|
|
{ timeout: 25000 },
|
|
(t) =>
|
|
scenario(t, { hangLogout: true }, async (page) => {
|
|
// 后端退出故意不返回;本地会话仍须立即撤销,不能把安全退出绑在网络成功上。
|
|
await login(page);
|
|
await page.getByRole("button", { name: "用户菜单", exact: true }).click();
|
|
await page
|
|
.getByRole("menuitem", { name: "退出登录", exact: true })
|
|
.click();
|
|
await page.waitForURL((url) => url.pathname === "/login", {
|
|
timeout: 1500,
|
|
});
|
|
await visibleLoginForm(page);
|
|
assert.equal(
|
|
await page.evaluate(() => localStorage.getItem("jwt")),
|
|
null,
|
|
);
|
|
const requests = await eventually(
|
|
() => fixtureRequest("requests"),
|
|
(entries) => entries.some((entry) => entry.path === "/logout"),
|
|
"Logout should invalidate the server session in the background",
|
|
);
|
|
assert.equal(
|
|
requests.find((request) => request.path === "/logout").authorization,
|
|
"Bearer e2e-session-token",
|
|
);
|
|
await page.goto(`${appOrigin}/security/ssh-keys`);
|
|
await page.waitForURL((url) => url.pathname === "/login");
|
|
await visibleLoginForm(page);
|
|
return {
|
|
localSessionClearedWithoutResponse: true,
|
|
nextProtectedNavigationBlocked: true,
|
|
};
|
|
}),
|
|
);
|
|
|
|
test(
|
|
"an envelope 401 clears the session and returns to login",
|
|
{ timeout: 25000 },
|
|
(t) =>
|
|
scenario(t, { expiredSession: true }, async (page) => {
|
|
await page.goto(
|
|
`${appOrigin}/login?redirect=${encodeURIComponent("/security/ssh-keys")}`,
|
|
);
|
|
await page.getByLabel("账号", { exact: true }).fill(loginAccount);
|
|
await page.getByLabel("密码", { exact: true }).fill(loginPassword);
|
|
await page.getByRole("button", { name: "登录", exact: true }).click();
|
|
await eventually(
|
|
() => fixtureRequest("requests"),
|
|
(entries) => entries.some((entry) => entry.path === "/ssh_key"),
|
|
"Authenticated page must request its data",
|
|
);
|
|
await page.waitForURL((url) => url.pathname === "/login");
|
|
await visibleLoginForm(page);
|
|
await eventually(
|
|
() => page.evaluate(() => localStorage.getItem("jwt")),
|
|
(jwt) => jwt === null,
|
|
"Expired token must be removed",
|
|
);
|
|
return { businessStatus401Enforced: true, expiredSessionCleared: true };
|
|
}),
|
|
);
|
|
|
|
for (const [name, redirect] of [
|
|
// 三种危险返回地址经真实登录入口验证,不能跳出本站 origin。
|
|
["protocol relative", "//external.invalid/steal"],
|
|
["encoded slash", "/%2fexternal.invalid/steal"],
|
|
["backslash", "/\\external.invalid/steal"],
|
|
]) {
|
|
test(`login rejects a ${name} return destination`, { timeout: 25000 }, (t) =>
|
|
scenario(t, {}, async (page) => {
|
|
await login(page, redirect);
|
|
assert.equal(new URL(page.url()).origin, appOrigin);
|
|
assert.equal(new URL(page.url()).pathname, "/");
|
|
await page.getByRole("heading", { name: /audit-user/ }).waitFor();
|
|
return { unsafeDestinationRejected: true, localHomeReached: true };
|
|
}),
|
|
);
|
|
}
|
|
|
|
test(
|
|
"payment handles the deployed top level Paid response",
|
|
{ timeout: 30000 },
|
|
(t) =>
|
|
scenario(t, {}, async (page) => {
|
|
await login(page, "/orders/order/pay?id=42");
|
|
await page.getByRole("button", { name: "确认支付", exact: true }).click();
|
|
await page.waitForURL(
|
|
(url) =>
|
|
url.pathname === "/orders/order/paid" &&
|
|
url.searchParams.get("id") === "42",
|
|
);
|
|
await page.getByText("支付成功", { exact: true }).waitFor();
|
|
const requests = await fixtureRequest("requests");
|
|
const payment = requests.find(
|
|
(request) => request.path === "/pay" && request.method === "POST",
|
|
);
|
|
assert.deepEqual(payment.body, { id: 42, gateway: "alipay" });
|
|
return { paidEnvelopeAccepted: true, receiptReached: true, orderId: 42 };
|
|
}),
|
|
);
|
|
|
|
test(
|
|
"a stalled payment status request cannot swallow the payment deadline",
|
|
{ timeout: 30000 },
|
|
(t) =>
|
|
scenario(t, { hangPaymentStatus: true }, async (page) => {
|
|
// 支付状态接口永久挂起时,独立五分钟期限仍应终止支付等待。
|
|
await login(page, "/orders/order/pay?id=42");
|
|
await page.clock.install();
|
|
await page.getByRole("button", { name: "确认支付", exact: true }).click();
|
|
await page.getByText("本地支付渠道", { exact: true }).waitFor();
|
|
await page.clock.runFor(2000);
|
|
await eventually(
|
|
() => fixtureRequest("requests"),
|
|
(entries) => entries.some((entry) => entry.path === "/pay/42/status"),
|
|
"Payment must start checking its status",
|
|
);
|
|
await page.clock.runFor(301000);
|
|
await page.getByText(/支付超时/).waitFor();
|
|
const requests = await fixtureRequest("requests");
|
|
assert.equal(
|
|
requests.filter(
|
|
(request) => request.path === "/pay" && request.method === "POST",
|
|
).length,
|
|
1,
|
|
"A timeout must not submit a second payment",
|
|
);
|
|
assert.equal(
|
|
await page.evaluate(() => localStorage.getItem("jwt")),
|
|
"e2e-session-token",
|
|
);
|
|
return {
|
|
independentDeadlineEnforced: true,
|
|
noDuplicatePayment: true,
|
|
sessionRetained: true,
|
|
};
|
|
}),
|
|
);
|
|
|
|
test(
|
|
"a legacy temporary credential is stripped and reaches the verified home",
|
|
{ timeout: 25000 },
|
|
(t) =>
|
|
scenario(t, {}, async (page) => {
|
|
await page.goto(`${appOrigin}/home.htm?queryParam=e2e-session-token`);
|
|
await page.waitForURL(
|
|
(url) => url.pathname === "/" && !url.searchParams.has("queryParam"),
|
|
);
|
|
await page.getByRole("heading", { name: /audit-user/ }).waitFor();
|
|
assert.equal(page.url().includes("e2e-session-token"), false);
|
|
assert.equal(
|
|
await page.evaluate(() => localStorage.getItem("jwt")),
|
|
"e2e-session-token",
|
|
);
|
|
const requests = await fixtureRequest("requests");
|
|
const verification = requests.find(
|
|
(request) => request.path === "/index",
|
|
);
|
|
assert.equal(verification.authorization, "Bearer e2e-session-token");
|
|
assert.equal(
|
|
verification.referrer,
|
|
null,
|
|
"Temporary credentials must not leak in the verification referrer",
|
|
);
|
|
assert.equal(
|
|
requests.some((request) => request.path === "/login"),
|
|
false,
|
|
);
|
|
return {
|
|
temporaryCredentialVerified: true,
|
|
credentialRemovedFromVisibleUrl: true,
|
|
referrerOmitted: true,
|
|
homeReached: true,
|
|
};
|
|
}),
|
|
);
|
|
|
|
test(
|
|
"an existing session entering the legacy route is verified again",
|
|
{ timeout: 25000 },
|
|
(t) =>
|
|
scenario(t, {}, async (page) => {
|
|
await login(page);
|
|
await eventually(
|
|
() => visibleKeyIds(page),
|
|
(ids) => ids.length === 10,
|
|
"The existing session must finish opening its protected page",
|
|
);
|
|
const before = await fixtureRequest("requests");
|
|
const verificationCount = before.filter(
|
|
(request) => request.path === "/index",
|
|
).length;
|
|
await page.goto(`${appOrigin}/home.htm`);
|
|
await page.waitForURL((url) => url.pathname === "/");
|
|
await page.getByRole("heading", { name: /audit-user/ }).waitFor();
|
|
const requests = await fixtureRequest("requests");
|
|
assert.ok(
|
|
requests.filter((request) => request.path === "/index").length >
|
|
verificationCount,
|
|
);
|
|
assert.equal(
|
|
requests.filter((request) => request.path === "/login").length,
|
|
1,
|
|
);
|
|
assert.equal(new URL(page.url()).searchParams.has("queryParam"), false);
|
|
return {
|
|
existingSessionReverified: true,
|
|
noSecondPasswordLogin: true,
|
|
homeReached: true,
|
|
};
|
|
}),
|
|
);
|
|
|
|
test(
|
|
"a rejected legacy credential stays out of the error URL and can be retried",
|
|
{ timeout: 25000 },
|
|
(t) =>
|
|
scenario(t, { expiredLegacy: true }, async (page) => {
|
|
// 临时凭证首次拒绝后仍留在内存供原页重试,但不得重新进入地址栏或持久化存储。
|
|
await page.goto(`${appOrigin}/home.htm?queryParam=e2e-session-token`);
|
|
await page
|
|
.getByRole("heading", { name: "暂时无法进入会员中心", exact: true })
|
|
.waitFor();
|
|
await page
|
|
.getByRole("link", { name: "使用账号登录", exact: true })
|
|
.waitFor();
|
|
const url = new URL(page.url());
|
|
assert.equal(url.pathname, "/home.htm");
|
|
assert.equal(url.searchParams.has("queryParam"), false);
|
|
assert.equal(page.url().includes("e2e-session-token"), false);
|
|
assert.equal(
|
|
await page.evaluate(() => localStorage.getItem("jwt")),
|
|
null,
|
|
);
|
|
const failedRequests = await fixtureRequest("requests");
|
|
await fixtureRequest("options", { expiredLegacy: false });
|
|
await page.getByRole("button", { name: "重新验证", exact: true }).click();
|
|
await page.waitForURL(
|
|
(url) => url.pathname === "/" && !url.searchParams.has("queryParam"),
|
|
);
|
|
await page.getByRole("heading", { name: /audit-user/ }).waitFor();
|
|
assert.equal(page.url().includes("e2e-session-token"), false);
|
|
assert.equal(
|
|
await page.evaluate(() => localStorage.getItem("jwt")),
|
|
"e2e-session-token",
|
|
);
|
|
const requests = await fixtureRequest("requests");
|
|
const retriedVerification = requests
|
|
.slice(failedRequests.length)
|
|
.find((request) => request.path === "/index");
|
|
assert.ok(
|
|
retriedVerification,
|
|
"Retry must verify the pending credential again",
|
|
);
|
|
assert.equal(
|
|
retriedVerification.authorization,
|
|
"Bearer e2e-session-token",
|
|
);
|
|
assert.equal(retriedVerification.referrer, null);
|
|
assert.equal(
|
|
requests.some((request) => request.path === "/login"),
|
|
false,
|
|
"Retry must recover through verification without a new password login",
|
|
);
|
|
return {
|
|
rejectedCredentialNotPersisted: true,
|
|
credentialRemovedFromErrorUrl: true,
|
|
accountLoginAvailable: true,
|
|
retryVerificationRecovers: true,
|
|
recoveredWithoutNewLogin: true,
|
|
};
|
|
}),
|
|
);
|
|
|
|
test(
|
|
"logging out of an open payment page cannot reinitialize its payment data",
|
|
{ timeout: 25000 },
|
|
(t) =>
|
|
scenario(t, { hangLogout: true }, async (page) => {
|
|
await login(page, "/orders/order/pay?id=42");
|
|
await eventually(
|
|
() =>
|
|
page
|
|
.getByRole("button", { name: "确认支付", exact: true })
|
|
.isEnabled(),
|
|
Boolean,
|
|
"Payment initialization must settle before logout",
|
|
);
|
|
const baseline = (await fixtureRequest("requests")).length;
|
|
await page.getByRole("button", { name: "用户菜单", exact: true }).click();
|
|
await page
|
|
.getByRole("menuitem", { name: "退出登录", exact: true })
|
|
.click();
|
|
await page.waitForURL((url) => url.pathname === "/login", {
|
|
timeout: 1500,
|
|
});
|
|
await visibleLoginForm(page);
|
|
assert.equal(
|
|
await page.evaluate(() => localStorage.getItem("jwt")),
|
|
null,
|
|
);
|
|
const afterLogout = (await fixtureRequest("requests")).slice(baseline);
|
|
const paymentPaths = new Set([
|
|
"/order/42",
|
|
"/account",
|
|
"/gateway",
|
|
"/voucher/pay",
|
|
"/pay",
|
|
]);
|
|
assert.deepEqual(
|
|
afterLogout.filter((request) => paymentPaths.has(request.path)),
|
|
[],
|
|
"Identity invalidation must not start payment initialization with stale credentials",
|
|
);
|
|
return {
|
|
logoutRevokesPaymentPage: true,
|
|
noPostLogoutPaymentInitialization: true,
|
|
};
|
|
}),
|
|
);
|
|
|
|
test(
|
|
"an expired identity on payment confirmation cannot restart payment initialization",
|
|
{ timeout: 25000 },
|
|
(t) =>
|
|
scenario(t, {}, async (page) => {
|
|
await login(page, "/orders/order/pay?id=42");
|
|
await eventually(
|
|
() =>
|
|
page
|
|
.getByRole("button", { name: "确认支付", exact: true })
|
|
.isEnabled(),
|
|
Boolean,
|
|
"Payment initialization must settle before expiry",
|
|
);
|
|
const baseline = (await fixtureRequest("requests")).length;
|
|
// 付款前预检发现401后,不应因响应式身份变更重新触发支付初始化。
|
|
await fixtureRequest("options", { expiredPayment: true });
|
|
await page.getByRole("button", { name: "确认支付", exact: true }).click();
|
|
await page.waitForURL((url) => url.pathname === "/login");
|
|
await visibleLoginForm(page);
|
|
assert.equal(
|
|
await page.evaluate(() => localStorage.getItem("jwt")),
|
|
null,
|
|
);
|
|
const afterExpiry = (await fixtureRequest("requests")).slice(baseline);
|
|
const paymentPaths = new Set([
|
|
"/order/42",
|
|
"/account",
|
|
"/gateway",
|
|
"/voucher/pay",
|
|
"/pay",
|
|
]);
|
|
assert.deepEqual(
|
|
afterExpiry
|
|
.filter((request) => paymentPaths.has(request.path))
|
|
.map((request) => ({ method: request.method, path: request.path })),
|
|
[{ method: "GET", path: "/order/42" }],
|
|
"Only the confirmation preflight that discovers expiry may reach the payment API",
|
|
);
|
|
return {
|
|
expiredIdentityRevokesPaymentPage: true,
|
|
noPaymentSubmitted: true,
|
|
noPostExpiryPaymentInitialization: true,
|
|
};
|
|
}),
|
|
);
|
|
|
|
test(
|
|
"logout in another tab revokes both visible sessions",
|
|
{ timeout: 30000 },
|
|
(t) =>
|
|
scenario(t, { hangLogout: true }, async (page) => {
|
|
await login(page);
|
|
await eventually(
|
|
() => visibleKeyIds(page),
|
|
(ids) => ids.length === 10,
|
|
"The first tab must show an authenticated page",
|
|
);
|
|
const secondTab = await page.context().newPage();
|
|
// 使用同一浏览器真实 localStorage/storage 事件,不伪造跨标签页退出通知。
|
|
await secondTab.goto(`${appOrigin}/security/ssh-keys`);
|
|
await eventually(
|
|
() => visibleKeyIds(secondTab),
|
|
(ids) => ids.length === 10,
|
|
"The second tab must recognize the browser session without another login",
|
|
);
|
|
await secondTab
|
|
.getByRole("button", { name: "用户菜单", exact: true })
|
|
.click();
|
|
await secondTab
|
|
.getByRole("menuitem", { name: "退出登录", exact: true })
|
|
.click();
|
|
await secondTab.waitForURL((url) => url.pathname === "/login", {
|
|
timeout: 1500,
|
|
});
|
|
await page.waitForURL((url) => url.pathname === "/login", {
|
|
timeout: 1500,
|
|
});
|
|
await visibleLoginForm(page);
|
|
await visibleLoginForm(secondTab);
|
|
assert.equal(
|
|
await page.evaluate(() => localStorage.getItem("jwt")),
|
|
null,
|
|
);
|
|
assert.equal(
|
|
await page
|
|
.getByRole("heading", { name: "SSH 密钥", exact: true })
|
|
.count(),
|
|
0,
|
|
);
|
|
const requests = await fixtureRequest("requests");
|
|
assert.equal(
|
|
requests.filter((request) => request.path === "/login").length,
|
|
1,
|
|
);
|
|
return {
|
|
sharedBrowserSessionRecognized: true,
|
|
logoutRevokesBothTabs: true,
|
|
noJwtPreseeding: true,
|
|
};
|
|
}),
|
|
);
|