288 lines
10 KiB
JavaScript
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是现有后端的顶层字段;挂起变体返回渠道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));
|
|
});
|