Persist payment-webhook dedup; declare BTCPay required; scope CORS
Replace the in-memory dedup Sets in the BTCPay and Zaprite webhook handlers (and the BTCPay rescan path) with a persistent JSON-backed store (server/webhook-dedup.js). The in-memory sets were cleared on restart, so a duplicate webhook delivery straddling a relay restart could double-credit (BTCPay) or double-extend a subscription (Zaprite). The store atomically writes /data/processed-webhooks.json, namespaces keys per rail (storeId|invoiceId vs zaprite:orderId), and prunes entries older than 180 days (safely beyond any retry window). Also: - BTCPay is a required running dependency (operator decision). Config was already optional:false/kind:'running'; corrected the contradictory "optional" comment in the manifest to match. - Scope cors() to /relay/* only — off /admin/* and the same-origin dashboard, which don't need permissive CORS. - Add money-path unit tests (commitCredit/refundCredit/applyTierPromotion) and webhook-dedup tests (incl. the survives-a-restart guarantee). - Fix two AGENTS.md auth-doc drifts; refresh Current state. Version 0.2.125 -> 0.2.126.
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
// Persistent webhook dedup — the store that stops a settled-payment
|
||||
// webhook duplicate from double-crediting (BTCPay) or double-extending
|
||||
// (Zaprite) when the duplicate straddles a relay restart.
|
||||
|
||||
import { test, describe } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
initWebhookDedup,
|
||||
isWebhookProcessed,
|
||||
markWebhookProcessed,
|
||||
} from "../webhook-dedup.js";
|
||||
|
||||
// The module is a singleton; each test inits a fresh dir to reset state.
|
||||
const freshDir = () => mkdtempSync(path.join(tmpdir(), "relay-dedup-"));
|
||||
|
||||
describe("webhook dedup (persistent)", () => {
|
||||
test("unknown key is not processed; marking makes it processed", async () => {
|
||||
await initWebhookDedup({ dataDir: freshDir() });
|
||||
assert.equal(isWebhookProcessed("store1|inv1"), false);
|
||||
await markWebhookProcessed("store1|inv1");
|
||||
assert.equal(isWebhookProcessed("store1|inv1"), true);
|
||||
});
|
||||
|
||||
// Headline guarantee for this change.
|
||||
test("processed keys survive a restart (reload from disk)", async () => {
|
||||
const dir = freshDir();
|
||||
await initWebhookDedup({ dataDir: dir });
|
||||
await markWebhookProcessed("store1|inv-restart");
|
||||
// Simulate a relay restart: re-init from the SAME data dir.
|
||||
await initWebhookDedup({ dataDir: dir });
|
||||
assert.equal(
|
||||
isWebhookProcessed("store1|inv-restart"),
|
||||
true,
|
||||
"a key marked before restart must still be deduped after restart"
|
||||
);
|
||||
});
|
||||
|
||||
test("BTCPay and Zaprite keys share the store without colliding", async () => {
|
||||
await initWebhookDedup({ dataDir: freshDir() });
|
||||
await markWebhookProcessed("store1|inv9");
|
||||
await markWebhookProcessed("zaprite:order9");
|
||||
assert.equal(isWebhookProcessed("store1|inv9"), true);
|
||||
assert.equal(isWebhookProcessed("zaprite:order9"), true);
|
||||
assert.equal(isWebhookProcessed("zaprite:inv9"), false);
|
||||
});
|
||||
|
||||
test("marking is idempotent", async () => {
|
||||
await initWebhookDedup({ dataDir: freshDir() });
|
||||
await markWebhookProcessed("k");
|
||||
await markWebhookProcessed("k"); // must not throw or duplicate
|
||||
assert.equal(isWebhookProcessed("k"), true);
|
||||
});
|
||||
|
||||
test("stale entries past the retention window are pruned on load", async () => {
|
||||
const dir = freshDir();
|
||||
const ancient = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const recent = new Date().toISOString();
|
||||
writeFileSync(
|
||||
path.join(dir, "processed-webhooks.json"),
|
||||
JSON.stringify({ keys: { "store1|old": ancient, "store1|new": recent } })
|
||||
);
|
||||
await initWebhookDedup({ dataDir: dir });
|
||||
assert.equal(isWebhookProcessed("store1|old"), false, "1y-old key should be pruned");
|
||||
assert.equal(isWebhookProcessed("store1|new"), true, "recent key should survive");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user