/** * 通过浏览器验证实际生产构建,不替换内部模块、不预置 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) ? "" : 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, "") .replaceAll(backendOrigin, "") .replaceAll(loginPassword, "") .replaceAll("e2e-session-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, }; }), );