Files
console-dev/tests/console.e2e.test.mjs
T

1333 lines
46 KiB
JavaScript

/**
* 通过浏览器验证实际生产构建,不替换内部模块、不预置 JWT。
* 测试只连接独立本地 HTTP 假后端,覆盖页面行为和接口契约,不代表真实支付/供应商已验收。
* 每个场景保存截图、脱敏请求形状及稳定结果,方便失败后复核。
*/
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { createDecipheriv } from "node:crypto";
import { access, mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
import { dirname, join, resolve } from "node:path";
import { createInterface } from "node:readline";
import { after, before, test } from "node:test";
import { fileURLToPath } from "node:url";
import { setTimeout as delay } from "node:timers/promises";
import { chromium } from "playwright";
const testDirectory = dirname(fileURLToPath(import.meta.url));
const appRoot = resolve(process.env.E2E_APP_ROOT || join(testDirectory, ".."));
// 默认复用系统 Chrome;CI/其他平台可指定可执行文件,不自动下载或修改用户浏览器。
const chromePath =
process.env.CHROME_PATH ||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const loginAccount = "audit@example.invalid";
const loginPassword = "test-only-password";
const manifest = { schemaVersion: 1, scenarios: [] };
const children = [];
let backendOrigin;
let appOrigin;
let browser;
let runDirectory;
async function eventually(read, accept, message, timeout = 10000) {
// 等待可观察结果稳定,而不是假设渲染/网络会在固定休眠后完成;失败信息过滤敏感字段。
const deadline = Date.now() + timeout;
let value;
do {
value = await read();
if (accept(value)) return value;
await delay(50);
} while (Date.now() < deadline);
const lastValue = JSON.stringify(value, (key, entry) =>
["authorization", "password", "jwt", "token"].includes(key)
? "<redacted>"
: entry,
);
assert.fail(`${message}; last value: ${lastValue}`);
}
function startProcess(arguments_, options = {}) {
// 统一收集子进程日志和句柄,异常退出可诊断,after 钩子负责结束所有本地服务。
const child = spawn(process.execPath, arguments_, {
...options,
stdio: ["ignore", "pipe", "pipe"],
});
child.log = "";
child.stdout.on("data", (chunk) => {
child.log += chunk.toString();
});
child.stderr.on("data", (chunk) => {
child.log += chunk.toString();
});
children.push(child);
return child;
}
async function fixtureRequest(path, body) {
// 控制口只属于本地测试后端,调整外部响应,不给产品源码增加测试专用分支。
const response = await fetch(`${backendOrigin}/__control/${path}`, {
method: body === undefined ? "GET" : "POST",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(5000),
});
assert.equal(response.status, 200);
return (await response.json()).data;
}
async function freePort() {
// 让系统分配回环端口,降低并行运行多个验收进程时的固定端口冲突风险。
const server = createServer();
await new Promise((resolve_, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve_);
});
const port = server.address().port;
await new Promise((resolve_) => server.close(resolve_));
return port;
}
before(
async () => {
// 必须先构建;此处运行 .output,而非开发服务器,确保测试走实际交付入口。
const runs = join(testDirectory, "fixtures", "runs");
await mkdir(runs, { recursive: true });
runDirectory = await mkdtemp(join(runs, "run-"));
await access(chromePath);
await access(join(appRoot, ".output", "server", "index.mjs"));
const api = startProcess([
join(testDirectory, "fixtures", "api-server.mjs"),
]);
// 假后端首行只报告监听地址,避免在启动协议中输出账号密码或请求内容。
backendOrigin = await new Promise((resolve_, reject) => {
const lines = createInterface({ input: api.stdout });
const timer = setTimeout(
() => reject(new Error("Fixture API did not start")),
10000,
);
lines.once("line", (line) => {
clearTimeout(timer);
lines.close();
try {
resolve_(JSON.parse(line).origin);
} catch (error) {
reject(error);
}
});
api.once("error", reject);
api.once("exit", (code) =>
reject(new Error(`Fixture API exited: ${code}`)),
);
});
const port = await freePort();
appOrigin = `http://127.0.0.1:${port}`;
const app = startProcess(
[join(appRoot, ".output", "server", "index.mjs")],
{
cwd: appRoot,
env: {
// 使用生产已有的公开 API 配置接入外部边界,不读取或修改项目 .env。
...process.env,
NODE_ENV: "production",
HOST: "127.0.0.1",
PORT: String(port),
NITRO_HOST: "127.0.0.1",
NITRO_PORT: String(port),
NUXT_PUBLIC_API_BASE: backendOrigin,
},
},
);
await eventually(
async () => {
if (app.exitCode !== null)
throw new Error(`Preview exited: ${app.log}`);
try {
return (
await fetch(`${appOrigin}/login`, {
signal: AbortSignal.timeout(1000),
})
).ok;
} catch {
return false;
}
},
Boolean,
"Built app did not start",
30000,
);
browser = await chromium.launch({
executablePath: chromePath,
headless: true,
});
},
{ timeout: 45000 },
);
after(async () => {
// 先关闭浏览器,再逆序结束服务;挂起接口也不能让验收进程永久留在后台。
await browser?.close();
for (const child of children.reverse()) {
if (child.exitCode !== null) continue;
child.kill("SIGTERM");
await Promise.race([
new Promise((resolve_) => child.once("exit", resolve_)),
delay(1500),
]);
if (child.exitCode === null) child.kill("SIGKILL");
}
if (runDirectory) {
manifest.scenarios.sort((left, right) =>
left.name.localeCompare(right.name),
);
await writeFile(
join(runDirectory, "manifest.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
);
console.log(`E2E artifacts: ${join(runDirectory, "manifest.json")}`);
}
});
function normalizeRequests(requests) {
// 只记录字段名和是否认证;去重、排序移除并发请求顺序等非业务差异,不保存凭证值。
const distinct = requests.map(
({ method, path, query, body, authorization }) => ({
method,
path,
queryKeys: Object.keys(query).sort(),
bodyKeys: Object.keys(body ?? {}).sort(),
authenticated: authorization !== null,
}),
);
return [
...new Map(
distinct.map((entry) => [JSON.stringify(entry), entry]),
).values(),
].sort((left, right) =>
JSON.stringify(left).localeCompare(JSON.stringify(right)),
);
}
async function scenario(t, options, exercise) {
// 每个场景重新初始化后端状态和浏览器上下文,账号与列表缓存不能污染下一场景。
await fixtureRequest("reset", options);
const context = await browser.newContext({
viewport: { width: 1680, height: 1050 },
serviceWorkers: "block",
});
const errors = [];
const networkViolations = [];
const allowedOrigins = new Set([appOrigin, backendOrigin]);
await context.route("**/*", async (route) => {
// 只允许两个本地 origin;意外的生产/CDN访问会被阻断,并在场景结束时作为失败报告。
const url = new URL(route.request().url());
if (
!["http:", "https:"].includes(url.protocol) ||
allowedOrigins.has(url.origin)
)
return route.continue();
networkViolations.push(
`${route.request().method()} ${url.origin}${url.pathname}`,
);
await route.abort("blockedbyclient");
});
context.on("page", (openedPage) => {
openedPage.setDefaultTimeout(10000);
openedPage.on("pageerror", (error) => errors.push(error.message));
openedPage.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
});
const page = await context.newPage();
const screenshot = `${t.name.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}.png`;
let failure;
let observed;
let requests = [];
try {
observed = await exercise(page);
} catch (error) {
failure = error;
} finally {
// 即使业务断言失败,也尽量保存截图与请求诊断,不能只留下“超时”这一条错误。
try {
assert.equal(
await page
.getByText("Invalid PrimeUI License", { exact: true })
.isVisible(),
false,
"The delivered UI must not display an invalid vendor license warning",
);
} catch (error) {
failure ??= error;
}
try {
await page.screenshot({
path: join(runDirectory, screenshot),
fullPage: true,
animations: "disabled",
timeout: 5000,
});
} catch (error) {
failure ??= error;
}
await context.close();
requests = await fixtureRequest("requests");
// 诊断附件保留先后顺序,但不落盘 body、Authorization 或 referrer 原文。
await writeFile(
join(runDirectory, screenshot.replace(/\.png$/, ".requests.json")),
`${JSON.stringify(
requests.map(
(
{ method, path, authorization, handled, invalidAuthorization },
index,
) => ({
sequence: index + 1,
method,
path,
authenticated: authorization !== null,
handled,
invalidAuthorization: invalidAuthorization === true,
}),
),
null,
2,
)}\n`,
);
try {
assert.deepEqual(
networkViolations,
[],
"Browser attempted a non-local network request",
);
assert.deepEqual(
errors,
[],
"Browser reported runtime or console errors",
);
assert.deepEqual(
requests.filter((request) => !request.handled),
[],
"Fixture is missing a production endpoint",
);
assert.deepEqual(
requests.filter((request) => request.invalidAuthorization),
[],
"API calls lost the session token",
);
} catch (error) {
failure ??= error;
}
// 主清单保留稳定观察结果;失败文本另外替换随机 origin、测试密码和令牌。
manifest.scenarios.push({
name: t.name,
result: failure ? "failed" : "passed",
...(observed ? { observed } : {}),
screenshot,
requests: normalizeRequests(requests),
browserErrors: [...new Set(errors)].sort(),
networkViolations: [...new Set(networkViolations)].sort(),
...(failure
? {
failure: {
name: failure.name,
message: failure.message
.replaceAll(appOrigin, "<app>")
.replaceAll(backendOrigin, "<api>")
.replaceAll(loginPassword, "<password>")
.replaceAll("e2e-session-token", "<token>"),
},
}
: {}),
});
}
if (failure) throw failure;
}
async function login(page, redirect = "/security/ssh-keys") {
// 所有普通场景经真实登录表单建立会话,顺带验证密码输入框没有退化成明文框。
await page.goto(
`${appOrigin}/login?redirect=${encodeURIComponent(redirect)}`,
);
const password = page.getByLabel("密码", { exact: true });
assert.equal(await password.getAttribute("type"), "password");
await page.getByLabel("账号", { exact: true }).fill(loginAccount);
await password.fill(loginPassword);
await page.getByRole("button", { name: "登录", exact: true }).click();
await page.waitForURL((url) => url.pathname !== "/login");
}
async function visibleKeyIds(page) {
// 从用户实际看到的表格读取 ID,而不是访问组件状态或请求客户端的内部缓存。
const rows = await page
.getByRole("row")
.filter({ hasText: /#\d+/ })
.allTextContents();
return rows.map((row) => Number(row.match(/#(\d+)/)[1]));
}
test(
"auth guard preserves the protected destination",
{ timeout: 20000 },
(t) =>
scenario(t, {}, async (page) => {
// 访客导航保留查询串和锚点,但不能先发受保护的列表请求再跳转。
await page.goto(`${appOrigin}/security/ssh-keys?m=7#details`);
await page.waitForURL((url) => url.pathname === "/login");
const url = new URL(page.url());
assert.equal(
url.searchParams.get("redirect"),
"/security/ssh-keys?m=7#details",
);
assert.equal(
await page.evaluate(() => localStorage.getItem("jwt")),
null,
);
const requests = await fixtureRequest("requests");
assert.equal(
requests.some((request) => request.path === "/ssh_key"),
false,
);
return { destinationPreserved: true, protectedApiNotCalled: true };
}),
);
test(
"password login uses the deployed encrypted field contract",
{ timeout: 25000 },
(t) =>
scenario(t, {}, async (page) => {
await login(page, "/security/ssh-keys?m=1");
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,
});
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");
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 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,
});
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");
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,
});
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,
};
}),
);