mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +00:00
300 lines
11 KiB
JavaScript
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}`));
|