diff --git a/dashcaddy-api/__tests__/atomic-write-dc099.test.js b/dashcaddy-api/__tests__/atomic-write-dc099.test.js index 5023e64..e64e3b3 100644 --- a/dashcaddy-api/__tests__/atomic-write-dc099.test.js +++ b/dashcaddy-api/__tests__/atomic-write-dc099.test.js @@ -422,3 +422,63 @@ describe('DC-103 fulfillment-store on canonical atomic-write (real fs)', () => { expect(leftovers).toEqual([]); }); }); + +// DC-104: stripe-license-bridge events file (Stripe webhook idempotency +// log) migrated off its private tmp+writeFileSync+rename copy onto the +// canonical writer. A torn stripe-events.json silently drops event-ids — +// the next Stripe retry then re-runs delivery (duplicate license email / +// duplicate key mint when combined with a torn fulfillment record). +// Pins: 0600 on create, complete JSON after every recordEvent mutation, +// no temp leftovers, and the full read-modify-write dedupe cycle through +// the bridge's exported functions. (The ignored-type / unpaid-status +// write classes route through the same writeEvents and are driven +// end-to-end in __tests__/billing/stripe-license-bridge.test.js.) +describe('DC-104 bridge events file on canonical atomic-write (real fs)', () => { + let dir; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc104-events-')); + process.env.STRIPE_BRIDGE_EVENTS_FILE = path.join(dir, 'stripe-events.json'); + jest.resetModules(); + }); + afterEach(() => { + delete process.env.STRIPE_BRIDGE_EVENTS_FILE; + try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) {} + }); + + test('recordEvent → eventSeen dedupe cycle lands at 0600, complete JSON, no temp leftovers', () => { + // Env is captured at require time — resetModules above makes this + // require see the fresh STRIPE_BRIDGE_EVENTS_FILE. + const bridge = require('../scripts/stripe-license-bridge'); + + const first = bridge.recordEvent('evt_dc104_a', { ignoredType: 'product.updated' }); + expect(first).toBe(true); // new event recorded + expect(bridge.eventSeen('evt_dc104_a')).toBe(true); + expect(bridge.eventSeen('evt_dc104_unknown')).toBe(false); + + const dup = bridge.recordEvent('evt_dc104_a', { ignoredType: 'product.updated' }); + expect(dup).toBe(false); // idempotent — already present + + const file = path.join(dir, 'stripe-events.json'); + const st = fs.statSync(file); + expect(st.mode & 0o777).toBe(0o600); // canonical writer default + + // complete JSON carrying the event — a torn write would fail parse here + const data = JSON.parse(fs.readFileSync(file, 'utf8')); + expect(data.events['evt_dc104_a'].ignoredType).toBe('product.updated'); + + const leftovers = fs.readdirSync(dir).filter((f) => f !== 'stripe-events.json'); + expect(leftovers).toEqual([]); // no tmp survivors + }); + + test('back-to-back recordEvent writes parse complete after every mutation', () => { + const bridge = require('../scripts/stripe-license-bridge'); + for (let i = 0; i < 8; i += 1) { + const ok = bridge.recordEvent(`evt_dc104_seq_${i}`, { ignoredType: 'product.updated', seq: i }); + expect(ok).toBe(true); + const file = path.join(dir, 'stripe-events.json'); + const data = JSON.parse(fs.readFileSync(file, 'utf8')); // throws on torn write + expect(Object.keys(data.events)).toHaveLength(i + 1); + } + }); +}); diff --git a/dashcaddy-api/scripts/stripe-license-bridge.js b/dashcaddy-api/scripts/stripe-license-bridge.js index 706878c..fe4cdf2 100644 --- a/dashcaddy-api/scripts/stripe-license-bridge.js +++ b/dashcaddy-api/scripts/stripe-license-bridge.js @@ -105,6 +105,7 @@ const fs = require('fs'); const path = require('path'); const { generateCodes, loadSecret } = require('../license-keygen'); const platformPaths = require('../platform-paths'); +const { atomicWriteJSON } = require('../src/utils/atomic-write'); const catalog = require('../src/billing/catalog'); const invoice = require('../src/billing/invoice'); const { createFulfillmentStore, DELIVERY_LEASE_MS } = require('../src/billing/fulfillment-store'); @@ -220,10 +221,11 @@ function readEvents() { } function writeEvents(state) { - // Atomic write: tmp + rename. - const tmp = `${EVENTS_FILE}.tmp.${process.pid}.${Date.now()}`; - fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 }); - fs.renameSync(tmp, EVENTS_FILE); + // Canonical atomic writer (DC-099/DC-104): exclusive-create tmp + fsync + + // rename + parent-dir fsync, 0600. Replaces the private tmp+rename copy — + // a torn stripe-events.json silently drops event-ids, which makes a + // Stripe retry re-run delivery (duplicate license email / duplicate key). + atomicWriteJSON(EVENTS_FILE, state, { mode: 0o600 }); } function recordEvent(eventId, meta) {