Files

288 lines
10 KiB
JavaScript

/**
* 独立进程的 HTTP 假后端,仅监听回环地址,按既有 /console/v1 信封响应。
* 只模拟测试需要的外部接口,不是可部署的业务后端,也不执行真实付款或短信投递。
* 控制口允许场景重置数据/制造挂起和失效;它不进入产品接口实现。
*/
import { createServer } from "node:http";
const token = "e2e-session-token";
const account = {
id: 1,
username: "audit-user",
email: "audit@example.invalid",
credit: "100.00",
};
const initialKeys = Array.from({ length: 28 }, (_, index) => ({
// 25个audit匹配项 + 3个other项,覆盖搜索结果10/10/5分页与切换过滤条件。
id: index + 1,
name:
index < 25
? `audit-${String(index + 1).padStart(2, "0")}`
: `other-${index + 1}`,
public_key: `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestPublicKey${index + 1}`,
finger_print: `SHA256:fixture-${index + 1}`,
}));
let options = {};
let requests = [];
let paid = false;
let keys = initialKeys.map((key) => ({ ...key }));
function reply(response, data = {}, extra = {}, httpStatus = 200) {
// HTTP状态与业务信封可以独立覆盖,确保前端同时处理传输401和信封401。
response.writeHead(httpStatus, { "Content-Type": "application/json" });
response.end(JSON.stringify({ status: httpStatus, msg: "", data, ...extra }));
}
const server = createServer(async (request, response) => {
// 预览服务和假后端端口不同,需要允许测试浏览器发送JSON与Bearer请求。
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader(
"Access-Control-Allow-Headers",
"Authorization, Content-Type, Accept",
);
response.setHeader(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS",
);
if (request.method === "OPTIONS") return response.writeHead(204).end();
const url = new URL(request.url, "http://127.0.0.1");
let body;
try {
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
const raw = Buffer.concat(chunks).toString();
body = raw ? JSON.parse(raw) : undefined;
} catch {
return reply(response, {}, { msg: "Invalid fixture request body" }, 400);
}
if (url.pathname === "/__control/reset" && request.method === "POST") {
// 场景从全新列表和未付款状态开始;11行变体用于删除最后一页唯一记录。
options = body ?? {};
requests = [];
paid = false;
keys = initialKeys
.slice(0, options.sshLastPageSingleRow ? 11 : initialKeys.length)
.map((key) => ({ ...key }));
return reply(response);
}
if (url.pathname === "/__control/options" && request.method === "POST") {
options = { ...options, ...body };
return reply(response);
}
if (url.pathname === "/__control/requests") return reply(response, requests);
const path = url.pathname.replace(/^\/console\/v1/, "");
const record = {
// 请求原文仅在测试进程内存供断言使用,测试输出端另做脱敏,不能直接导出此对象。
method: request.method,
path,
query: Object.fromEntries(url.searchParams),
body,
authorization: request.headers.authorization ?? null,
referrer: request.headers.referer ?? null,
handled: true,
};
requests.push(record);
if (path === "/common" && request.method === "GET") {
// 模拟站点配置决定允许的登录方式;离线场景关闭外部CAPTCHA与OAuth。
return reply(response, {
website_name: "本地前端验收",
login_phone_password: options.loginMode === "sms-only" ? 0 : 1,
login_email_password: options.loginMode === "sms-only" ? 0 : 1,
login_phone_verify: options.loginMode === "password-only" ? 0 : 1,
first_login_method:
options.loginMode === "password-only" ? "code" : "password",
captcha_client_login: 0,
captcha_client_login_error: 0,
captcha_client_login_error_3_times: 0,
register_phone: 1,
register_email: 1,
plugin_configuration: { mp_weixin_notice: { scan_login: 0 } },
oauth: [],
});
}
if (path === "/login" && request.method === "POST")
return reply(response, { jwt: token });
if (path === "/phone/code" && request.method === "POST")
return reply(response);
// 公共配置和登录之后的接口必须携带实际登录得到的测试令牌。
if (request.headers.authorization !== `Bearer ${token}`) {
record.invalidAuthorization = true;
return reply(response, {}, { msg: "Missing fixture session" }, 401);
}
if (path === "/index" && request.method === "GET" && options.expiredLegacy)
return reply(
response,
{},
{ status: 401, msg: "Expired legacy fixture session" },
);
if (path === "/index" && request.method === "GET")
return reply(response, { account });
if (path === "/account" && request.method === "GET")
return reply(response, { account });
if (path === "/menu" && request.method === "GET") {
return reply(response, {
menu: options.emptyMenu
? []
: [
{
id: 1,
name: "SSH 密钥",
menu_type: "custom",
url: "/security/ssh-keys",
},
{ id: 2, name: "订单", menu_type: "custom", url: "/orders" },
],
});
}
if (path === "/client_care/mail" && request.method === "GET") {
return reply(response, { credit_limit: { count: 0, list: [], type: [] } });
}
if (path === "/logout" && request.method === "POST") {
if (options.hangLogout) return;
return reply(response);
}
if (path === "/ssh_key" && request.method === "GET") {
if (options.hangSshBody) {
// 只发送响应头和不完整JSON,保持连接以复现“响应体解码永不结束”。
response.writeHead(200, { "Content-Type": "application/json" });
response.write('{"status":200,"msg":"","data":');
return;
}
if (options.expiredSession)
return reply(
response,
{},
{ status: 401, msg: "Expired fixture session" },
);
const page = Math.max(1, Number(url.searchParams.get("page")) || 1);
// 列表接口仍走后端分页;搜索聚合/切片由真实前端路径负责。
const limit = Math.max(1, Number(url.searchParams.get("limit")) || 10);
return reply(response, {
list: keys.slice((page - 1) * limit, page * limit),
count: keys.length,
});
}
if (path === "/ssh_key" && request.method === "POST") {
const id = Math.max(0, ...keys.map((key) => key.id)) + 1;
keys.push({ id, ...body, finger_print: `SHA256:fixture-${id}` });
return reply(response, { id });
}
const keyId = /^\/ssh_key\/(\d+)$/.exec(path);
if (keyId && request.method === "PUT") {
const key = keys.find((entry) => entry.id === Number(keyId[1]));
if (!key) return reply(response, {}, { msg: "Fixture key not found" }, 404);
Object.assign(key, body);
return reply(response);
}
if (keyId && request.method === "DELETE") {
keys = keys.filter((entry) => entry.id !== Number(keyId[1]));
return reply(response);
}
if (path === "/order/42" && request.method === "GET") {
// 订单未付金额、流水与支付状态共享 paid 标记,支付成功后回读反映已付金额。
if (options.expiredPayment)
return reply(
response,
{},
{ status: 401, msg: "Expired payment fixture session" },
);
return reply(response, {
order: {
id: 42,
amount: "20.00",
credit: "0.00",
amount_unpaid: paid ? "0.00" : "20.00",
type: "new",
status: paid ? "Paid" : "Unpaid",
create_time: 1700000000,
pay_time: paid ? 1700000060 : 0,
gateway: "alipay",
items: [
{
id: 1,
product_name: "本地测试实例",
description: "前端支付验收",
amount: "20.00",
},
],
},
customfields: { is_pre_invoice: 0 },
});
}
if (path === "/order/42/transaction_record" && request.method === "GET") {
return reply(response, {
list: paid
? [{ amount: "20.00", create_time: 1700000060, description: "alipay" }]
: [],
});
}
if (path === "/gateway" && request.method === "GET") {
return reply(response, {
list: [{ id: 1, name: "alipay", title: "本地测试支付" }],
});
}
if (path === "/voucher/pay" && request.method === "GET")
return reply(response, { list: [] });
if (path === "/pay" && request.method === "POST") {
// Paid 是顶层 code 字段的支付状态值;挂起变体返回渠道 HTML,随后状态查询不响应。
if (options.hangPaymentStatus)
return reply(response, { html: '<div role="status">本地支付渠道</div>' });
paid = true;
return reply(response, {}, { code: "Paid" });
}
if (path === "/pay/42/status" && request.method === "GET") {
if (options.hangPaymentStatus) return;
return reply(response, {}, { code: paid ? "Paid" : "Unpaid" });
}
if (path === "/certification/info" && request.method === "GET") {
return reply(response, {
certification_open: 0,
certification_company_open: 0,
is_certification: 0,
});
}
if (
["/ticket", "/announcement", "/host"].includes(path) &&
request.method === "GET"
) {
return reply(response, {
list: [],
count: 0,
...(path === "/host" ? { server_page: "classified-v1" } : {}),
});
}
if (path === "/withdraw/rule/credit" && request.method === "GET")
return reply(response, {});
if (path === "/recommend/promoter" && request.method === "GET")
return reply(response, { promoter: { permission: 0 } });
// 未实现接口显式失败并留标记,防止新增产品请求被“统一成功”假后端掩盖。
record.handled = false;
reply(
response,
{},
{ msg: `Unhandled fixture endpoint: ${record.method} ${path}` },
404,
);
});
server.listen(0, "127.0.0.1", () => {
// 由系统选择空闲端口,首行JSON作为父测试进程的启动握手。
process.stdout.write(
`${JSON.stringify({ origin: `http://127.0.0.1:${server.address().port}` })}\n`,
);
});
process.on("SIGTERM", () => {
// 先关闭挂起连接,确保超时测试也能正常清理进程。
server.closeAllConnections();
server.close(() => process.exit(0));
});