From ac93fca2c3c2163cc3182a218cf3278dc8e7718c Mon Sep 17 00:00:00 2001 From: shadrach68 <139136816+shadrach68@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:56:30 +0100 Subject: [PATCH] test: malformed payload/replay fixtures, recovery, export redaction, reconciliation Closes #313 Closes #314 Closes #315 Closes #316 --- .../sensitive-field-redaction.test.ts | 160 +++++++++ .../__tests__/source-projection.test.ts | 138 ++++++++ .../__tests__/interrupted-operations.test.ts | 167 +++++++++ .../malformed-payload-replay.test.ts | 317 ++++++++++++++++++ 4 files changed, 782 insertions(+) create mode 100644 core/export/__tests__/sensitive-field-redaction.test.ts create mode 100644 core/reconciliation/__tests__/source-projection.test.ts create mode 100644 core/recovery/__tests__/interrupted-operations.test.ts create mode 100644 core/security/__tests__/malformed-payload-replay.test.ts diff --git a/core/export/__tests__/sensitive-field-redaction.test.ts b/core/export/__tests__/sensitive-field-redaction.test.ts new file mode 100644 index 0000000..6e56f75 --- /dev/null +++ b/core/export/__tests__/sensitive-field-redaction.test.ts @@ -0,0 +1,160 @@ +/** + * Permission-aware export with sensitive-field redaction (#314). + * + * Verifies that `applyFieldLevelAuthorization` and `evaluateFieldAuthorization` + * correctly omit or mask each class of sensitive field based on actor kind and + * explicit permission grants, covering the normal path, failure path, and the + * boundary between "omit" and "mask" behaviours. + */ + +import { describe, expect, it } from "vitest"; +import { + applyFieldLevelAuthorization, + evaluateFieldAuthorization, + DEFAULT_SENSITIVE_FIELD_RULES, + type ExportActor, +} from "../exporter"; + +const userActor: ExportActor = { kind: "user" }; +const maintainerActor: ExportActor = { kind: "maintainer" }; + +const record = { + id: "op-001", + amount: "100.00", + status: "settled", + // maintainer-only fields + ipAddress: "203.0.113.10", + userAgent: "Mozilla/5.0", + internalLatencyMs: 42, + adminNotes: "flagged for review", + riskScore: 0.85, + debugTrace: '{"step":"auth","ok":true}', + traceId: "trace-abc123", + rawPayload: '{"raw":true}', + systemSignature: "sig-xyz", + nodeId: "node-7", + sessionToken: "tok-secret", +}; + +describe("sensitive-field redaction (#314)", () => { + describe("user actor — omit/mask behaviour", () => { + it("strips all maintainer-only fields for a plain user actor", () => { + const { record: out, filteredCount } = applyFieldLevelAuthorization( + record, + userActor, + { rules: DEFAULT_SENSITIVE_FIELD_RULES } + ); + + // Non-sensitive fields must survive + expect(out.id).toBe("op-001"); + expect(out.amount).toBe("100.00"); + expect(out.status).toBe("settled"); + + // Every maintainer-only field must be absent or masked + expect(out.internalLatencyMs).toBeUndefined(); + expect(out.userAgent).toBeUndefined(); + expect(out.debugTrace).toBeUndefined(); + expect(out.traceId).toBeUndefined(); + expect(out.rawPayload).toBeUndefined(); + expect(out.systemSignature).toBeUndefined(); + expect(out.nodeId).toBeUndefined(); + expect(out.sessionToken).toBeUndefined(); + expect(out.adminNotes).toBe("[FILTERED: UNAUTHORIZED]"); // onDenied: "mask" + expect(filteredCount).toBeGreaterThanOrEqual(9); + }); + + it("masks ipAddress (onDenied: mask) instead of omitting it", () => { + const { record: out } = applyFieldLevelAuthorization( + { ipAddress: "192.0.2.1" }, + userActor, + { rules: DEFAULT_SENSITIVE_FIELD_RULES } + ); + expect(out.ipAddress).toBe("[FILTERED: UNAUTHORIZED]"); + }); + + it("omits riskScore (onDenied: omit) from user output", () => { + const { record: out } = applyFieldLevelAuthorization( + { riskScore: 0.9 }, + userActor, + { rules: DEFAULT_SENSITIVE_FIELD_RULES } + ); + expect(out.riskScore).toBeUndefined(); + }); + }); + + describe("maintainer actor — full access", () => { + it("passes all fields through unchanged for a maintainer", () => { + const { record: out, filteredCount } = applyFieldLevelAuthorization( + record, + maintainerActor, + { rules: DEFAULT_SENSITIVE_FIELD_RULES } + ); + + expect(out.ipAddress).toBe(record.ipAddress); + expect(out.adminNotes).toBe(record.adminNotes); + expect(out.riskScore).toBe(record.riskScore); + expect(out.debugTrace).toBe(record.debugTrace); + expect(filteredCount).toBe(0); + }); + }); + + describe("evaluateFieldAuthorization — explicit field request", () => { + it("denies a user explicitly requesting a maintainer-only field", () => { + const result = evaluateFieldAuthorization( + userActor, + ["id", "amount", "riskScore"], + DEFAULT_SENSITIVE_FIELD_RULES + ); + expect(result.authorized).toBe(false); + expect(result.deniedFields).toContain("riskScore"); + }); + + it("allows a user requesting only safe fields", () => { + const result = evaluateFieldAuthorization( + userActor, + ["id", "amount", "status"], + DEFAULT_SENSITIVE_FIELD_RULES + ); + expect(result.authorized).toBe(true); + expect(result.deniedFields).toHaveLength(0); + }); + + it("grants a maintainer access to any explicitly requested field", () => { + const result = evaluateFieldAuthorization( + maintainerActor, + ["riskScore", "debugTrace", "sessionToken"], + DEFAULT_SENSITIVE_FIELD_RULES + ); + expect(result.authorized).toBe(true); + expect(result.deniedFields).toHaveLength(0); + }); + }); + + describe("field projection intersection", () => { + it("returns only the requested projection fields even when authorized", () => { + const { record: out } = applyFieldLevelAuthorization( + record, + maintainerActor, + { + requestedFields: ["id", "amount"], + rules: DEFAULT_SENSITIVE_FIELD_RULES, + } + ); + expect(Object.keys(out)).toEqual(["id", "amount"]); + }); + + it("does not leak a sensitive field when projection and deny rule both apply", () => { + // User requests both a safe field and a sensitive one in the projection + const { record: out } = applyFieldLevelAuthorization( + record, + userActor, + { + requestedFields: ["id", "riskScore"], + rules: DEFAULT_SENSITIVE_FIELD_RULES, + } + ); + expect(out.id).toBe("op-001"); + expect(out.riskScore).toBeUndefined(); + }); + }); +}); diff --git a/core/reconciliation/__tests__/source-projection.test.ts b/core/reconciliation/__tests__/source-projection.test.ts new file mode 100644 index 0000000..841e722 --- /dev/null +++ b/core/reconciliation/__tests__/source-projection.test.ts @@ -0,0 +1,138 @@ +/** + * Reconciliation checks for source state and cached projections (#315). + * + * Verifies that `reconcile` detects the specific class of drift that arises + * when a cached projection disagrees with its source of truth — a stored + * balance that does not match the current ledger entry, or a balance that + * exists in the cache but has no corresponding ledger record at all. + * + * These are the two scenarios an operator sees after a cache warm-up failure + * or a missed ledger close: the projection says one thing, the source says + * another. + */ + +import { describe, expect, it } from "vitest"; +import { + reconcile, + amountsEqual, + type ReconciliationInput, + type StoredBalance, + type LedgerBalance, +} from "@/core/reconciliation/reconciliation"; + +const NOW = 1_770_000_000_000; + +function snap(overrides: Partial = {}): ReconciliationInput { + return { + storedBalances: [], + ledgerBalances: [], + jobs: [], + notifications: [], + idempotencyRecords: [], + exportEnvelopes: [], + operations: [], + now: NOW, + ...overrides, + }; +} + +describe("source state vs cached projection reconciliation (#315)", () => { + describe("missing ledger entry", () => { + it("flags a stored balance that has no matching ledger entry", () => { + const stored: StoredBalance = { + account: "GABC", + asset: "USDC:GAAA", + balance: "100.00", + cachedAt: new Date(NOW - 30_000).toISOString(), + }; + + const report = reconcile(snap({ storedBalances: [stored], ledgerBalances: [] })); + + const missing = report.results.find((r) => r.invariant === "balance.ledger_entry_present"); + expect(missing).toBeDefined(); + expect(missing!.findings).toBeGreaterThanOrEqual(1); + expect(report.totalFindings).toBeGreaterThanOrEqual(1); + }); + + it("passes when every stored balance has a matching ledger entry", () => { + const account = "GABC"; + const asset = "USDC:GAAA"; + const stored: StoredBalance = { account, asset, balance: "50.00" }; + const ledger: LedgerBalance = { account, asset, balance: "50.00", reference: "ledger-close-1" }; + + const report = reconcile(snap({ storedBalances: [stored], ledgerBalances: [ledger] })); + + const missing = report.results.find((r) => r.invariant === "balance.ledger_entry_present"); + expect(missing?.findings ?? 0).toBe(0); + expect(report.passed).toBe(true); + }); + }); + + describe("amount disagreement between cached projection and source", () => { + it("flags a stored balance that differs from the ledger amount", () => { + const account = "GDEF"; + const asset = "XLM:native"; + const stored: StoredBalance = { + account, + asset, + balance: "200.00", + cachedAt: new Date(NOW - 5_000).toISOString(), + }; + const ledger: LedgerBalance = { + account, + asset, + balance: "195.50", // ledger closed with a different amount + reference: "ledger-close-2", + }; + + const report = reconcile(snap({ storedBalances: [stored], ledgerBalances: [ledger] })); + + const inconsistent = report.results.find((r) => r.invariant === "balance.amount_agrees"); + expect(inconsistent).toBeDefined(); + expect(inconsistent!.findings).toBeGreaterThanOrEqual(1); + }); + + it("passes when cached and ledger amounts are equal", () => { + const account = "GDEF"; + const asset = "XLM:native"; + const stored: StoredBalance = { account, asset, balance: "195.50" }; + const ledger: LedgerBalance = { account, asset, balance: "195.50", reference: "ledger-close-3" }; + + const report = reconcile(snap({ storedBalances: [stored], ledgerBalances: [ledger] })); + + const inconsistent = report.results.find((r) => r.invariant === "balance.amount_agrees"); + expect(inconsistent?.findings ?? 0).toBe(0); + }); + }); + + describe("duplicate cached projection lines", () => { + it("flags two stored lines for the same account and asset", () => { + const account = "GHIJ"; + const asset = "USDC:GAAA"; + const stored: StoredBalance[] = [ + { account, asset, balance: "10.00" }, + { account, asset, balance: "10.00" }, + ]; + const ledger: LedgerBalance = { account, asset, balance: "10.00", reference: "lc-4" }; + + const report = reconcile( + snap({ storedBalances: stored, ledgerBalances: [ledger] }) + ); + + const dup = report.results.find((r) => r.invariant === "balance.single_line_per_asset"); + expect(dup).toBeDefined(); + expect(dup!.findings).toBeGreaterThanOrEqual(1); + }); + }); + + describe("amountsEqual helper", () => { + it("treats numerically equal decimal strings as equal", () => { + expect(amountsEqual("1.00", "1.0")).toBe(true); + expect(amountsEqual("100", "100.00")).toBe(true); + }); + + it("distinguishes genuinely different amounts", () => { + expect(amountsEqual("100.00", "100.01")).toBe(false); + }); + }); +}); diff --git a/core/recovery/__tests__/interrupted-operations.test.ts b/core/recovery/__tests__/interrupted-operations.test.ts new file mode 100644 index 0000000..c17a0c5 --- /dev/null +++ b/core/recovery/__tests__/interrupted-operations.test.ts @@ -0,0 +1,167 @@ +/** + * Deterministic recovery tests for interrupted multi-step operations (#313). + * + * Covers the failure paths where a multi-step operation (transfer, redemption) + * is left mid-flight after a rollback or a process crash. Each test exercises + * a concrete interrupted state: pending transfer from a rolled-back version, + * stale in-flight transfer, and partially confirmed redemption. + */ + +import { describe, expect, it } from "vitest"; +import { Keypair } from "@stellar/stellar-sdk"; +import { + verifyPendingOperationsAndInFlightTransfers, + verifyPartiallyProcessedRedemptions, + type ProtocolDataset, + type RollbackTarget, +} from "../rollback-verifier"; + +const ALICE = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 0xaa)).publicKey(); +const BOB = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 0xbb)).publicKey(); + +const TARGET: RollbackTarget = { + targetVersion: "1.0.0", + rolledBackFromVersion: "2.0.0", +}; + +const NOW = 1_770_000_000_000; + +function emptyDataset(overrides: Partial = {}): ProtocolDataset { + return { + events: [], + tickets: [], + transfers: [], + redemptions: [], + workerJobs: [], + ...overrides, + }; +} + +describe("interrupted multi-step operations — recovery tests (#313)", () => { + describe("pending transfer interrupted by rollback", () => { + it("flags a pending transfer whose version matches the rolled-back release", () => { + const dataset = emptyDataset({ + transfers: [ + { + id: "xfer-interrupted-001", + ticketId: "ticket-1", + fromOwner: ALICE, + toOwner: BOB, + status: "pending", + protocolVersion: "2.0.0", // the rolled-back version + initiatedAt: NOW - 60_000, + expiresAt: NOW + 3_600_000, + }, + ], + }); + + const { check, repairs } = verifyPendingOperationsAndInFlightTransfers( + dataset, + TARGET, + { now: () => new Date(NOW) } + ); + + expect(check.passed).toBe(false); + expect(check.severity).toBe("critical"); + expect(repairs).toHaveLength(1); + expect(repairs[0].recordId).toBe("xfer-interrupted-001"); + expect(repairs[0].severity).toBe("critical"); + }); + + it("flags a stale pending transfer that exceeded the max age window", () => { + const dataset = emptyDataset({ + transfers: [ + { + id: "xfer-stale-001", + ticketId: "ticket-2", + fromOwner: ALICE, + toOwner: BOB, + status: "pending", + protocolVersion: "1.0.0", // current version — not a rollback victim + initiatedAt: NOW - 25 * 60 * 60 * 1_000, // 25 hours ago + expiresAt: NOW + 3_600_000, + }, + ], + }); + + const { check, repairs } = verifyPendingOperationsAndInFlightTransfers( + dataset, + TARGET, + { now: () => new Date(NOW) } + ); + + expect(check.passed).toBe(false); + expect(repairs[0].severity).toBe("warning"); + }); + + it("passes when all transfers are settled or cancelled", () => { + const dataset = emptyDataset({ + transfers: [ + { + id: "xfer-ok-001", + ticketId: "ticket-3", + fromOwner: ALICE, + toOwner: BOB, + status: "accepted", + protocolVersion: "2.0.0", + initiatedAt: NOW - 60_000, + expiresAt: NOW + 3_600_000, + settledAt: NOW - 30_000, + }, + ], + }); + + const { check } = verifyPendingOperationsAndInFlightTransfers(dataset, TARGET, { + now: () => new Date(NOW), + }); + + expect(check.passed).toBe(true); + }); + }); + + describe("partially confirmed redemption interrupted mid-step", () => { + it("flags an unconfirmed redemption from a rolled-back version", () => { + const dataset = emptyDataset({ + redemptions: [ + { + id: "redemption-interrupted-001", + ticketId: "ticket-4", + eventId: "event-1", + redeemedBy: ALICE, + nonce: "nonce-abc", + protocolVersion: "2.0.0", + redeemedAt: NOW - 30_000, + isConfirmed: false, // check-in recorded but not confirmed — interrupted + }, + ], + }); + + const { check, repairs } = verifyPartiallyProcessedRedemptions(dataset, TARGET); + + expect(check.passed).toBe(false); + expect(repairs.length).toBeGreaterThanOrEqual(1); + expect(repairs[0].domain).toBe("redemption"); + }); + + it("passes for a fully confirmed redemption even on a rolled-back version", () => { + const dataset = emptyDataset({ + redemptions: [ + { + id: "redemption-confirmed-001", + ticketId: "ticket-5", + eventId: "event-1", + redeemedBy: ALICE, + nonce: "nonce-def", + protocolVersion: "2.0.0", + redeemedAt: NOW - 60_000, + isConfirmed: true, + }, + ], + }); + + const { check } = verifyPartiallyProcessedRedemptions(dataset, TARGET); + + expect(check.passed).toBe(true); + }); + }); +}); diff --git a/core/security/__tests__/malformed-payload-replay.test.ts b/core/security/__tests__/malformed-payload-replay.test.ts new file mode 100644 index 0000000..1bc49e5 --- /dev/null +++ b/core/security/__tests__/malformed-payload-replay.test.ts @@ -0,0 +1,317 @@ +/** + * Fixtures for malformed payload and replay attack scenarios (#316). + * + * Two threat families covered: + * + * 1. Malformed payloads — inputs that deviate from the expected format at the + * parser boundary (null bytes, truncated data, wrong encoding, oversized, + * control characters, Unicode trick-strings). Each fixture exercises a + * specific property of the parser's sanitization surface. + * + * 2. Replay attacks — the same high-risk write submitted more than once under + * the same idempotency key, a different key with a conflicting request hash, + * or a key whose TTL has elapsed. The idempotency module is the single + * enforcement point; these fixtures prove it handles each replay sub-class + * correctly. + */ + +import { beforeEach, describe, expect, it } from "vitest"; +import { + evaluateXdrBombScenario, + evaluateSecretCustodyScenario, + evaluateSep007UriScenario, + MAX_XDR_PAYLOAD_BYTES, + MAX_XDR_RECURSION_DEPTH, +} from "../threat-model"; +import { + createIdempotencyStore, + isValidIdempotencyKey, + requestDigest, + withIdempotency, +} from "@/core/idempotency/idempotency"; +import { ok, err } from "@/core/result/result"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeStore(ttlMs = 60_000) { + let clock = 1_770_000_000_000; + const store = createIdempotencyStore({ + ttlMs, + now: () => clock, + newId: () => "idem-" + clock, + }); + return { store, advance: (ms: number) => { clock += ms; } }; +} + +// ─── Malformed Payload Fixtures ─────────────────────────────────────────────── + +describe("Malformed payload fixtures", () => { + describe("XDR boundary", () => { + it("rejects a payload at exactly one byte above the limit", () => { + const payload = "A".repeat(MAX_XDR_PAYLOAD_BYTES + 1); + const result = evaluateXdrBombScenario(payload, 1); + expect(result.passed).toBe(true); + expect(result.threatSeverity).toBe("critical"); + expect(result.details).toMatchObject({ maxAllowed: MAX_XDR_PAYLOAD_BYTES }); + }); + + it("accepts a payload at exactly the limit", () => { + // Exactly MAX_XDR_PAYLOAD_BYTES ASCII chars → exactly MAX bytes in utf-8 + const payload = "A".repeat(MAX_XDR_PAYLOAD_BYTES); + const result = evaluateXdrBombScenario(payload, 1); + expect(result.threatSeverity).toBe("low"); + }); + + it("rejects recursion depth exactly one above the ceiling", () => { + const result = evaluateXdrBombScenario("AAAA", MAX_XDR_RECURSION_DEPTH + 1); + expect(result.passed).toBe(true); + expect(result.threatSeverity).toBe("high"); + expect(result.details).toMatchObject({ maxDepth: MAX_XDR_RECURSION_DEPTH }); + }); + + it("accepts recursion at exactly the ceiling", () => { + const result = evaluateXdrBombScenario("AAAA", MAX_XDR_RECURSION_DEPTH); + expect(result.threatSeverity).toBe("low"); + }); + + it("flags invalid base64 characters (null byte injected)", () => { + const payload = "AAAA\x00BBBB"; + const result = evaluateXdrBombScenario(payload, 1); + expect(result.passed).toBe(true); + expect(result.mitigationApplied).toContain("Strict base64 character set validation"); + }); + + it("flags invalid base64 characters (control characters)", () => { + const payload = "AAAA\t\r\nBBBB"; + const result = evaluateXdrBombScenario(payload, 1); + expect(result.passed).toBe(true); + expect(result.mitigationApplied).toContain("Strict base64 character set validation"); + }); + + it("flags Unicode confusable characters in XDR payload", () => { + // U+FF21 is FULLWIDTH LATIN CAPITAL LETTER A — looks like 'A' but is not ASCII + const payload = "ABCD"; + const result = evaluateXdrBombScenario(payload, 1); + expect(result.passed).toBe(true); + expect(result.mitigationApplied).toContain("Strict base64 character set validation"); + }); + + it("handles empty string without throwing", () => { + const result = evaluateXdrBombScenario("", 0); + expect(result.passed).toBe(true); + }); + + it("handles whitespace-only string as malformed base64", () => { + const result = evaluateXdrBombScenario(" ", 1); + // After trim: empty string — the base64 regex skips the check, treated as benign + expect(result.passed).toBe(true); + }); + }); + + describe("SEP-0007 URI boundary", () => { + it("blocks data: URI carrying base64 payload (script injection vector)", () => { + const result = evaluateSep007UriScenario("data:text/html,"); + expect(result.passed).toBe(true); + expect(result.threatSeverity).toBe("critical"); + }); + + it("blocks vbscript: URI (legacy ActiveX execution vector)", () => { + const result = evaluateSep007UriScenario("vbscript:MsgBox(1)"); + expect(result.passed).toBe(true); + expect(result.threatSeverity).toBe("critical"); + }); + + it("rejects empty URI without throwing", () => { + const result = evaluateSep007UriScenario(""); + expect(result.passed).toBe(true); + }); + + it("flags malformed destination key embedded in SEP-0007 URI", () => { + const result = evaluateSep007UriScenario( + "web+stellar:pay?destination=NOT_A_KEY&amount=10&asset_code=USDC" + ); + expect(result.passed).toBe(true); + expect(result.threatSeverity).toBe("high"); + expect(result.mitigationApplied).toContain("Malformed destination account key"); + }); + }); + + describe("Secret custody boundary", () => { + it("intercepts secret key embedded in a JSON memo field", () => { + const result = evaluateSecretCustodyScenario({ + memo: "payment for SA7TWFMFZPRQKLICYQST7N9Z3NVX2KAHUPCWDQ2XKIJBNXFAZF57LXFG", + }); + expect(result.passed).toBe(true); + expect(result.threatSeverity).toBe("critical"); + expect(result.details).toMatchObject({ secretDetectedCount: 1 }); + }); + + it("intercepts multiple secret keys in a bulk object", () => { + const result = evaluateSecretCustodyScenario({ + keys: [ + "SA7TWFMFZPRQKLICYQST7N9Z3NVX2KAHUPCWDQ2XKIJBNXFAZF57LXFG", + "SBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + ], + }); + expect(result.passed).toBe(true); + expect(result.threatSeverity).toBe("critical"); + expect((result.details.secretDetectedCount as number)).toBeGreaterThanOrEqual(1); + }); + + it("accepts a payload with a public key only (no secret)", () => { + const result = evaluateSecretCustodyScenario({ + address: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + }); + expect(result.passed).toBe(true); + expect(result.threatSeverity).toBe("low"); + }); + }); +}); + +// ─── Replay Attack Fixtures ─────────────────────────────────────────────────── + +describe("Replay attack fixtures", () => { + describe("idempotency key validation", () => { + it("rejects keys that are empty strings", () => { + expect(isValidIdempotencyKey("")).toBe(false); + }); + + it("rejects keys that are too short (< 8 chars)", () => { + expect(isValidIdempotencyKey("abc")).toBe(false); + }); + + it("accepts a valid UUID-shaped key", () => { + expect(isValidIdempotencyKey("123e4567-e89b-12d3-a456-426614174000")).toBe(true); + }); + }); + + describe("duplicate submission replay", () => { + it("replays a completed outcome and does not re-execute the handler", async () => { + const { store } = makeStore(); + let calls = 0; + const run = () => { calls++; return ok({ done: true }); }; + + const first = await withIdempotency(store, { + key: "replay-fixture-001", + operation: "export.generate", + request: { page: 1 }, + run, + }); + const second = await withIdempotency(store, { + key: "replay-fixture-001", + operation: "export.generate", + request: { page: 1 }, + run, + }); + + expect(first.ok && first.value.replayed).toBe(false); + expect(second.ok && second.value.replayed).toBe(true); + expect(calls).toBe(1); + }); + + it("replays a recorded failure without re-running the operation", async () => { + const { store } = makeStore(); + let calls = 0; + const run = () => { calls++; return err("horizon_timeout" as const); }; + + await withIdempotency(store, { key: "replay-fail-001", operation: "worker.run", run }); + const second = await withIdempotency(store, { key: "replay-fail-001", operation: "worker.run", run }); + + expect(second.ok).toBe(false); + if (!second.ok) { + expect(second.error).toBe("horizon_timeout"); + } + expect(calls).toBe(1); + }); + }); + + describe("request hash conflict (mutated payload replay)", () => { + it("rejects a second request with the same key but a different payload", async () => { + const { store } = makeStore(); + + await withIdempotency(store, { + key: "conflict-key-001", + operation: "export.generate", + request: { page: 1 }, + run: () => ok({ done: true }), + }); + + const second = await withIdempotency(store, { + key: "conflict-key-001", + operation: "export.generate", + request: { page: 2 }, // different payload — same key + run: () => ok({ done: true }), + }); + + expect(second.ok).toBe(false); + if (!second.ok) { + expect(second.error).toBe("idempotency_key_conflict"); + } + }); + + it("requestDigest produces different hashes for different payloads", () => { + const h1 = requestDigest({ page: 1 }); + const h2 = requestDigest({ page: 2 }); + expect(h1).not.toBe(h2); + }); + + it("requestDigest is stable for the same payload regardless of key order", () => { + const h1 = requestDigest({ b: 2, a: 1 }); + const h2 = requestDigest({ a: 1, b: 2 }); + expect(h1).toBe(h2); + }); + }); + + describe("expired key replay", () => { + it("rejects a key that has elapsed its TTL", async () => { + const { store, advance } = makeStore(5_000); // 5s TTL + + await withIdempotency(store, { + key: "expired-key-001", + operation: "export.generate", + request: {}, + run: () => ok({ done: true }), + }); + + advance(6_000); // past TTL + + const second = await withIdempotency(store, { + key: "expired-key-001", + operation: "export.generate", + request: {}, + run: () => ok({ done: true }), + }); + + expect(second.ok).toBe(false); + if (!second.ok) { + expect(second.error).toBe("idempotency_key_expired"); + } + }); + + it("accepts the same key again just before TTL elapses", async () => { + const { store, advance } = makeStore(5_000); + + await withIdempotency(store, { + key: "almost-expired-001", + operation: "export.generate", + request: {}, + run: () => ok({ done: true }), + }); + + advance(4_999); // one ms before expiry + + const second = await withIdempotency(store, { + key: "almost-expired-001", + operation: "export.generate", + request: {}, + run: () => ok({ done: true }), + }); + + // Still within TTL — should replay, not error + expect(second.ok).toBe(true); + if (second.ok) { + expect(second.value.replayed).toBe(true); + } + }); + }); +});