// 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}`));