From 89f3638098c3c4972e046736383df94bd87552e0 Mon Sep 17 00:00:00 2001 From: hartz0 Date: Sat, 17 Oct 2026 22:46:14 +0000 Subject: [PATCH] test: add test suites for batch atomicity, timeout/error handling, currency conversion, and webhook verification (#1042-#1045) - Add batch operations atomicity test suite covering success batch, partial failure, rollback coordination, and consistency invariants (#1045) - Add timeout and error handling test suite covering request timeouts, network error classification/recovery, XDR/JSON parse errors, and validation (#1044) - Add currency conversion accuracy test suite covering oracle price calculations, multi-asset precision preservation, split rounding auditor, and numerical edge cases (#1043) - Add webhook signature verification test suite covering HMAC-SHA256 digests, invalid signature rejection, replay protection, and nonce tracking (#1042) - Initialize eventHandlers and expiries in OptimisticCache to support cache lifecycle events during rollback --- src/cache/OptimisticCache.ts | 2 + test/batchOperationsAtomicity.test.ts | 356 +++++++++++++++++++ test/currencyConversionAccuracy.test.ts | 330 ++++++++++++++++++ test/timeoutAndErrorHandling.test.ts | 361 +++++++++++++++++++ test/webhookSignatureVerification.test.ts | 403 ++++++++++++++++++++++ 5 files changed, 1452 insertions(+) create mode 100644 test/batchOperationsAtomicity.test.ts create mode 100644 test/currencyConversionAccuracy.test.ts create mode 100644 test/timeoutAndErrorHandling.test.ts create mode 100644 test/webhookSignatureVerification.test.ts diff --git a/src/cache/OptimisticCache.ts b/src/cache/OptimisticCache.ts index d310c92..f249b59 100644 --- a/src/cache/OptimisticCache.ts +++ b/src/cache/OptimisticCache.ts @@ -162,6 +162,8 @@ export class OptimisticCache { /** Per-invoice FIFO queue of pending (uncommitted, unrolled-back) predictions. */ private readonly pending = new Map[]>(); private readonly rollbackHandlers = new Set<(event: RollbackEvent) => void>(); + private readonly eventHandlers = new Set<(event: CacheEvent) => void>(); + private readonly expiries = new Map(); private readonly versionCounters = new Map(); /** Freshness bookkeeping for values written through read()/set(). */ private readonly freshness = new Map>(); diff --git a/test/batchOperationsAtomicity.test.ts b/test/batchOperationsAtomicity.test.ts new file mode 100644 index 0000000..a3645a5 --- /dev/null +++ b/test/batchOperationsAtomicity.test.ts @@ -0,0 +1,356 @@ +/** + * Test Suite: Batch Operations Atomicity (#1045) + * + * Requirements: + * - Test success batch: all operations succeed and state updates atomically + * - Test partial failure: isolated failure handling without corrupting valid state + * - Test rollback: automatic and manual rollback restores prior consistent state + * - Test consistency: balance conservation, idempotency, and lifecycle invariants + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + RollbackCoordinator, + RollbackTimeoutError, +} from "../src/splitRollbackCoordinator.js"; +import { OptimisticCache } from "../src/cache/OptimisticCache.js"; +import { + InvoiceBatchProcessor, + type InvoicePaymentSubmitter, + type BatchInvoiceResult, +} from "../src/invoiceBatchProcessor.js"; +import { IdempotencyManager } from "../src/idempotency.js"; +import { auditSplitRounding, RoundingOverflowError } from "../src/invoice/rounding.js"; +import { UnknownSplitError, ValidationError } from "../src/errors.js"; +import type { SplitLine } from "../src/types.js"; + +describe("Batch Operations Atomicity (#1045)", () => { + describe("Requirement: Test success batch", () => { + it("successfully coordinates multi-leg split payment when all legs succeed", () => { + const coordinator = new RollbackCoordinator(); + const splitId = "split-success-1"; + const invoiceId = "inv-success-1"; + const legs = [ + { recipient: "G_RECIPIENT_A", amount: 500n }, + { recipient: "G_RECIPIENT_B", amount: 300n }, + { recipient: "G_RECIPIENT_C", amount: 200n }, + ]; + + const checkpoint = coordinator.begin(splitId, invoiceId, legs); + expect(checkpoint.splitId).toBe(splitId); + expect(checkpoint.invoiceId).toBe(invoiceId); + expect(checkpoint.legs).toHaveLength(3); + expect(checkpoint.legs.every((l) => l.state === "pending")).toBe(true); + + // Acknowledge all legs as succeeded + coordinator.markLegSuccess(splitId, 0); + coordinator.markLegSuccess(splitId, 1); + coordinator.markLegSuccess(splitId, 2); + + // Verify no legs remain incomplete + const incomplete = coordinator.getIncomplete(splitId); + expect(incomplete).toHaveLength(0); + + const updated = coordinator.getCheckpointFor(splitId); + expect(updated?.legs.every((l) => l.state === "succeeded")).toBe(true); + }); + + it("processes full invoice batch successfully with concurrency limits", async () => { + const settledEvents: BatchInvoiceResult[] = []; + const failedEvents: BatchInvoiceResult[] = []; + + const mockSubmitter: InvoicePaymentSubmitter = { + submitPayment: vi.fn(async ({ invoiceId }) => { + return { txHash: `tx_${invoiceId}` }; + }), + }; + + const processor = new InvoiceBatchProcessor(mockSubmitter); + processor.events.on("batchInvoiceSettled", (res) => settledEvents.push(res)); + processor.events.on("batchInvoiceFailed", (res) => failedEvents.push(res)); + + const invoiceIds = ["inv-1", "inv-2", "inv-3", "inv-4"]; + const amounts = { + "inv-1": 100n, + "inv-2": 200n, + "inv-3": 300n, + "inv-4": 400n, + }; + + const results: BatchInvoiceResult[] = []; + for await (const result of processor.process(invoiceIds, { + payer: "G_PAYER", + amounts, + maxConcurrent: 2, + })) { + results.push(result); + } + + expect(results).toHaveLength(4); + expect(results.every((r) => r.status === "success")).toBe(true); + expect(settledEvents).toHaveLength(4); + expect(failedEvents).toHaveLength(0); + expect(mockSubmitter.submitPayment).toHaveBeenCalledTimes(4); + }); + + it("applies and commits batch optimistic cache mutations cleanly", () => { + const cache = new OptimisticCache<{ balance: bigint }>(); + + const commit1 = cache.applyOptimistic("inv-1", { balance: 100n }, { balance: 0n }).commit; + const commit2 = cache.applyOptimistic("inv-2", { balance: 250n }, { balance: 50n }).commit; + const commit3 = cache.applyOptimistic("inv-3", { balance: 500n }, { balance: 100n }).commit; + + expect(cache.pendingCount).toBe(3); + expect(cache.get("inv-1")).toEqual({ balance: 100n }); + expect(cache.get("inv-2")).toEqual({ balance: 250n }); + expect(cache.get("inv-3")).toEqual({ balance: 500n }); + + commit1(); + commit2(); + commit3(); + + expect(cache.pendingCount).toBe(0); + expect(cache.get("inv-1")).toEqual({ balance: 100n }); + expect(cache.get("inv-2")).toEqual({ balance: 250n }); + expect(cache.get("inv-3")).toEqual({ balance: 500n }); + }); + }); + + describe("Requirement: Test partial failure", () => { + it("safely isolates and tracks incomplete legs when some legs fail", () => { + const coordinator = new RollbackCoordinator(); + const splitId = "split-partial-1"; + const legs = [ + { recipient: "G_REC_1", amount: 100n }, + { recipient: "G_REC_2", amount: 200n }, + { recipient: "G_REC_3", amount: 300n }, + ]; + + coordinator.begin(splitId, "inv-partial-1", legs); + + // Leg 0 succeeds, Leg 1 fails, Leg 2 is untouched + coordinator.markLegSuccess(splitId, 0); + coordinator.markLegFailed(splitId, 1); + + const incomplete = coordinator.getIncomplete(splitId); + expect(incomplete).toHaveLength(1); + expect(incomplete[0]?.recipient).toBe("G_REC_3"); + expect(incomplete[0]?.amount).toBe(300n); + + const checkpoint = coordinator.getCheckpointFor(splitId); + expect(checkpoint?.legs[0]?.state).toBe("succeeded"); + expect(checkpoint?.legs[1]?.state).toBe("failed"); + expect(checkpoint?.legs[2]?.state).toBe("pending"); + }); + + it("handles partial failure in invoice batch processor without aborting other operations", async () => { + const mockSubmitter: InvoicePaymentSubmitter = { + submitPayment: vi.fn(async ({ invoiceId }) => { + if (invoiceId === "inv-fail") { + throw new Error("RPC rejection for inv-fail"); + } + return { txHash: `tx_${invoiceId}` }; + }), + }; + + const processor = new InvoiceBatchProcessor(mockSubmitter); + const settled: BatchInvoiceResult[] = []; + const failed: BatchInvoiceResult[] = []; + + processor.events.on("batchInvoiceSettled", (res) => settled.push(res)); + processor.events.on("batchInvoiceFailed", (res) => failed.push(res)); + + const invoiceIds = ["inv-ok-1", "inv-fail", "inv-ok-2"]; + const amounts = { + "inv-ok-1": 100n, + "inv-fail": 200n, + "inv-ok-2": 300n, + }; + + const results: BatchInvoiceResult[] = []; + for await (const res of processor.process(invoiceIds, { + payer: "G_PAYER", + amounts, + maxConcurrent: 1, + })) { + results.push(res); + } + + expect(results).toHaveLength(3); + const successfulResults = results.filter((r) => r.status === "success"); + const failedResults = results.filter((r) => r.status === "failed"); + + expect(successfulResults).toHaveLength(2); + expect(failedResults).toHaveLength(1); + expect(failedResults[0]?.invoiceId).toBe("inv-fail"); + expect(failedResults[0]?.error).toContain("RPC rejection for inv-fail"); + + expect(settled).toHaveLength(2); + expect(failed).toHaveLength(1); + }); + + it("rejects unknown leg indices and unknown splits safely", () => { + const coordinator = new RollbackCoordinator(); + coordinator.begin("split-known", "inv-1", [{ recipient: "G_A", amount: 10n }]); + + expect(() => coordinator.markLegSuccess("split-unknown", 0)).toThrow(UnknownSplitError); + expect(() => coordinator.markLegSuccess("split-known", 99)).toThrow(UnknownSplitError); + expect(() => coordinator.getIncomplete("split-nonexistent")).toThrow(UnknownSplitError); + }); + }); + + describe("Requirement: Test rollback", () => { + it("initiates rollback snapshot for all incomplete legs and emits events", () => { + const coordinator = new RollbackCoordinator(); + const splitId = "split-rollback-1"; + const legs = [ + { recipient: "G_A", amount: 100n }, + { recipient: "G_B", amount: 200n }, + { recipient: "G_C", amount: 300n }, + ]; + + coordinator.begin(splitId, "inv-rb", legs); + coordinator.markLegSuccess(splitId, 0); + + const initiatedEvents: unknown[] = []; + const completedEvents: unknown[] = []; + coordinator.on("splitRollbackInitiated", (e) => initiatedEvents.push(e)); + coordinator.on("splitRollbackCompleted", (e) => completedEvents.push(e)); + + const record = coordinator.initiateRollback(splitId); + + expect(record).toBeDefined(); + expect(record.checkpoint.splitId).toBe(splitId); + expect(record.checkpoint.legs.filter((l) => l.state === "pending")).toHaveLength(2); + expect(record.snapshotId).toBeDefined(); + + expect(initiatedEvents).toHaveLength(1); + expect(completedEvents).toHaveLength(1); + expect(coordinator.getRollbackRecord(splitId)).toEqual(record); + + // Rollback initiation is idempotent + const duplicateRecord = coordinator.initiateRollback(splitId); + expect(duplicateRecord).toEqual(record); + expect(initiatedEvents).toHaveLength(1); // not emitted again + }); + + it("rolls back optimistic cache state on failure while keeping unaffected keys intact", () => { + const cache = new OptimisticCache<{ balance: bigint }>(); + + // Seed initial base state + const { commit: commitBase } = cache.applyOptimistic( + "inv-stable", + { balance: 50n }, + { balance: 0n } + ); + commitBase(); + + const rollbackEvents: unknown[] = []; + cache.onRollback((e) => rollbackEvents.push(e)); + + const opA = cache.applyOptimistic("inv-stable", { balance: 100n }, { balance: 50n }); + const opB = cache.applyOptimistic("inv-failing", { balance: 200n }, { balance: 0n }); + + expect(cache.get("inv-stable")).toEqual({ balance: 100n }); + expect(cache.get("inv-failing")).toEqual({ balance: 200n }); + + // Roll back failing operation + opB.rollback(); + + expect(cache.get("inv-failing")).toEqual({ balance: 0n }); + expect(cache.get("inv-stable")).toEqual({ balance: 100n }); // unaffected + expect(rollbackEvents).toHaveLength(1); + + // Rolling back again is safe idempotent no-op + opB.rollback(); + expect(rollbackEvents).toHaveLength(1); + }); + + it("rolls back chained optimistic mutations in LIFO order preserving intermediate states", () => { + const cache = new OptimisticCache<{ count: number }>(); + + const op1 = cache.applyOptimistic("item-1", { count: 1 }, { count: 0 }); + const op2 = cache.applyOptimistic("item-1", { count: 2 }, { count: 1 }); + const op3 = cache.applyOptimistic("item-1", { count: 3 }, { count: 2 }); + + expect(cache.get("item-1")).toEqual({ count: 3 }); + + // Rolling back newest mutation (op3) restores op2's predicted value + op3.rollback(); + expect(cache.get("item-1")).toEqual({ count: 2 }); + + // Rolling back op2 restores op1's predicted value + op2.rollback(); + expect(cache.get("item-1")).toEqual({ count: 1 }); + + // Rolling back op1 restores base value + op1.rollback(); + expect(cache.get("item-1")).toEqual({ count: 0 }); + }); + + it("cleans up partial state when rollback execution times out", async () => { + vi.useFakeTimers(); + const coordinator = new RollbackCoordinator(); + const splitId = "split-timeout-1"; + coordinator.begin(splitId, "inv-t", [{ recipient: "G_DEST", amount: 50n }]); + + const cleanupSpy = vi.fn(); + const timeoutPromise = coordinator.initiateRollbackWithTimeout(splitId, { + timeoutMs: 150, + execute: () => new Promise(() => undefined), // never resolves + cleanup: cleanupSpy, + }); + + const assertRejection = expect(timeoutPromise).rejects.toBeInstanceOf(RollbackTimeoutError); + await vi.advanceTimersByTimeAsync(150); + await assertRejection; + + expect(cleanupSpy).toHaveBeenCalledTimes(1); + expect(coordinator.getRollbackRecord(splitId)).toBeUndefined(); + expect(coordinator.getCheckpointFor(splitId)).toBeUndefined(); + + vi.useRealTimers(); + }); + }); + + describe("Requirement: Test consistency", () => { + it("preserves exact balance conservation across split legs (no stroops created or lost)", () => { + const totalAmount = 1000n; + const splits: SplitLine[] = [ + { recipientId: "G_A", ratio: 0.3333333 }, + { recipientId: "G_B", ratio: 0.3333333 }, + { recipientId: "G_C", ratio: 0.3333334 }, + ]; + + const audited = auditSplitRounding(totalAmount, splits); + const sumOfAmounts = Object.values(audited.amounts).reduce((acc, v) => acc + v, 0n); + + expect(sumOfAmounts).toBe(totalAmount); + expect(audited.total).toBe(totalAmount); + }); + + it("enforces idempotency preventing duplicate execution or duplicate rollbacks", () => { + const idempotency = new IdempotencyManager(); + const coordinator = new RollbackCoordinator(idempotency); + + const splitId = "split-idempotent"; + coordinator.begin(splitId, "inv-idem", [{ recipient: "G_REC", amount: 100n }]); + + const record1 = coordinator.initiateRollback(splitId); + const record2 = coordinator.initiateRollback(splitId); + + expect(record1.snapshotId).toBe(record2.snapshotId); + expect(record1).toBe(record2); + }); + + it("detects and rejects invalid split ratios that exceed rounding threshold", () => { + // Ratios sum to 1.5 (drastically inconsistent) + const splits: SplitLine[] = [ + { recipientId: "G_A", ratio: 0.8 }, + { recipientId: "G_B", ratio: 0.7 }, + ]; + + expect(() => auditSplitRounding(1000n, splits)).toThrow(RoundingOverflowError); + }); + }); +}); diff --git a/test/currencyConversionAccuracy.test.ts b/test/currencyConversionAccuracy.test.ts new file mode 100644 index 0000000..2b612b6 --- /dev/null +++ b/test/currencyConversionAccuracy.test.ts @@ -0,0 +1,330 @@ +/** + * Test Suite: Currency Conversion Accuracy (#1043) + * + * Requirements: + * - Test conversions: Oracle price conversions, rate calculation, fiat-to-asset conversion + * - Test precision: Decimal preservation across XLM (7 decimals) and custom token precisions + * - Test rounding: Largest-remainder split division, exact sum invariants, and overflow thresholds + * - Test edge cases: Zero amounts, single stroop, large BigInt values, repeating decimals + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@stellar/stellar-sdk", async () => { + const actual = await vi.importActual("@stellar/stellar-sdk"); + return { + ...actual, + Contract: class MockContract { + constructor(_id: string) {} + call(..._args: any[]) { return {}; } + }, + TransactionBuilder: class MockTxBuilder { + constructor(_source: any, _opts: any) {} + addOperation(_op: any) { return this; } + setTimeout(_s: number) { return this; } + build() { return {}; } + }, + scValToNative: vi.fn((_val: any) => _val?.value ?? "0"), + nativeToScVal: vi.fn((...args: any[]) => args[0]), + }; +}); + +import { Asset, rpc as SorobanRpc } from "@stellar/stellar-sdk"; +import { + convertAmount, + convertFiatToAsset, + clearPriceCache, +} from "../src/currencyConverter.js"; +import { + normalizeAmount, + toOnChainAmount, + registerAssetPrecision, + getAssetPrecision, + verifyRoundTrip, +} from "../src/currencyNormalizer.js"; +import { auditSplitRounding, RoundingOverflowError } from "../src/invoice/rounding.js"; +import { OraclePriceError, PrecisionError } from "../src/errors.js"; +import type { SplitLine } from "../src/types.js"; + +const TEST_NETWORK = "Test SDF Network ; September 2015"; +const TEST_ORACLE_ADDR = "GORACLE_ADDR"; + +function createMockRpcServer(rate: bigint): SorobanRpc.Server { + return { + simulateTransaction: vi.fn().mockResolvedValue({ + result: { + retval: { value: rate.toString() }, + }, + } as unknown as SorobanRpc.Api.SimulateTransactionSuccessResponse), + } as unknown as SorobanRpc.Server; +} + +describe("Currency Conversion Accuracy (#1043)", () => { + beforeEach(() => { + clearPriceCache(); + vi.restoreAllMocks(); + }); + + describe("Requirement: Test conversions", () => { + it("accurately converts asset amount using 18-decimal fixed-point oracle rate (> 1.0)", async () => { + // Rate: 2.50 USD per token -> 2.5 * 10^18 = 2_500_000_000_000_000_000n + const rate = 2_500_000_000_000_000_000n; + const server = createMockRpcServer(rate); + + // Convert 10,000,000 stroops (1 XLM) + const inputAmount = 10_000_000n; + const result = await convertAmount( + inputAmount, + "XLM", + "USD", + TEST_ORACLE_ADDR, + server, + TEST_NETWORK + ); + + expect(result.original).toBe(inputAmount); + // 10_000_000n * 2.5 = 25_000_000n + expect(result.converted).toBe(25_000_000n); + expect(result.rate).toBe(rate); + expect(result.fromToken).toBe("XLM"); + expect(result.toDisplayCurrency).toBe("USD"); + }); + + it("accurately converts asset amount using fractional rate (< 1.0)", async () => { + // Rate: 0.125 USD per token -> 0.125 * 10^18 = 125_000_000_000_000_000n + const rate = 125_000_000_000_000_000n; + const server = createMockRpcServer(rate); + + const inputAmount = 80_000_000n; + const result = await convertAmount( + inputAmount, + "XLM", + "USD", + TEST_ORACLE_ADDR, + server, + TEST_NETWORK + ); + + // 80_000_000n * 0.125 = 10_000_000n + expect(result.converted).toBe(10_000_000n); + }); + + it("accurately converts at exact 1:1 parity", async () => { + // Rate: 1.000000000000000000 (10^18) + const rate = 1_000_000_000_000_000_000n; + const server = createMockRpcServer(rate); + + const inputAmount = 45_678_901n; + const result = await convertAmount( + inputAmount, + "USDC", + "USD", + TEST_ORACLE_ADDR, + server, + TEST_NETWORK + ); + + expect(result.converted).toBe(inputAmount); + }); + + it("converts fiat amounts to crypto assets using price oracle adapter", async () => { + const mockOracle = { + getPrice: vi.fn().mockResolvedValue(0.25), // 1 XLM = $0.25 USD + }; + + const result = await convertFiatToAsset(100, "USD", "XLM", mockOracle); + + expect(result.fiatAmount).toBe(100); + expect(result.fiatCurrency).toBe("USD"); + expect(result.assetAmount).toBe(400); // $100 / $0.25 = 400 XLM + expect(result.assetCode).toBe("XLM"); + expect(result.rate).toBe(0.25); + }); + + it("rejects non-positive oracle conversion rates with OraclePriceError", async () => { + const zeroRateOracle = { + getPrice: vi.fn().mockResolvedValue(0), + }; + await expect(convertFiatToAsset(100, "USD", "XLM", zeroRateOracle)).rejects.toThrow( + OraclePriceError + ); + + const negativeRateOracle = { + getPrice: vi.fn().mockResolvedValue(-1.5), + }; + await expect(convertFiatToAsset(100, "USD", "XLM", negativeRateOracle)).rejects.toThrow( + OraclePriceError + ); + }); + }); + + describe("Requirement: Test precision", () => { + it("maintains exact 7-decimal precision for native XLM without floating point drift", () => { + expect(normalizeAmount(10_000_000n, Asset.native())).toBe("1.0000000"); + expect(normalizeAmount(1n, Asset.native())).toBe("0.0000001"); + expect(normalizeAmount(12_345_678n, Asset.native())).toBe("1.2345678"); + expect(normalizeAmount(100_000_000n, Asset.native())).toBe("10.0000000"); + }); + + it("converts display format back to on-chain stroops precisely", () => { + expect(toOnChainAmount("1.0000000", Asset.native())).toBe("10000000"); + expect(toOnChainAmount("0.0000001", Asset.native())).toBe("1"); + expect(toOnChainAmount("1.5", Asset.native())).toBe("15000000"); + expect(toOnChainAmount("100", Asset.native())).toBe("1000000000"); + }); + + it("supports custom token precisions (6 decimals for USDC, 2 for fiat, 0 for points)", () => { + registerAssetPrecision("USDC_CANONICAL", 6); + registerAssetPrecision("FIAT_CENT", 2); + registerAssetPrecision("REWARD_POINTS", 0); + + expect(getAssetPrecision("USDC_CANONICAL")).toBe(6); + expect(getAssetPrecision("FIAT_CENT")).toBe(2); + expect(getAssetPrecision("REWARD_POINTS")).toBe(0); + + expect(normalizeAmount(1_500_000n, "USDC_CANONICAL")).toBe("1.500000"); + expect(toOnChainAmount("1.5", "USDC_CANONICAL")).toBe("1500000"); + + expect(normalizeAmount(250n, "FIAT_CENT")).toBe("2.50"); + expect(toOnChainAmount("2.50", "FIAT_CENT")).toBe("250"); + + expect(normalizeAmount(100n, "REWARD_POINTS")).toBe("100"); + expect(toOnChainAmount("100", "REWARD_POINTS")).toBe("100"); + }); + + it("verifies lossless round-trip accuracy across a wide range of amounts", () => { + expect(verifyRoundTrip("1.5000000", Asset.native())).toBe(true); + expect(verifyRoundTrip("0.0000001", Asset.native())).toBe(true); + expect(verifyRoundTrip("999999.9999999", Asset.native())).toBe(true); + expect(verifyRoundTrip("42", Asset.native())).toBe(true); + }); + + it("rejects input amounts with fractional precision exceeding asset capacity", () => { + // XLM supports up to 7 decimal digits; 8 digits must throw PrecisionError + expect(() => toOnChainAmount("1.12345678", Asset.native())).toThrow(PrecisionError); + expect(verifyRoundTrip("1.12345678", Asset.native())).toBe(false); + + // USDC supports 6 decimal digits; 7 digits must throw PrecisionError + expect(() => toOnChainAmount("1.1234567", "USDC_CANONICAL")).toThrow(PrecisionError); + }); + + it("throws PrecisionError for unregistered custom assets", () => { + expect(() => getAssetPrecision("UNKNOWN_TOKEN")).toThrow(PrecisionError); + expect(() => normalizeAmount(100n, "UNKNOWN_TOKEN")).toThrow(PrecisionError); + }); + }); + + describe("Requirement: Test rounding", () => { + it("handles exact division with zero rounding adjustments", () => { + const splits: SplitLine[] = [ + { recipientId: "G_A", ratio: 0.5 }, + { recipientId: "G_B", ratio: 0.5 }, + ]; + + const result = auditSplitRounding(100n, splits); + expect(result.total).toBe(100n); + expect(result.amounts["G_A"]).toBe(50n); + expect(result.amounts["G_B"]).toBe(50n); + expect(result.adjustments).toHaveLength(0); + }); + + it("uses largest-remainder method for uneven 3-way split preserving exact total sum", () => { + // 100 stroops among 3 recipients with 1/3 ratio each + // Floor: 33 + 33 + 33 = 99 (1 stroop remainder to distribute) + const splits: SplitLine[] = [ + { recipientId: "G_A", ratio: 0.3333333 }, + { recipientId: "G_B", ratio: 0.3333333 }, + { recipientId: "G_C", ratio: 0.3333334 }, // has highest ratio/remainder + ]; + + const result = auditSplitRounding(100n, splits); + const sum = Object.values(result.amounts).reduce((a, b) => a + b, 0n); + + expect(sum).toBe(100n); + expect(result.total).toBe(100n); + // Highest remainder gets 34, others get 33 + expect(result.amounts["G_C"]).toBe(34n); + expect(result.amounts["G_A"]).toBe(33n); + expect(result.amounts["G_B"]).toBe(33n); + expect(result.adjustments).toHaveLength(1); + expect(result.adjustments[0]?.recipientId).toBe("G_C"); + expect(result.adjustments[0]?.delta).toBe(1n); + }); + + it("handles minimal 1-stroop total distributed among 3 recipients", () => { + const splits: SplitLine[] = [ + { recipientId: "G_1", ratio: 0.4 }, + { recipientId: "G_2", ratio: 0.3 }, + { recipientId: "G_3", ratio: 0.3 }, + ]; + + const result = auditSplitRounding(1n, splits); + const sum = Object.values(result.amounts).reduce((a, b) => a + b, 0n); + + expect(sum).toBe(1n); + expect(result.amounts["G_1"]).toBe(1n); + expect(result.amounts["G_2"]).toBe(0n); + expect(result.amounts["G_3"]).toBe(0n); + }); + + it("throws RoundingOverflowError when cumulative adjustment exceeds threshold", () => { + // Bad ratios that do not sum close to 1.0 (sum = 1.6) + const splits: SplitLine[] = [ + { recipientId: "G_A", ratio: 0.8 }, + { recipientId: "G_B", ratio: 0.8 }, + ]; + + expect(() => auditSplitRounding(1000n, splits)).toThrow(RoundingOverflowError); + }); + }); + + describe("Requirement: Test edge cases", () => { + it("handles zero amount cleanly across all conversion and normalization methods", async () => { + const server = createMockRpcServer(2_000_000_000_000_000_000n); + const converted = await convertAmount(0n, "XLM", "USD", TEST_ORACLE_ADDR, server, TEST_NETWORK); + expect(converted.converted).toBe(0n); + + expect(normalizeAmount(0n, Asset.native())).toBe("0.0000000"); + expect(toOnChainAmount("0", Asset.native())).toBe("0"); + + const splits: SplitLine[] = [ + { recipientId: "G_A", ratio: 0.6 }, + { recipientId: "G_B", ratio: 0.4 }, + ]; + const rounded = auditSplitRounding(0n, splits); + expect(rounded.total).toBe(0n); + expect(rounded.amounts["G_A"]).toBe(0n); + expect(rounded.amounts["G_B"]).toBe(0n); + }); + + it("handles 1 stroop conversion and normalization", async () => { + const server = createMockRpcServer(1_000_000_000_000_000_000n); // 1.0x rate + const converted = await convertAmount(1n, "XLM", "USD", TEST_ORACLE_ADDR, server, TEST_NETWORK); + expect(converted.converted).toBe(1n); + + expect(normalizeAmount(1n, Asset.native())).toBe("0.0000001"); + expect(toOnChainAmount("0.0000001", Asset.native())).toBe("1"); + }); + + it("accurately handles very large amounts without BigInt or float overflow", async () => { + // 100 billion XLM = 100_000_000_000 * 10^7 = 10^18 stroops + const hugeAmount = 1_000_000_000_000_000_000n; + const rate = 3_000_000_000_000_000_000n; // 3.0x rate + const server = createMockRpcServer(rate); + + const converted = await convertAmount(hugeAmount, "XLM", "USD", TEST_ORACLE_ADDR, server, TEST_NETWORK); + expect(converted.converted).toBe(3_000_000_000_000_000_000n); + + const normalized = normalizeAmount(hugeAmount, Asset.native()); + expect(normalized).toBe("100000000000.0000000"); + expect(toOnChainAmount(normalized, Asset.native())).toBe(hugeAmount.toString()); + }); + + it("handles integer truncation correctly for tiny fractional values", async () => { + // 1 stroop with 0.5x rate -> (1n * 5e17) / 1e18 = 0n (integer floor) + const server = createMockRpcServer(500_000_000_000_000_000n); + const converted = await convertAmount(1n, "XLM", "USD", TEST_ORACLE_ADDR, server, TEST_NETWORK); + expect(converted.converted).toBe(0n); + }); + }); +}); diff --git a/test/timeoutAndErrorHandling.test.ts b/test/timeoutAndErrorHandling.test.ts new file mode 100644 index 0000000..e6fff14 --- /dev/null +++ b/test/timeoutAndErrorHandling.test.ts @@ -0,0 +1,361 @@ +/** + * Test Suite: Timeout and Error Handling (#1044) + * + * Requirements: + * - Test timeouts: Request timeouts trigger, abort signals fire, per-method overrides + * - Test network errors: Transient RPC failures, classification, backoff & recovery + * - Test parse errors: Malformed XDR envelopes, invalid JSON, and invalid hex handling + * - Test validation: Input validation, clear error messages, and successful recovery + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; +import { + withTimeout, + withTimeoutOrThrow, + TimeoutManager, + RequestTimeoutError, + EscalationManager, +} from "../src/timeout.js"; +import { + RpcError, + RpcConnectionError, + RpcUnavailableError, + ValidationError, + InvoiceNotFoundError, + StellarSplitError, +} from "../src/errors.js"; +import { executeWithRetry, isRetryable } from "../src/retryPolicy.js"; +import { parseEnvelope } from "../src/xdrParser.js"; +import { FallbackChain } from "../src/fallbackChain.js"; + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("Timeout and Error Handling (#1044)", () => { + describe("Requirement: Test timeouts", () => { + it("triggers RequestTimeoutError when an async operation exceeds timeout", async () => { + vi.useFakeTimers(); + + const slowOperation = () => new Promise(() => undefined); + const promise = withTimeout(slowOperation, 200, "simulateTransaction"); + + vi.advanceTimersByTime(250); + + const result = await promise; + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe("timeout"); + expect(result.error).toBeInstanceOf(RequestTimeoutError); + expect(result.error?.method).toBe("simulateTransaction"); + expect(result.error?.timeoutMs).toBe(200); + expect(result.error?.message).toContain("Request timed out after 200ms (method: simulateTransaction)"); + } + }); + + it("withTimeoutOrThrow throws RequestTimeoutError when timeout triggers", async () => { + vi.useFakeTimers(); + + const slowOperation = () => new Promise(() => undefined); + const promise = withTimeoutOrThrow(slowOperation, 100, "sendTransaction"); + + vi.advanceTimersByTime(150); + + await expect(promise).rejects.toThrow(RequestTimeoutError); + await expect(promise).rejects.toMatchObject({ + method: "sendTransaction", + timeoutMs: 100, + code: "REQUEST_TIMEOUT", + }); + }); + + it("aborts the AbortSignal when the timeout triggers", async () => { + vi.useFakeTimers(); + + let signalWasAborted = false; + const promise = withTimeout( + async (signal) => { + signal.addEventListener("abort", () => { + signalWasAborted = true; + }); + return new Promise(() => undefined); + }, + 300, + "fetchEvents" + ); + + expect(signalWasAborted).toBe(false); + vi.advanceTimersByTime(350); + + await promise; + expect(signalWasAborted).toBe(true); + }); + + it("clears timeout cleanly when operation completes fast", async () => { + vi.useFakeTimers(); + + const fastOperation = async () => "quick_success"; + const result = await withTimeout(fastOperation, 5000, "quickOp"); + + expect(result).toEqual({ ok: true, value: "quick_success" }); + expect(vi.getTimerCount()).toBe(0); + }); + + it("resolves per-method timeouts and respects default fallback", () => { + const tm = new TimeoutManager({ + default: 8000, + pay: 20000, + batchPay: 30000, + }); + + expect(tm.resolveTimeout("pay")).toBe(20000); + expect(tm.resolveTimeout("batchPay")).toBe(30000); + expect(tm.resolveTimeout("getInvoice")).toBe(8000); // fallback to default + }); + + it("triggers escalation warnings before deadline expires", () => { + vi.useFakeTimers(); + + const onEvent = vi.fn(); + const manager = new EscalationManager( + { + deadlineMs: 5000, + escalations: [ + { triggerAtMs: 2000, action: "warn", target: "team-lead" }, + { triggerAtMs: 1000, action: "notify", target: "billing-ops" }, + ], + }, + { onEvent } + ); + + manager.start("inv-escalate-1"); + + // delayMs for step 1 = 5000 - 2000 = 3000ms + vi.advanceTimersByTime(3100); + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ + step: "warn", + invoiceId: "inv-escalate-1", + remainingMs: 2000, + }) + ); + + // delayMs for step 2 = 5000 - 1000 = 4000ms + vi.advanceTimersByTime(1000); + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ + step: "notify", + invoiceId: "inv-escalate-1", + remainingMs: 1000, + }) + ); + + manager.cancel(); + }); + }); + + describe("Requirement: Test network errors", () => { + it("correctly classifies retryable vs non-retryable network errors", () => { + // 503 Service Unavailable -> retryable + const err503 = new RpcError("Service Unavailable", 503); + expect(isRetryable(err503)).toBe(true); + + // 429 Too Many Requests -> retryable + const err429 = new RpcError("Rate Limited", 429); + expect(isRetryable(err429)).toBe(true); + + // Network connection errors -> retryable + const connectionErr = new RpcConnectionError("http://localhost:8000", new Error("ECONNREFUSED")); + expect(isRetryable(connectionErr)).toBe(true); + + const timeoutErr = new Error("Network timeout while calling RPC"); + expect(isRetryable(timeoutErr)).toBe(true); + + // 400 Bad Request / 404 Not Found -> non-retryable + const err400 = new RpcError("Bad Request", 400); + const err404 = new RpcError("Not Found", 404); + expect(isRetryable(err400)).toBe(false); + expect(isRetryable(err404)).toBe(false); + + // Semantic business errors -> non-retryable + const notFoundErr = new InvoiceNotFoundError("inv-missing"); + expect(isRetryable(notFoundErr)).toBe(false); + }); + + it("recovers from transient network errors via executeWithRetry", async () => { + let attempts = 0; + const retryLog: number[] = []; + + const unreliableNetworkCall = vi.fn(async () => { + attempts++; + if (attempts < 3) { + throw new RpcError("Service temporarily unavailable", 503); + } + return "recovered_data"; + }); + + const result = await executeWithRetry(unreliableNetworkCall, { + maxAttempts: 4, + baseDelayMs: 10, + maxDelayMs: 50, + onRetry: (attempt) => { + retryLog.push(attempt); + }, + }); + + expect(result).toBe("recovered_data"); + expect(attempts).toBe(3); + expect(retryLog).toEqual([1, 2]); + }); + + it("terminates when maximum retry attempts are exceeded", async () => { + const alwaysFails = vi.fn(async () => { + throw new RpcError("Connection timed out", 503); + }); + + await expect( + executeWithRetry(alwaysFails, { + maxAttempts: 3, + baseDelayMs: 5, + maxDelayMs: 10, + }) + ).rejects.toThrow("Connection timed out"); + + expect(alwaysFails).toHaveBeenCalledTimes(3); + }); + + it("recovers via FallbackChain when primary network endpoint fails", async () => { + const loggerSpy = vi.fn(); + const chain = new FallbackChain( + ["https://primary.rpc.stellar", "https://backup.rpc.stellar"], + { logger: loggerSpy } + ); + + const result = await chain.execute(async (url) => { + if (url.includes("primary")) { + throw new RpcUnavailableError(url); + } + return { id: "inv-backup", status: "Pending", servedBy: url }; + }); + + expect(result).toEqual({ + id: "inv-backup", + status: "Pending", + servedBy: "https://backup.rpc.stellar", + }); + expect(loggerSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe("Requirement: Test parse errors", () => { + it("throws descriptive error when parsing invalid base64 XDR", () => { + expect(() => parseEnvelope("invalid-not-base64-xdr!@#$")).toThrow(); + }); + + it("throws descriptive error when parsing empty or truncated XDR string", () => { + expect(() => parseEnvelope("")).toThrow(); + expect(() => parseEnvelope("AAAA")).toThrow(); + }); + + it("handles JSON payload parse failure gracefully with clear message", () => { + const malformedJson = '{"invoiceId": 123, unquoted: true,}'; + + expect(() => { + try { + JSON.parse(malformedJson); + } catch (err) { + throw new ValidationError("Payload is not valid JSON", { + error: err instanceof Error ? err.message : String(err), + }); + } + }).toThrowError(/Payload is not valid JSON/); + }); + + it("validates hex parsing and rejects odd-length or non-hex input with clear error", () => { + const parseHex = (hex: string) => { + if (hex.length % 2 !== 0) { + throw new ValidationError("Invalid hex string length", { length: hex.length }); + } + if (!/^[0-9a-fA-F]*$/.test(hex)) { + throw new ValidationError("Invalid hex character sequence", { hex }); + } + return Buffer.from(hex, "hex"); + }; + + expect(() => parseHex("abc")).toThrow(ValidationError); + expect(() => parseHex("abc")).toThrow("Invalid hex string length"); + + expect(() => parseHex("zz12")).toThrow(ValidationError); + expect(() => parseHex("zz12")).toThrow("Invalid hex character sequence"); + + expect(parseHex("deadbeef")).toHaveLength(4); + }); + }); + + describe("Requirement: Test validation", () => { + it("validates payment amounts and rejects negative or zero amounts", () => { + const validatePaymentAmount = (amount: bigint) => { + if (amount <= 0n) { + throw new ValidationError("Payment amount must be greater than zero", { amount: amount.toString() }); + } + }; + + expect(() => validatePaymentAmount(0n)).toThrow(ValidationError); + expect(() => validatePaymentAmount(0n)).toThrow("Payment amount must be greater than zero"); + expect(() => validatePaymentAmount(-50n)).toThrow(ValidationError); + + // Valid amount passes + expect(() => validatePaymentAmount(100n)).not.toThrow(); + }); + + it("validates Stellar public keys and addresses", () => { + const validateStellarAddress = (address: string) => { + if (!address || typeof address !== "string") { + throw new ValidationError("Stellar address is required"); + } + if (!address.startsWith("G") || address.length !== 56) { + throw new ValidationError(`Invalid Stellar public key address: ${address}`, { address }); + } + }; + + expect(() => validateStellarAddress("")).toThrow("Stellar address is required"); + expect(() => validateStellarAddress("INVALID_ADDRESS")).toThrow("Invalid Stellar public key address"); + expect(() => validateStellarAddress("G123")).toThrow("Invalid Stellar public key address"); + + const validGAddress = "GA2HGBJIJKI6O4XJJ7HI7OINUI42QIWDN4O7EBITCXFH5GYECOEB7W5L"; + expect(() => validateStellarAddress(validGAddress)).not.toThrow(); + }); + + it("demonstrates recovery by correcting invalid parameters after catching ValidationError", () => { + let submissionResult = ""; + + const attemptSubmission = (address: string, amount: bigint) => { + if (!address.startsWith("G") || address.length !== 56) { + throw new ValidationError("Invalid address", { address }); + } + if (amount <= 0n) { + throw new ValidationError("Invalid amount", { amount: amount.toString() }); + } + return `success:${address}:${amount}`; + }; + + // 1. Initial attempt fails validation + let caughtError: unknown = null; + try { + attemptSubmission("bad_address", -10n); + } catch (err) { + caughtError = err; + } + + expect(caughtError).toBeInstanceOf(ValidationError); + + // 2. Caller inspects error context and recovers with valid parameters + const validAddress = "GA2HGBJIJKI6O4XJJ7HI7OINUI42QIWDN4O7EBITCXFH5GYECOEB7W5L"; + const validAmount = 500n; + + submissionResult = attemptSubmission(validAddress, validAmount); + expect(submissionResult).toBe(`success:${validAddress}:500`); + }); + }); +}); diff --git a/test/webhookSignatureVerification.test.ts b/test/webhookSignatureVerification.test.ts new file mode 100644 index 0000000..2f10c1d --- /dev/null +++ b/test/webhookSignatureVerification.test.ts @@ -0,0 +1,403 @@ +/** + * Test Suite: Webhook Signature Verification (#1042) + * + * Requirements: + * - Test valid signatures: HMAC-SHA256 signature computation, verification, and prefix handling + * - Test invalid signatures: Mismatched secrets, tampered payloads, malformed hex, and timing safety + * - Test replay protection: Timestamp tolerance, clock skew windows, and duplicate submission rejection + * - Test nonce tracking: Nonce deduplication cache, LRU capacity eviction, and replay store tracking + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createHmac } from "crypto"; +import { + WebhookSignatureValidator, + computeSignature, + safeCompare, + buildSignedPayload, + verifyWebhookSignature as verifyValidatorSignature, +} from "../src/webhookSignatureValidator.js"; +import { verifyWebhookSignature as verifyRawWebhookSignature } from "../src/webhooks/verify.js"; +import { validateWebhook, WebhookSignatureError } from "../src/webhookValidator.js"; +import { + RingBufferStore, + recordWebhookEvent, + configureReplayStore, + WebhookEventNotFoundError, +} from "../src/webhookReplay.js"; + +const TEST_SECRET = "stellar_webhook_secret_key_abcdef123456"; +const ALT_SECRET = "different_secret_key_xyz987654"; + +function hmacSha256(secret: string, body: string | Buffer): string { + return createHmac("sha256", secret).update(body).digest("hex"); +} + +describe("Webhook Signature Verification (#1042)", () => { + beforeEach(() => { + configureReplayStore(new RingBufferStore(50)); + vi.restoreAllMocks(); + }); + + describe("Requirement: Test valid signatures", () => { + it("computes accurate HMAC-SHA256 hex signature", () => { + const payload = JSON.stringify({ event: "invoice.paid", invoiceId: "inv-101", amount: "5000" }); + const computed = computeSignature(payload, TEST_SECRET, "sha256"); + + expect(computed).toHaveLength(64); + expect(computed).toMatch(/^[0-9a-f]{64}$/); + expect(computed).toBe(hmacSha256(TEST_SECRET, payload)); + }); + + it("verifies valid signature with WebhookSignatureValidator (string body)", () => { + const validator = new WebhookSignatureValidator({ secret: TEST_SECRET }); + const body = JSON.stringify({ event: "invoice.created", invoiceId: "inv-202" }); + const signature = hmacSha256(TEST_SECRET, body); + + const result = validator.verify({ body, signature }); + expect(result.valid).toBe(true); + expect(result.reason).toBeUndefined(); + }); + + it("verifies valid signature with Buffer body", () => { + const validator = new WebhookSignatureValidator({ secret: TEST_SECRET }); + const bodyStr = JSON.stringify({ event: "invoice.refunded", amount: "100" }); + const bodyBuf = Buffer.from(bodyStr, "utf8"); + const signature = hmacSha256(TEST_SECRET, bodyBuf); + + const result = validator.verify({ body: bodyBuf, signature }); + expect(result.valid).toBe(true); + }); + + it("verifies valid signatures with custom prefix (e.g., sha256= or hmac-sha256=)", () => { + const validator = new WebhookSignatureValidator({ + secret: TEST_SECRET, + signaturePrefix: "sha256=", + }); + const body = JSON.stringify({ event: "invoice.released" }); + const signature = `sha256=${hmacSha256(TEST_SECRET, body)}`; + + const result = validator.verify({ body, signature }); + expect(result.valid).toBe(true); + }); + + it("emits webhook.validated event on successful verification", () => { + const validator = new WebhookSignatureValidator({ secret: TEST_SECRET }); + const eventSpy = vi.fn(); + const unsubscribe = validator.on(eventSpy); + + const body = JSON.stringify({ event: "invoice.paid" }); + const signature = hmacSha256(TEST_SECRET, body); + + validator.verify({ body, signature }); + + expect(eventSpy).toHaveBeenCalledTimes(1); + expect(eventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: "webhook.validated", + timestamp: expect.any(Number), + }) + ); + + unsubscribe(); + validator.verify({ body, signature }); + expect(eventSpy).toHaveBeenCalledTimes(1); // not called after unsubscribe + }); + + it("verifies signature with verifyWebhookSignature from webhooks/verify", () => { + const rawBody = JSON.stringify({ event: "invoice.settled", id: 1 }); + const signature = hmacSha256(TEST_SECRET, rawBody); + + const isValid = verifyRawWebhookSignature(TEST_SECRET, rawBody, signature); + expect(isValid).toBe(true); + }); + + it("verifies signature with validateWebhook from webhookValidator", () => { + const rawBody = JSON.stringify({ event: "invoice.paid", stroops: 1000n.toString() }); + const signature = `hmac-sha256=${hmacSha256(TEST_SECRET, rawBody)}`; + + expect(() => + validateWebhook({ event: "invoice.paid" }, rawBody, TEST_SECRET, signature) + ).not.toThrow(); + + // Opt-out mode (secret = null) skips verification + expect(() => + validateWebhook({}, rawBody, null, "any-bogus-signature") + ).not.toThrow(); + }); + }); + + describe("Requirement: Test invalid signatures", () => { + it("rejects signature generated with a mismatched secret", () => { + const validator = new WebhookSignatureValidator({ secret: TEST_SECRET }); + const body = JSON.stringify({ event: "invoice.paid", invoiceId: "inv-bad-sec" }); + const wrongSecretSignature = hmacSha256(ALT_SECRET, body); + + const result = validator.verify({ body, signature: wrongSecretSignature }); + expect(result.valid).toBe(false); + expect(result.reason).toBe("signature mismatch"); + }); + + it("rejects tampered request payload body", () => { + const validator = new WebhookSignatureValidator({ secret: TEST_SECRET }); + const originalBody = JSON.stringify({ event: "invoice.paid", amount: "100" }); + const signature = hmacSha256(TEST_SECRET, originalBody); + + const tamperedBody = JSON.stringify({ event: "invoice.paid", amount: "9999" }); + const result = validator.verify({ body: tamperedBody, signature }); + + expect(result.valid).toBe(false); + expect(result.reason).toBe("signature mismatch"); + }); + + it("rejects tampered signature bytes", () => { + const validator = new WebhookSignatureValidator({ secret: TEST_SECRET }); + const body = JSON.stringify({ event: "invoice.paid" }); + const signature = hmacSha256(TEST_SECRET, body); + + // Flip the first character of the signature hex + const tamperedSig = (signature[0] === "a" ? "b" : "a") + signature.slice(1); + const result = validator.verify({ body, signature: tamperedSig }); + + expect(result.valid).toBe(false); + expect(result.reason).toBe("signature mismatch"); + }); + + it("rejects missing or empty signature with clear reason", () => { + const validator = new WebhookSignatureValidator({ secret: TEST_SECRET }); + const body = JSON.stringify({ event: "invoice.paid" }); + + expect(validator.verify({ body, signature: "" })).toEqual({ + valid: false, + reason: "missing signature", + }); + + expect(validator.verify({ body, signature: null as any })).toEqual({ + valid: false, + reason: "missing signature", + }); + }); + + it("emits webhook.rejected event on invalid signature", () => { + const validator = new WebhookSignatureValidator({ secret: TEST_SECRET }); + const eventSpy = vi.fn(); + validator.on(eventSpy); + + const body = JSON.stringify({ event: "invoice.paid" }); + validator.verify({ body, signature: "invalid_sig_hex" }); + + expect(eventSpy).toHaveBeenCalledTimes(1); + expect(eventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: "webhook.rejected", + reason: expect.any(String), + }) + ); + }); + + it("rejects malformed non-hex or odd-length signatures", () => { + const rawBody = JSON.stringify({ event: "invoice.paid" }); + + // Odd length + expect(verifyRawWebhookSignature(TEST_SECRET, rawBody, "abc")).toBe(false); + + // Non-hex characters + expect(verifyRawWebhookSignature(TEST_SECRET, rawBody, "not_a_hex_digest_xyz!@#$")).toBe(false); + + // Throws WebhookSignatureError on validateWebhook + expect(() => + validateWebhook({}, rawBody, TEST_SECRET, "hmac-sha256=invalidhex") + ).toThrow(WebhookSignatureError); + }); + + it("uses constant-time comparison in safeCompare to prevent timing attacks", () => { + const sigA = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + const sigB = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456780"; // 1 char diff + + expect(safeCompare(sigA, sigA)).toBe(true); + expect(safeCompare(sigA, sigB)).toBe(false); + expect(safeCompare(sigA, sigA.slice(0, 10))).toBe(false); // different lengths + }); + }); + + describe("Requirement: Test replay protection", () => { + it("accepts timestamped payload within allowed tolerance window", () => { + const validator = new WebhookSignatureValidator({ + secret: TEST_SECRET, + toleranceSeconds: 300, // 5 minutes + }); + + const nowSeconds = Math.floor(Date.now() / 1000); + const body = JSON.stringify({ event: "invoice.paid" }); + const signedPayload = buildSignedPayload(body, nowSeconds); + const signature = hmacSha256(TEST_SECRET, signedPayload); + + const result = validator.verify({ + body, + signature, + timestamp: nowSeconds, + }); + + expect(result.valid).toBe(true); + }); + + it("accepts timestamps within clock skew tolerance in both directions (past and slight future)", () => { + const validator = new WebhookSignatureValidator({ + secret: TEST_SECRET, + toleranceSeconds: 60, + }); + + const nowSeconds = Math.floor(Date.now() / 1000); + const pastTimestamp = nowSeconds - 30; // 30s ago + const futureTimestamp = nowSeconds + 20; // 20s in future + + const body = "event=ping"; + + // Past within tolerance + const sigPast = hmacSha256(TEST_SECRET, buildSignedPayload(body, pastTimestamp)); + expect(validator.verify({ body, signature: sigPast, timestamp: pastTimestamp }).valid).toBe(true); + + // Future within tolerance + const sigFuture = hmacSha256(TEST_SECRET, buildSignedPayload(body, futureTimestamp)); + expect(validator.verify({ body, signature: sigFuture, timestamp: futureTimestamp }).valid).toBe(true); + }); + + it("rejects expired timestamp outside tolerance window", () => { + const validator = new WebhookSignatureValidator({ + secret: TEST_SECRET, + toleranceSeconds: 60, // 1 minute tolerance + }); + + const expiredTimestamp = Math.floor(Date.now() / 1000) - 120; // 2 minutes ago + const body = JSON.stringify({ event: "invoice.paid" }); + const signedPayload = buildSignedPayload(body, expiredTimestamp); + const signature = hmacSha256(TEST_SECRET, signedPayload); + + const result = validator.verify({ + body, + signature, + timestamp: expiredTimestamp, + }); + + expect(result.valid).toBe(false); + expect(result.reason).toBe("timestamp outside tolerance"); + }); + + it("rejects timestamp in distant future exceeding tolerance window", () => { + const validator = new WebhookSignatureValidator({ + secret: TEST_SECRET, + toleranceSeconds: 60, + }); + + const futureTimestamp = Math.floor(Date.now() / 1000) + 3600; // 1 hour in future + const body = JSON.stringify({ event: "invoice.paid" }); + const signature = hmacSha256(TEST_SECRET, buildSignedPayload(body, futureTimestamp)); + + const result = validator.verify({ + body, + signature, + timestamp: futureTimestamp, + }); + + expect(result.valid).toBe(false); + expect(result.reason).toBe("timestamp outside tolerance"); + }); + + it("supports both seconds and milliseconds timestamp formats", () => { + const validator = new WebhookSignatureValidator({ secret: TEST_SECRET, toleranceSeconds: 300 }); + const body = "test_payload"; + + const nowMs = Date.now(); + const sigMs = hmacSha256(TEST_SECRET, buildSignedPayload(body, nowMs)); + expect(validator.verify({ body, signature: sigMs, timestamp: nowMs }).valid).toBe(true); + + const nowSec = Math.floor(nowMs / 1000); + const sigSec = hmacSha256(TEST_SECRET, buildSignedPayload(body, nowSec)); + expect(validator.verify({ body, signature: sigSec, timestamp: nowSec }).valid).toBe(true); + }); + }); + + describe("Requirement: Test nonce tracking", () => { + it("tracks seen nonces and detects replay attacks", () => { + // In-memory nonce tracking set + const seenNonces = new Set(); + + const processWebhookDelivery = (nonce: string, payload: unknown) => { + if (seenNonces.has(nonce)) { + return { accepted: false, error: "REPLAY_ATTACK_DETECTED" }; + } + seenNonces.add(nonce); + return { accepted: true, data: payload }; + }; + + const nonce1 = "nonce_123_abc"; + const payload1 = { invoiceId: "inv-1", amount: 100n }; + + // First delivery accepted and nonce tracked + const firstResult = processWebhookDelivery(nonce1, payload1); + expect(firstResult.accepted).toBe(true); + expect(seenNonces.has(nonce1)).toBe(true); + + // Second delivery with identical nonce rejected as replay + const replayResult = processWebhookDelivery(nonce1, payload1); + expect(replayResult.accepted).toBe(false); + expect(replayResult.error).toBe("REPLAY_ATTACK_DETECTED"); + + // New unique nonce accepted + const nonce2 = "nonce_456_def"; + const nextResult = processWebhookDelivery(nonce2, payload1); + expect(nextResult.accepted).toBe(true); + expect(seenNonces.size).toBe(2); + }); + + it("RingBufferStore stores, retrieves, and enforces capacity eviction for webhook replay", () => { + const store = new RingBufferStore(3); // Capacity = 3 + + const record1 = { + eventId: "evt-1", + invoiceId: "inv-1", + event: "invoice.paid" as any, + url: "https://api.merchant.com/hook", + payload: { amount: 100 }, + firedAt: new Date().toISOString(), + }; + const record2 = { ...record1, eventId: "evt-2" }; + const record3 = { ...record1, eventId: "evt-3" }; + const record4 = { ...record1, eventId: "evt-4" }; + + store.set("evt-1", record1); + store.set("evt-2", record2); + store.set("evt-3", record3); + + expect(store.size).toBe(3); + expect(store.get("evt-1")).toEqual(record1); + expect(store.get("evt-2")).toEqual(record2); + expect(store.get("evt-3")).toEqual(record3); + + // Adding 4th record evicts oldest ("evt-1") + store.set("evt-4", record4); + expect(store.size).toBe(3); + expect(store.get("evt-1")).toBeUndefined(); + expect(store.get("evt-4")).toEqual(record4); + }); + + it("records webhook deliveries with unique event IDs and supports lookup", () => { + const eventId1 = recordWebhookEvent("inv-100", "invoice.created" as any, "https://example.com/wh", { foo: 1 }); + const eventId2 = recordWebhookEvent("inv-101", "invoice.paid" as any, "https://example.com/wh", { foo: 2 }); + + expect(eventId1).toBeDefined(); + expect(eventId2).toBeDefined(); + expect(eventId1).not.toBe(eventId2); + + const store = new RingBufferStore(10); + configureReplayStore(store); + + const recordedId = recordWebhookEvent("inv-200", "invoice.paid" as any, "https://example.com/wh", { bar: 42 }); + const stored = store.get(recordedId); + + expect(stored).toBeDefined(); + expect(stored?.invoiceId).toBe("inv-200"); + expect(stored?.payload).toEqual({ bar: 42 }); + }); + }); +});