diff --git a/dashcaddy-api/__tests__/atomic-write-dc099.test.js b/dashcaddy-api/__tests__/atomic-write-dc099.test.js index 40a6527..5023e64 100644 --- a/dashcaddy-api/__tests__/atomic-write-dc099.test.js +++ b/dashcaddy-api/__tests__/atomic-write-dc099.test.js @@ -360,3 +360,65 @@ describe('DC-102 share-store on canonical atomic-write (real fs)', () => { expect(leftovers).toEqual([]); }); }); + +// DC-103: fulfillment-store (Stripe license state, shared file-IPC between +// the API's lookup endpoint and the stripe-license-bridge process) migrated +// off its private tmp+rename copy onto the canonical writer. Pins: the file +// lands at 0600, parses as complete JSON after every mutation class, and no +// temp files survive — a torn write here would make a webhook retry mint a +// SECOND valid license key for an order that already has one. +describe('DC-103 fulfillment-store on canonical atomic-write (real fs)', () => { + let dir, store; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc103-fulfill-')); + store = require('../src/billing/fulfillment-store').createFulfillmentStore({ filePath: path.join(dir, 'stripe-fulfillments.json') }); + }); + afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) {} }); + + test('claim → saveLicense → claimDelivery → markDelivered lands at 0600, complete JSON, no temp leftovers', async () => { + const claimed = await store.claim({ eventId: 'evt_dc103', sessionId: 'cs_dc103', productId: 'pro-30d', durationDays: 30, email: 'dc103@x.com' }); + expect(claimed.claimed).toBe(true); + const saved = await store.saveLicense({ eventId: 'evt_dc103', sessionId: 'cs_dc103', code: 'DC103-KEY-XXXX', codeId: 'kg_dc103' }); + expect(saved.saved).toBe(true); + const delivery = await store.claimDelivery({ sessionId: 'cs_dc103', ownerToken: 'own_1' }); + expect(delivery.claimed).toBe(true); + const delivered = await store.markDelivered({ sessionId: 'cs_dc103', ownerToken: 'own_1', deliveredVia: 'smtp' }); + expect(delivered.saved).toBe(true); + + const file = path.join(dir, 'stripe-fulfillments.json'); + const st = fs.statSync(file); + expect(st.mode & 0o777).toBe(0o600); + + // complete JSON carrying the full lifecycle — a torn write would fail + // JSON.parse right here + const data = JSON.parse(fs.readFileSync(file, 'utf8')); + expect(data.bySessionId['cs_dc103'].status).toBe('delivered'); + expect(data.bySessionId['cs_dc103'].code).toBe('DC103-KEY-XXXX'); + expect(data.bySessionId['cs_dc103'].eventId).toBe('evt_dc103'); + // both index maps point at the same record + expect(data.byEventId['evt_dc103'].sessionId).toBe('cs_dc103'); + + const leftovers = fs.readdirSync(dir).filter((f) => f !== 'stripe-fulfillments.json'); + expect(leftovers).toEqual([]); + }); + + test('back-to-back mutations across separate store instances never collide on tmp names', async () => { + // Two processes share this file (bridge + API lookup). Two store + // instances writing interleaved must never collide on the same tmp name + // (the counter is per-process, so cross-instance is the real pin). + const storeA = require('../src/billing/fulfillment-store').createFulfillmentStore({ filePath: path.join(dir, 'stripe-fulfillments.json') }); + const storeB = require('../src/billing/fulfillment-store').createFulfillmentStore({ filePath: path.join(dir, 'stripe-fulfillments.json') }); + for (let i = 0; i < 6; i += 1) { + const a = await storeA.claim({ eventId: `evt_a${i}`, sessionId: `cs_a${i}`, productId: 'pro-30d', durationDays: 30, email: 'a@x.com' }); + expect(a.claimed).toBe(true); + const b = await storeB.claim({ eventId: `evt_b${i}`, sessionId: `cs_b${i}`, productId: 'pro-30d', durationDays: 30, email: 'b@x.com' }); + expect(b.claimed).toBe(true); + } + const file = path.join(dir, 'stripe-fulfillments.json'); + const data = JSON.parse(fs.readFileSync(file, 'utf8')); + expect(Object.keys(data.byEventId)).toHaveLength(12); + const leftovers = fs.readdirSync(dir).filter((f) => f !== 'stripe-fulfillments.json'); + expect(leftovers).toEqual([]); + }); +}); diff --git a/dashcaddy-api/src/billing/fulfillment-store.js b/dashcaddy-api/src/billing/fulfillment-store.js index 4b3acdc..92cbe9b 100644 --- a/dashcaddy-api/src/billing/fulfillment-store.js +++ b/dashcaddy-api/src/billing/fulfillment-store.js @@ -10,8 +10,8 @@ const fs = require('fs'); const path = require('path'); -const crypto = require('crypto'); const platformPaths = require('../../platform-paths'); +const { atomicWriteJSON } = require('../utils/atomic-write'); const DELIVERY_LEASE_MS = 5 * 60 * 1000; @@ -44,15 +44,11 @@ function createFulfillmentStore(options = {}) { function writeState(state) { const dir = path.dirname(filePath); fs.mkdirSync(dir, { recursive: true }); - const tmp = `${filePath}.tmp.${process.pid}.${Date.now()}.${crypto.randomBytes(4).toString('hex')}`; - fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 }); - try { - fs.renameSync(tmp, filePath); - } catch (error) { - try { fs.unlinkSync(tmp); } catch (_) { /* best effort */ } - throw error; - } - try { fs.chmodSync(filePath, 0o600); } catch (_) { /* best effort */ } + // Canonical atomic-write (DC-099): fsync-before-rename + exclusive-create + // tmp + dir fsync. A crash mid-write can no longer leave a torn + // stripe-fulfillments.json — which would have forced the bridge to + // re-mint a duplicate license key on the next webhook retry. + atomicWriteJSON(filePath, state); } function mutate(mutator) {