Some checks failed
CI / Frontend Lint (push) Has been cancelled
CI / Frontend Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Frontend E2E Tests (push) Has been cancelled
CI / Orchestrator Build (push) Has been cancelled
CI / Contracts Compile (push) Has been cancelled
CI / Contracts Test (push) Has been cancelled
Security Scan / Dependency Vulnerability Scan (push) Has been cancelled
Security Scan / OWASP ZAP Scan (push) Has been cancelled
285 lines
8.5 KiB
TypeScript
285 lines
8.5 KiB
TypeScript
import { describe, it, expect } from "@jest/globals";
|
|
import {
|
|
canonicalize,
|
|
hashObligationTerms,
|
|
validateObligationTerms,
|
|
evaluateClauses,
|
|
evaluateCommit,
|
|
evaluateAbort,
|
|
buildIssueInstrumentObligation,
|
|
type ObligationTerms,
|
|
} from "../../src/services/obligations";
|
|
import { evaluateCondition, resolvePath } from "../../src/services/obligations/evaluator";
|
|
|
|
describe("Obligation layer (gap-analysis v2 §4.1)", () => {
|
|
const instrument = {
|
|
applicant: "ACME Corp",
|
|
issuingBankBIC: "CHASUS33",
|
|
beneficiaryBankBIC: "EBILAEAD",
|
|
beneficiaryName: "Acme Beneficiary Ltd",
|
|
beneficiaryAccount: "AE070331234567890123456",
|
|
amount: 1_000_000,
|
|
currency: "USD",
|
|
tenor: "1Y",
|
|
expiryDate: "2026-12-31",
|
|
placeOfPresentation: "Dubai",
|
|
governingLaw: "URDG 758",
|
|
templateRef: "emirates-islamic-sblc-v3",
|
|
templateHash:
|
|
"a".repeat(64),
|
|
};
|
|
|
|
const authorizedParticipants = [
|
|
{ role: "coordinator" as const, actorId: "actor-1" },
|
|
{ role: "approver" as const, actorId: "actor-2" },
|
|
{ role: "releaser" as const, actorId: "actor-3" },
|
|
{ role: "validator" as const, actorId: "actor-4" },
|
|
{ role: "exception_manager" as const, actorId: "actor-5" },
|
|
];
|
|
|
|
describe("canonicalize()", () => {
|
|
it("sorts object keys at every depth", () => {
|
|
const a = canonicalize({ b: 1, a: { d: 2, c: 3 } });
|
|
const b = canonicalize({ a: { c: 3, d: 2 }, b: 1 });
|
|
expect(a).toBe(b);
|
|
expect(a).toBe('{"a":{"c":3,"d":2},"b":1}');
|
|
});
|
|
|
|
it("preserves array order", () => {
|
|
expect(canonicalize({ x: [3, 1, 2] })).toBe('{"x":[3,1,2]}');
|
|
});
|
|
|
|
it("handles null and nested arrays of objects", () => {
|
|
expect(
|
|
canonicalize({ a: null, b: [{ y: 2, x: 1 }, { z: 3 }] }),
|
|
).toBe('{"a":null,"b":[{"x":1,"y":2},{"z":3}]}');
|
|
});
|
|
});
|
|
|
|
describe("hashObligationTerms()", () => {
|
|
const terms = buildIssueInstrumentObligation({
|
|
instrument,
|
|
payor: "ACME Corp",
|
|
payee: "Acme Beneficiary Ltd",
|
|
authorizedParticipants,
|
|
});
|
|
|
|
it("produces a 64-char hex hash", () => {
|
|
expect(hashObligationTerms(terms)).toMatch(/^[0-9a-f]{64}$/);
|
|
});
|
|
|
|
it("is insensitive to key ordering", () => {
|
|
const shuffled: ObligationTerms = {
|
|
...terms,
|
|
consideration: {
|
|
payee: terms.consideration.payee,
|
|
currency: terms.consideration.currency,
|
|
amount: terms.consideration.amount,
|
|
payor: terms.consideration.payor,
|
|
},
|
|
};
|
|
expect(hashObligationTerms(shuffled)).toBe(hashObligationTerms(terms));
|
|
});
|
|
|
|
it("changes when any field mutates", () => {
|
|
const mutated: ObligationTerms = {
|
|
...terms,
|
|
consideration: { ...terms.consideration, amount: 999 },
|
|
};
|
|
expect(hashObligationTerms(mutated)).not.toBe(hashObligationTerms(terms));
|
|
});
|
|
});
|
|
|
|
describe("validateObligationTerms()", () => {
|
|
const valid = buildIssueInstrumentObligation({
|
|
instrument,
|
|
payor: "A",
|
|
payee: "B",
|
|
authorizedParticipants,
|
|
});
|
|
|
|
it("accepts a well-formed obligation", () => {
|
|
expect(validateObligationTerms(valid).ok).toBe(true);
|
|
});
|
|
|
|
it("rejects non-object input", () => {
|
|
expect(validateObligationTerms(null).ok).toBe(false);
|
|
expect(validateObligationTerms("nope").ok).toBe(false);
|
|
});
|
|
|
|
it("flags missing consideration fields", () => {
|
|
const bad = {
|
|
...valid,
|
|
consideration: { payor: "A", payee: "B", currency: "usd", amount: -5 },
|
|
};
|
|
const r = validateObligationTerms(bad);
|
|
expect(r.ok).toBe(false);
|
|
expect(r.errors).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining("ISO-4217"),
|
|
expect.stringContaining("amount"),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("flags bad template hash", () => {
|
|
const bad = {
|
|
...valid,
|
|
governingDocuments: [
|
|
{ templateRef: "t", templateHash: "not-a-hash" },
|
|
],
|
|
};
|
|
const r = validateObligationTerms(bad);
|
|
expect(r.ok).toBe(false);
|
|
expect(r.errors.some((e) => e.includes("hex SHA-256"))).toBe(true);
|
|
});
|
|
|
|
it("flags empty authorizedParticipants[].role", () => {
|
|
const bad = {
|
|
...valid,
|
|
authorizedParticipants: [{ actorId: "x" }],
|
|
};
|
|
const r = validateObligationTerms(bad);
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("evaluator", () => {
|
|
it("resolvePath handles dotted + indexed paths", () => {
|
|
const ctx = { plan: { steps: [{ type: "pay" }, { type: "issueInstrument" }] } };
|
|
expect(resolvePath("plan.steps[1].type", ctx)).toBe("issueInstrument");
|
|
expect(resolvePath("plan.missing.x", ctx)).toBeUndefined();
|
|
});
|
|
|
|
it("evaluates all/any/not combinators", () => {
|
|
const ctx = { a: 1, b: 2 };
|
|
expect(
|
|
evaluateCondition(
|
|
{
|
|
all: [
|
|
{ path: "a", op: "eq", value: 1 },
|
|
{ path: "b", op: "gt", value: 1 },
|
|
],
|
|
},
|
|
ctx,
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
evaluateCondition(
|
|
{
|
|
any: [
|
|
{ path: "a", op: "eq", value: 99 },
|
|
{ path: "b", op: "gt", value: 1 },
|
|
],
|
|
},
|
|
ctx,
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
evaluateCondition({ not: { path: "a", op: "eq", value: 2 } }, ctx),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("matches regex operator safely (no eval)", () => {
|
|
expect(
|
|
evaluateCondition(
|
|
{ path: "h", op: "matches", value: "^0x[0-9a-f]{4}$" },
|
|
{ h: "0xbeef" },
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
evaluateCondition(
|
|
{ path: "h", op: "matches", value: "^0x[0-9a-f]{4}$" },
|
|
{ h: "0xBEEFG" },
|
|
),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("evaluateClauses / evaluateCommit / evaluateAbort", () => {
|
|
const terms = buildIssueInstrumentObligation({
|
|
instrument,
|
|
payor: "ACME Corp",
|
|
payee: "Acme Beneficiary Ltd",
|
|
authorizedParticipants,
|
|
});
|
|
|
|
const passingCtx = {
|
|
state: "VALIDATING",
|
|
dlt: { tx_hash: "0x" + "b".repeat(64) },
|
|
bank: { iso_message_id: "MSG-1" },
|
|
exceptions: { active: [] },
|
|
instrument: { template_hash: instrument.templateHash, dispatched: true },
|
|
payment: {
|
|
amount: instrument.amount,
|
|
currency: instrument.currency,
|
|
failed: false,
|
|
},
|
|
};
|
|
|
|
it("evaluateCommit returns ok=true when all commit clauses pass", () => {
|
|
const r = evaluateCommit(terms, passingCtx);
|
|
expect(r.ok).toBe(true);
|
|
expect(r.results.every((x) => x.ok)).toBe(true);
|
|
});
|
|
|
|
it("evaluateCommit returns ok=false with per-clause reasons on failure", () => {
|
|
const badCtx = { ...passingCtx, dlt: { tx_hash: "not-hex" } };
|
|
const r = evaluateCommit(terms, badCtx);
|
|
expect(r.ok).toBe(false);
|
|
const failing = r.results.find((x) => !x.ok);
|
|
expect(failing?.clauseId).toBe("commit.dlt_tx_hash");
|
|
expect(failing?.failureReason).toBeTruthy();
|
|
});
|
|
|
|
it("evaluateAbort fires when an active exception exists", () => {
|
|
const ctx = {
|
|
...passingCtx,
|
|
exceptions: { active: [{ kind: "timeout" }] },
|
|
};
|
|
const r = evaluateAbort(terms, ctx);
|
|
expect(r.ok).toBe(true);
|
|
expect(r.results.find((x) => x.clauseId === "abort.exception_raised")?.ok).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("evaluateClauses surfaces evaluator errors without throwing", () => {
|
|
const bogus = [
|
|
{
|
|
id: "bogus",
|
|
description: "bad regex",
|
|
binds: "both" as const,
|
|
assert: { path: "h", op: "matches" as const, value: "[" }, // invalid regex
|
|
},
|
|
];
|
|
const r = evaluateClauses(bogus, { h: "x" });
|
|
expect(r.ok).toBe(false);
|
|
expect(r.results[0].failureReason).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("buildIssueInstrumentObligation()", () => {
|
|
it("binds the instrument template hash into governingDocuments", () => {
|
|
const terms = buildIssueInstrumentObligation({
|
|
instrument,
|
|
payor: "A",
|
|
payee: "B",
|
|
authorizedParticipants,
|
|
});
|
|
expect(terms.governingDocuments[0].templateHash).toBe(instrument.templateHash);
|
|
expect(terms.governingDocuments[0].governingLaw).toBe("URDG 758");
|
|
});
|
|
|
|
it("validates cleanly", () => {
|
|
const terms = buildIssueInstrumentObligation({
|
|
instrument,
|
|
payor: "A",
|
|
payee: "B",
|
|
authorizedParticipants,
|
|
});
|
|
expect(validateObligationTerms(terms).ok).toBe(true);
|
|
});
|
|
});
|
|
});
|