Files
CurrenciCombo/orchestrator/tests/unit/finLinkSandbox.test.ts
Devin 7c5dd145d6
Some checks failed
CI / Frontend Lint (pull_request) Failing after 6s
CI / Frontend Type Check (pull_request) Failing after 5s
CI / Frontend Build (pull_request) Failing after 7s
CI / Frontend E2E Tests (pull_request) Failing after 8s
CI / Orchestrator Build (pull_request) Failing after 7s
CI / Contracts Compile (pull_request) Failing after 5s
CI / Contracts Test (pull_request) Failing after 6s
Code Quality / SonarQube Analysis (pull_request) Failing after 18s
Code Quality / Code Quality Checks (pull_request) Failing after 4s
Security Scan / Dependency Vulnerability Scan (pull_request) Failing after 4s
Security Scan / OWASP ZAP Scan (pull_request) Failing after 4s
FIN-link sandbox service
Closes gap-analysis v2 §7.1 (no FIN-link adapter; SWIFT generators
produce strings but no transport) + §10.6 (stand up sandbox transport).

- services/finLink/sandbox.ts — in-process FIN-link sandbox.
  Accepts POST /dispatch with a SWIFT/ISO payload, assigns a FIN
  reference, and advances messages deterministically through
  received -> acknowledged -> accepted -> settled (with reject as a
  terminal fork). Optional webhook per-message (x-fin-sandbox-signature
  header, HMAC-SHA256). Timer-driven auto-progress opt-in via
  startAutoProgress().
- services/finLink/client.ts — two client adapters:
    createHttpFinLinkClient(baseUrl) - for the live router
    createInProcessFinLinkClient()   - for unit tests that skip
                                       the HTTP hop
  getFinLinkClient() picks HTTP when FIN_SANDBOX_URL is set, else
  falls back to in-process.
- services/finLink/index.ts — public surface.
- src/index.ts — mounts /fin-sandbox only when
  FIN_SANDBOX_ENABLED=true; off by default to keep prod surface clean.
- tests/unit/finLinkSandbox.test.ts — 12 tests covering lifecycle,
  rejection, listing, signature determinism, HTTP endpoints
  (dispatch/advance/messages/filtering), and both client adapters
  (including a live ephemeral-port HTTP round-trip).
- Verification: tsc --noEmit clean; full jest 92/92 passing
  (8 suites).
2026-04-22 18:39:31 +00:00

171 lines
6.0 KiB
TypeScript

import { describe, it, expect, beforeEach } from "@jest/globals";
import express from "express";
import request from "supertest";
import {
buildSandboxRouter,
recordDispatch,
advance,
rejectMessage,
getMessage,
listMessages,
resetSandboxForTests,
finSignature,
} from "../../src/services/finLink/sandbox";
import {
createInProcessFinLinkClient,
createHttpFinLinkClient,
} from "../../src/services/finLink/client";
describe("FIN-link sandbox (gap-analysis v2 §7.1 / §10.6)", () => {
beforeEach(() => {
resetSandboxForTests();
});
describe("lifecycle (in-memory)", () => {
it("assigns a FIN reference and records state received", () => {
const msg = recordDispatch({
messageType: "MT760",
payload: "MT760 payload",
planId: "plan-1",
});
expect(msg.reference).toMatch(/^FIN-[0-9A-F]{12}$/);
expect(msg.state).toBe("received");
expect(msg.stateHistory).toHaveLength(1);
expect(msg.planId).toBe("plan-1");
});
it("advances deterministically: received -> acknowledged -> accepted -> settled", async () => {
const msg = recordDispatch({ messageType: "pacs.009", payload: "<pacs.009/>" });
expect((await advance(msg.reference))!.state).toBe("acknowledged");
expect((await advance(msg.reference))!.state).toBe("accepted");
expect((await advance(msg.reference))!.state).toBe("settled");
expect((await advance(msg.reference))!.state).toBe("settled"); // terminal
const final = getMessage(msg.reference)!;
expect(final.stateHistory.map((h) => h.state)).toEqual([
"received",
"acknowledged",
"accepted",
"settled",
]);
});
it("supports rejection and stops lifecycle progression", async () => {
const msg = recordDispatch({ messageType: "MT202", payload: "MT202 payload" });
const rejected = rejectMessage(msg.reference, "bad coordinates")!;
expect(rejected.state).toBe("rejected");
const afterAdvance = await advance(msg.reference);
expect(afterAdvance!.state).toBe("rejected");
});
it("listMessages filters by planId", () => {
recordDispatch({ messageType: "MT760", payload: "a", planId: "plan-a" });
recordDispatch({ messageType: "MT760", payload: "b", planId: "plan-b" });
recordDispatch({ messageType: "MT760", payload: "c", planId: "plan-a" });
expect(listMessages().length).toBe(3);
expect(listMessages({ planId: "plan-a" }).length).toBe(2);
});
});
describe("signature", () => {
it("produces a stable 64-char hex HMAC", () => {
const sig = finSignature("hello");
expect(sig).toMatch(/^[0-9a-f]{64}$/);
expect(finSignature("hello")).toBe(sig);
expect(finSignature("world")).not.toBe(sig);
});
});
describe("HTTP router", () => {
const app = express();
app.use("/fin", buildSandboxRouter());
beforeEach(() => resetSandboxForTests());
it("POST /fin/dispatch returns 202 + reference", async () => {
const resp = await request(app)
.post("/fin/dispatch")
.send({ messageType: "MT760", payload: "mt760", planId: "plan-x" })
.expect(202);
expect(resp.body.reference).toMatch(/^FIN-/);
expect(resp.body.state).toBe("received");
});
it("POST /fin/dispatch 400s on missing payload", async () => {
await request(app)
.post("/fin/dispatch")
.send({ messageType: "MT760" })
.expect(400);
});
it("POST /fin/advance/:ref walks through lifecycle", async () => {
const d = await request(app)
.post("/fin/dispatch")
.send({ messageType: "pacs.009", payload: "<pacs.009/>" })
.expect(202);
const ref = d.body.reference;
const a1 = await request(app).post(`/fin/advance/${ref}`).expect(200);
expect(a1.body.state).toBe("acknowledged");
const a2 = await request(app).post(`/fin/advance/${ref}`).expect(200);
expect(a2.body.state).toBe("accepted");
const a3 = await request(app).post(`/fin/advance/${ref}`).expect(200);
expect(a3.body.state).toBe("settled");
});
it("GET /fin/messages?planId=... filters", async () => {
await request(app)
.post("/fin/dispatch")
.send({ messageType: "MT760", payload: "a", planId: "p1" });
await request(app)
.post("/fin/dispatch")
.send({ messageType: "MT760", payload: "b", planId: "p2" });
const r = await request(app).get("/fin/messages?planId=p1").expect(200);
expect(r.body.messages).toHaveLength(1);
expect(r.body.messages[0].planId).toBe("p1");
});
it("GET /fin/messages/:ref returns 404 for unknown", async () => {
await request(app).get("/fin/messages/FIN-UNKNOWN").expect(404);
});
});
describe("client", () => {
beforeEach(() => resetSandboxForTests());
it("createInProcessFinLinkClient dispatches and reads back", async () => {
const client = await createInProcessFinLinkClient();
const ack = await client.dispatch({
messageType: "MT760",
payload: "mt760",
planId: "plan-ip",
});
expect(ack.reference).toMatch(/^FIN-/);
const msg = await client.getMessage(ack.reference);
expect(msg?.planId).toBe("plan-ip");
});
it("createHttpFinLinkClient hits the live router", async () => {
const app = express();
app.use("/fin", buildSandboxRouter());
const server = app.listen(0);
try {
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
const client = createHttpFinLinkClient(`http://127.0.0.1:${port}/fin`);
const ack = await client.dispatch({
messageType: "pacs.009",
payload: "<pacs.009/>",
planId: "plan-http",
});
expect(ack.reference).toMatch(/^FIN-/);
const msg = await client.getMessage(ack.reference);
expect(msg?.messageType).toBe("pacs.009");
const missing = await client.getMessage("FIN-DOES-NOT-EXIST");
expect(missing).toBeNull();
} finally {
server.close();
}
});
});
});