Files
edr-platform/integration/gateway-mock/server.js
2026-08-03 10:51:43 +00:00

300 lines
11 KiB
JavaScript

// Stand-in for every bank/wallet gateway the payment API calls, plus a control
// plane the integration tests drive.
//
// WHY ONE PROCESS
//
// Each provider's base URL is env-configurable (packages/payment-providers/…),
// so pointing them all at one server with a per-provider path prefix stubs the
// whole outbound surface without touching a line of app code. The payment API
// itself, its state machine, its webhook pipeline and its signature checks all
// run for real.
//
// Signatures are REAL: this server holds the same CBE_SECRET_KEY the API does
// and signs the callbacks it fires, so the API's verifyWebhookSignature runs in
// anger instead of being bypassed. That also makes the negative test possible —
// ask for a bad signature and the API must refuse to move any money.
//
// No dependencies (node:http + node:crypto), same shape as e2e/freight/*-mock.
const http = require("node:http");
const crypto = require("node:crypto");
const PORT = Number(process.env.PORT || 4600);
const PAYMENT_API_URL = process.env.PAYMENT_API_URL || "http://payment-api-it:3003";
const CBE_SECRET = process.env.CBE_SECRET_KEY || "it-cbe-secret";
const CBE_MERCHANT = process.env.CBE_MERCHANT_ID || "it-cbe-merchant";
const CAC_OTP = "123456";
/**
* Per-provider behaviour, set by POST /__control/provider/:name.
* ok — succeed (default)
* fail — answer 502, so the provider call throws inside the API
* timeout — never answer (the API's own 10s axios timeout fires)
* pending — succeed on initiate, but report "not paid yet" on every query
* paid — report SUCCESS on query without any webhook (reconciliation path)
* `remaining` counts down when set, then the provider reverts to ok.
*/
const modes = new Map();
/** Every inbound call, for "the provider was queried exactly once" assertions. */
let calls = [];
/** merchantOrderId → what the mock believes the payment did. */
const orders = new Map();
function modeFor(provider) {
const entry = modes.get(provider);
if (!entry) return "ok";
if (entry.remaining != null) {
if (entry.remaining <= 0) {
modes.delete(provider);
return "ok";
}
entry.remaining -= 1;
}
return entry.mode;
}
/** CBE Birr signs `k=v` pairs over sorted keys with HMAC-SHA256 (hex). */
function cbeSign(data) {
const signString = Object.keys(data)
.sort()
.map((k) => `${k}=${data[k]}`)
.join("&");
return crypto.createHmac("sha256", CBE_SECRET).update(signString).digest("hex");
}
async function postJson(url, body, headers = {}) {
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json", ...headers },
body: JSON.stringify(body),
});
const text = await res.text();
return { status: res.status, body: text };
}
/**
* Fire a provider callback at the payment API, signed the way the real gateway
* would. `signature: "bad"` deliberately produces a well-formed but wrong
* signature (same length — the API compares with timingSafeEqual, which throws
* on a length mismatch and would mask what we are testing).
*/
async function fireWebhook(opts) {
const {
provider = "CBE_BIRR",
merchantOrderId,
status = "SUCCESS",
transactionId,
eventId,
signature,
} = opts;
if (provider !== "CBE_BIRR") {
throw new Error(`webhook not implemented for provider ${provider}`);
}
const order = orders.get(merchantOrderId) ?? {};
const payload = {
merchantId: CBE_MERCHANT,
merchantOrderId,
// orderId doubles as the dedupe key upstream: externalEventId is
// `${orderId}_${status}` (cbe-birr-webhook.service.ts), so a caller-supplied
// eventId is how a test replays the SAME event.
orderId: eventId ?? order.orderId ?? `CBEORD-${merchantOrderId}`,
status,
transactionId: transactionId ?? order.transactionId ?? `CBETXN-${merchantOrderId}`,
amount: order.amount ?? "1.00",
currency: order.currency ?? "ETB",
paidAt: new Date().toISOString(),
};
payload.signature =
signature === "bad" ? crypto.randomBytes(32).toString("hex") : cbeSign(payload);
return postJson(`${PAYMENT_API_URL}/webhooks/cbe-birr`, payload);
}
// ---------------------------------------------------------------------------
// provider routes
// ---------------------------------------------------------------------------
/** @returns {[number, unknown] | "hang"} */
function handleProvider(provider, path, body, url) {
const mode = modeFor(provider);
if (mode === "timeout") return "hang";
if (mode === "fail") return [502, { error: `${provider} unavailable (forced)` }];
switch (`${provider}/${path}`) {
// --- CBE Birr (the suite's primary provider: plain HMAC, no key material)
case "cbe-birr/api/v1/payment/initiate": {
const orderId = `CBEORD-${body.merchantOrderId}`;
orders.set(body.merchantOrderId, {
orderId,
amount: body.amount,
currency: body.currency,
transactionId: `CBETXN-${body.merchantOrderId}`,
paid: false,
});
return [
200,
{
success: true,
orderId,
paymentUrl: `http://gateway-mock-it:${PORT}/cbe-birr/pay/${orderId}`,
expiresIn: 900,
},
];
}
case "cbe-birr/api/v1/payment/query": {
const order = orders.get(body.merchantOrderId);
if (!order) return [200, { success: false, status: "NOT_FOUND" }];
const paid = mode === "paid" || order.paid;
return [
200,
{
success: true,
orderId: order.orderId,
status: paid ? "SUCCESS" : mode === "pending" ? "PENDING" : "PROCESSING",
transactionId: order.transactionId,
amount: order.amount,
paidAt: paid ? new Date().toISOString() : undefined,
},
];
}
// --- Telebirr (fabric token + createOrder + queryOrder)
case "telebirr/payment/v1/token":
return [200, { token: "it-fabric-token" }];
case "telebirr/payment/v1/inapp/createOrder": {
const merchOrderId = body?.biz_content?.merch_order_id;
const prepayId = `PREPAY-${merchOrderId ?? Date.now()}`;
orders.set(merchOrderId, { orderId: prepayId, paid: false });
return [
200,
{
result: "SUCCESS",
code: "0",
biz_content: { prepay_id: prepayId, merch_order_id: merchOrderId },
},
];
}
case "telebirr/payment/v1/merchant/queryOrder": {
const order = orders.get(body?.biz_content?.merch_order_id);
const paid = mode === "paid" || order?.paid;
return [
200,
{
result: "SUCCESS",
code: "0",
biz_content: {
order_status: paid ? "Completed" : "Paying",
trans_id: order?.orderId,
},
},
];
}
// --- CAC Bank (OTP debit)
case "cac/paymentapi/auth/signin":
return [200, { token: "it-cac-token", expiresIn: 86400 }];
case "cac/paymentapi/PaymentInitiateRequest": {
const id = `${Date.now()}00000`;
orders.set(String(id), { orderId: String(id), paid: false });
return [200, { status: true, message: "OTP sent", data: { id, otpRequired: true } }];
}
default:
// Unimplemented gateway paths answer a generic OK rather than 404: the
// suite only drives CBE Birr / CAC end to end, and a 404 here would look
// like a bug in the API rather than an unused stub. Add real shapes when
// a scenario needs them.
calls.push({ provider, path, unimplemented: true });
return [200, { success: true, stub: true, path: `${provider}/${path}`, url }];
}
}
// ---------------------------------------------------------------------------
// server
// ---------------------------------------------------------------------------
const server = http.createServer((req, res) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", async () => {
const raw = Buffer.concat(chunks).toString("utf8");
let body = {};
try {
body = raw ? JSON.parse(raw) : {};
} catch {
body = { raw };
}
const send = (status, payload) => {
const json = JSON.stringify(payload ?? {});
res.writeHead(status, {
"content-type": "application/json",
"content-length": Buffer.byteLength(json),
});
res.end(json);
};
const url = new URL(req.url, "http://mock");
const path = url.pathname.replace(/^\/+/, "");
// --- control plane -----------------------------------------------------
if (path.startsWith("__control")) {
const [, action, arg] = path.split("/");
if (action === "health") return send(200, { ok: true });
if (action === "reset") {
modes.clear();
orders.clear();
calls = [];
return send(200, { ok: true });
}
if (action === "calls") {
return send(200, { calls });
}
if (action === "provider" && req.method === "POST") {
modes.set(arg, { mode: body.mode ?? "ok", remaining: body.times ?? null });
console.log(`[gateway-mock] ${arg}${body.mode} (times=${body.times ?? "∞"})`);
return send(200, { ok: true, provider: arg, mode: body.mode });
}
if (action === "settle" && req.method === "POST") {
// Mark the order paid at the gateway WITHOUT notifying — the payment
// API can then only learn about it by polling (reconciliation path).
const order = orders.get(body.merchantOrderId);
if (!order) return send(404, { error: "unknown merchantOrderId" });
order.paid = true;
return send(200, { ok: true });
}
if (action === "webhook" && req.method === "POST") {
try {
const result = await fireWebhook(body);
console.log(
`[gateway-mock] webhook ${body.merchantOrderId} ${body.status ?? "SUCCESS"}${result.status}`,
);
return send(200, { ok: true, delivered: result.status, body: result.body });
} catch (err) {
return send(500, { error: String(err) });
}
}
return send(404, { error: `unknown control action ${action}` });
}
// --- gateway routes ----------------------------------------------------
const provider = path.split("/")[0];
const rest = path.slice(provider.length + 1);
calls.push({ provider, path: rest, method: req.method, body, at: Date.now() });
// CAC confirm carries the OTP; wrong code must fail the way the bank does.
if (rest.startsWith("paymentapi/") && rest.includes("Confirm")) {
const ok = String(body.otp ?? body.OTP ?? "") === CAC_OTP;
return send(200, ok
? { status: true, data: { id: body.id, status: "SUCCESS" } }
: { status: false, message: "Invalid OTP" });
}
const result = handleProvider(provider, rest, body, req.url);
if (result === "hang") {
console.log(`[gateway-mock] ${provider}/${rest} → hanging (forced timeout)`);
return; // never answer; the caller's own timeout fires
}
send(result[0], result[1]);
});
});
server.listen(PORT, () => console.log(`gateway-mock listening on ${PORT}`));