writeEvents() in scripts/stripe-license-bridge.js drops its private tmp+writeFileSync+rename copy (no fsync, Date.now() tmp names) and delegates to src/utils/atomic-write.js atomicWriteJSON (exclusive-create tmp, fsync, rename, parent-dir fsync, 0600). stripe-events.json is the Stripe webhook idempotency log - a torn write silently drops event-ids, so a Stripe retry re-runs delivery (duplicate license email; combined with a torn fulfillment record, a duplicate key mint). readEvents is JSON.parse-only, so the canonical serializer's trailing-newline drop is unobservable. Sole writer confirmed by grep. +2 DC-104 pins: full recordEvent->eventSeen dedupe cycle (0600/complete JSON/zero-leftovers) and 8-step back-to-back mutation parse-complete check. 121 suites / 2778 tests green. Judge: GLM-5.3 round-1 A (deleg_e8e1f9d0), URN urn:ump:frwh34wtlsunsymok6pzyujyehq6ikpvadypmsg7ndk7jjvzrp5a
485 lines
22 KiB
JavaScript
485 lines
22 KiB
JavaScript
/**
|
|
* DC-099: canonical atomic file writer (src/utils/atomic-write.js).
|
|
*
|
|
* The notification config's two write paths (load-time canonicalization
|
|
* write-back and the UI saveConfig) used plain fs.writeFileSync — a crash or
|
|
* power loss mid-write could leave a truncated/empty notifications.json. The
|
|
* same risk exists in every store that grew its own private
|
|
* _atomicWriteJSON copy (invite-store, user-store, share-store, …).
|
|
*
|
|
* These tests pin the shared writer's contract:
|
|
* - durability: fsync before rename, exclusive create, 0600 default
|
|
* - atomicity: destination only ever replaced via rename
|
|
* - failure: destination untouched, temp cleaned up, error propagated
|
|
* - JSON helper: single serialization shape (2-space, no trailing newline —
|
|
* notification-manager._persistCanonicalForm depends on byte-for-byte
|
|
* idempotence)
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { atomicWriteFile, atomicWriteJSON, tmpPathFor } = require('../src/utils/atomic-write');
|
|
|
|
// Real-FS tests: the actual syscalls, in a private temp dir.
|
|
describe('DC-099 atomic-write (real fs)', () => {
|
|
let dir;
|
|
|
|
beforeEach(() => {
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc099-atomic-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('writes contents and returns the final path', () => {
|
|
const target = path.join(dir, 'state.json');
|
|
const ret = atomicWriteFile(target, '{"a":1}');
|
|
expect(ret).toBe(target);
|
|
expect(fs.readFileSync(target, 'utf8')).toBe('{"a":1}');
|
|
});
|
|
|
|
test('replaces an existing file completely (no torn writes possible)', () => {
|
|
const target = path.join(dir, 'state.json');
|
|
atomicWriteFile(target, 'x'.repeat(1000));
|
|
atomicWriteFile(target, 'y'.repeat(10));
|
|
expect(fs.readFileSync(target, 'utf8')).toBe('y'.repeat(10));
|
|
});
|
|
|
|
test('creates the file 0600 by default', () => {
|
|
const target = path.join(dir, 'secret.json');
|
|
atomicWriteJSON(target, { ok: true });
|
|
expect(fs.statSync(target).mode & 0o777).toBe(0o600);
|
|
});
|
|
|
|
test('honors an explicit mode override', () => {
|
|
const target = path.join(dir, 'public.json');
|
|
atomicWriteFile(target, '{}', { mode: 0o644 });
|
|
expect(fs.statSync(target).mode & 0o777).toBe(0o644);
|
|
});
|
|
|
|
test('leaves no temp files behind after success', () => {
|
|
const target = path.join(dir, 'state.json');
|
|
atomicWriteFile(target, 'abc');
|
|
expect(fs.readdirSync(dir).filter((f) => f.includes('.tmp-'))).toEqual([]);
|
|
});
|
|
|
|
test('two rapid writes both land (unique tmp names per write)', () => {
|
|
const target = path.join(dir, 'state.json');
|
|
atomicWriteFile(target, 'first');
|
|
atomicWriteFile(target, 'second');
|
|
expect(fs.readFileSync(target, 'utf8')).toBe('second');
|
|
});
|
|
|
|
test('atomicWriteJSON serializes 2-space, no trailing newline', () => {
|
|
const target = path.join(dir, 'conf.json');
|
|
atomicWriteJSON(target, { a: { b: 1 } });
|
|
const raw = fs.readFileSync(target, 'utf8');
|
|
expect(raw).toBe('{\n "a": {\n "b": 1\n }\n}');
|
|
});
|
|
|
|
test('write failure leaves the destination untouched and cleans the temp file', () => {
|
|
const target = path.join(dir, 'state.json');
|
|
fs.writeFileSync(target, 'ORIGINAL');
|
|
const origWrite = fs.writeSync;
|
|
fs.writeSync = () => {
|
|
throw Object.assign(new Error('ENOSPC: no space left on device'), { code: 'ENOSPC' });
|
|
};
|
|
try {
|
|
expect(() => atomicWriteFile(target, 'NEW-CONTENT')).toThrow(/ENOSPC/);
|
|
} finally {
|
|
fs.writeSync = origWrite;
|
|
}
|
|
expect(fs.readFileSync(target, 'utf8')).toBe('ORIGINAL');
|
|
expect(fs.readdirSync(dir).filter((f) => f.includes('.tmp-'))).toEqual([]);
|
|
});
|
|
|
|
test('tmpPathFor: unique per call, hidden dotfile in the same directory', () => {
|
|
const a = tmpPathFor('/data/x.json');
|
|
const b = tmpPathFor('/data/x.json');
|
|
expect(a).not.toBe(b);
|
|
expect(path.dirname(a)).toBe('/data');
|
|
expect(path.basename(a)).toMatch(/^\.x\.json\.tmp-/);
|
|
});
|
|
});
|
|
|
|
// Mocked-FS tests: pin the syscall DISCIPLINE itself (order + flags), which
|
|
// the real-fs tests can't observe directly.
|
|
describe('DC-099 atomic-write syscall discipline (mocked fs)', () => {
|
|
const calls = [];
|
|
|
|
beforeEach(() => {
|
|
calls.length = 0;
|
|
const rec = (name, impl) =>
|
|
jest.spyOn(fs, name).mockImplementation((...args) => {
|
|
calls.push(name);
|
|
return impl(...args);
|
|
});
|
|
rec('openSync', () => 3);
|
|
rec('writeSync', () => 8);
|
|
rec('fsyncSync', () => {});
|
|
rec('closeSync', () => {});
|
|
rec('renameSync', () => {});
|
|
rec('unlinkSync', () => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.restoreAllMocks();
|
|
});
|
|
|
|
test('order: open → write → fsync → close → rename, then dir fsync (open → fsync → close)', () => {
|
|
atomicWriteFile('/data/x.json', '{"a":1}');
|
|
expect(calls).toEqual([
|
|
'openSync', 'writeSync', 'fsyncSync', 'closeSync', 'renameSync',
|
|
'openSync', 'fsyncSync', 'closeSync',
|
|
]);
|
|
});
|
|
|
|
test('dir fsync opens the PARENT directory (second openSync), not another tmp file', () => {
|
|
atomicWriteFile('/data/x.json', '{}');
|
|
const dirOpen = fs.openSync.mock.calls[1];
|
|
expect(dirOpen[0]).toBe('/data');
|
|
expect(dirOpen[1]).toBe('r');
|
|
});
|
|
|
|
test('dir fsync failure is swallowed (write still succeeds)', () => {
|
|
let n = 0;
|
|
fs.fsyncSync.mockImplementation(() => {
|
|
n += 1;
|
|
if (n === 2) throw new Error('EINVAL: invalid argument'); // 2nd fsync = dir
|
|
});
|
|
expect(() => atomicWriteFile('/data/x.json', '{}')).not.toThrow();
|
|
expect(fs.renameSync).toHaveBeenCalled();
|
|
});
|
|
|
|
test('open uses exclusive-create with the 0600 default on the tmp path', () => {
|
|
atomicWriteFile('/data/x.json', '{}');
|
|
const [tmpPath, flags, modeArg] = fs.openSync.mock.calls[0];
|
|
expect(tmpPath).toMatch(/^\/data\/\.x\.json\.tmp-/);
|
|
expect(flags).toBe('wx');
|
|
expect(modeArg).toBe(0o600);
|
|
});
|
|
|
|
test('write passes the payload with utf8 encoding', () => {
|
|
atomicWriteFile('/data/x.json', '{"a":1}');
|
|
expect(fs.writeSync.mock.calls[0]).toEqual([3, '{"a":1}', null, 'utf8']);
|
|
});
|
|
|
|
test('rename swaps a same-dir temp onto the target', () => {
|
|
atomicWriteFile('/data/x.json', '{}');
|
|
const [tmp, dest] = fs.renameSync.mock.calls[0];
|
|
expect(tmp).toMatch(/\/data\/\.x\.json\.tmp-/);
|
|
expect(dest).toBe('/data/x.json');
|
|
});
|
|
|
|
test('rename failure unlinks the temp and propagates the error', () => {
|
|
fs.renameSync.mockImplementation(() => {
|
|
calls.push('renameSync');
|
|
throw new Error('EXDEV: cross-device link not permitted');
|
|
});
|
|
expect(() => atomicWriteFile('/data/x.json', '{}')).toThrow(/EXDEV/);
|
|
expect(calls).toEqual([
|
|
'openSync', 'writeSync', 'fsyncSync', 'closeSync', 'renameSync', 'unlinkSync',
|
|
]);
|
|
});
|
|
|
|
test('open failure propagates without write/rename (nothing was created)', () => {
|
|
fs.openSync.mockImplementation(() => {
|
|
calls.push('openSync');
|
|
throw new Error('EACCES: permission denied');
|
|
});
|
|
expect(() => atomicWriteFile('/data/x.json', '{}')).toThrow(/EACCES/);
|
|
// best-effort unlink of the never-created temp, then stop
|
|
expect(calls).toEqual(['openSync', 'unlinkSync']);
|
|
});
|
|
});
|
|
|
|
// DC-100: invite-store migrated off its private _atomicWriteJSON copy onto
|
|
// the canonical writer. Store-level pins: writes are durable-canonical
|
|
// (0600, complete JSON, no temp leftovers) even under back-to-back mutations
|
|
// — the access pattern that could collide tmp names in the naive copy.
|
|
describe('DC-100 invite-store on canonical atomic-write (real fs)', () => {
|
|
let dir, store;
|
|
|
|
beforeEach(() => {
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc100-invite-'));
|
|
store = require('../src/security/invite-store').createInviteStore({ dataDir: dir });
|
|
});
|
|
afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) {} });
|
|
|
|
test('issued invite lands as complete JSON at mode 0600 with no temp leftovers', async () => {
|
|
const r = await store.issue({ email: 'dc100@x.com', ttlMs: 60_000 });
|
|
expect(r.ok).toBe(true);
|
|
const file = path.join(dir, 'invites.json');
|
|
const st = fs.statSync(file);
|
|
expect(st.mode & 0o777).toBe(0o600);
|
|
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
expect(Object.keys(data.invites)).toHaveLength(1);
|
|
const leftovers = fs.readdirSync(dir).filter((f) => f !== 'invites.json');
|
|
expect(leftovers).toEqual([]);
|
|
});
|
|
|
|
test('back-to-back mutations (issue, revoke, issue) never collide on tmp names', async () => {
|
|
const a = await store.issue({ email: 'a@x.com', ttlMs: 60_000 });
|
|
const b = await store.issue({ email: 'b@x.com', ttlMs: 60_000 });
|
|
await store.revoke(a.id);
|
|
const c = await store.issue({ email: 'c@x.com', ttlMs: 60_000 });
|
|
expect(b.ok).toBe(true);
|
|
expect(c.ok).toBe(true);
|
|
const data = JSON.parse(fs.readFileSync(path.join(dir, 'invites.json'), 'utf8'));
|
|
expect(Object.keys(data.invites).sort()).toEqual([b.id, c.id].sort());
|
|
const leftovers = fs.readdirSync(dir).filter((f) => f !== 'invites.json');
|
|
expect(leftovers).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// DC-101: user-store migrated off its private _atomicWriteJSON copy onto
|
|
// the canonical writer. Store-level pins across ALL THREE persisted files
|
|
// (users.json, authorized-users.json, .bootstrapped sentinel): 0600 mode,
|
|
// complete JSON, no temp leftovers — including the bootstrap path that
|
|
// writes two JSON files plus the sentinel back-to-back in one login.
|
|
describe('DC-101 user-store on canonical atomic-write (real fs)', () => {
|
|
let dir, store;
|
|
|
|
beforeEach(() => {
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc101-user-'));
|
|
store = require('../src/security/user-store').createUserStore({ dataDir: dir });
|
|
});
|
|
afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) {} });
|
|
|
|
test('bootstrap login persists users.json + allowlist + sentinel at 0600, complete JSON, no leftovers', async () => {
|
|
const r = await store.login({ email: 'dc101@x.com', ip: '10.0.0.1' });
|
|
expect(r.ok).toBe(true);
|
|
expect(r.isBootstrap).toBe(true);
|
|
|
|
const usersSt = fs.statSync(path.join(dir, 'users.json'));
|
|
const allowSt = fs.statSync(path.join(dir, 'authorized-users.json'));
|
|
const sentSt = fs.statSync(path.join(dir, '.bootstrapped'));
|
|
expect(usersSt.mode & 0o777).toBe(0o600);
|
|
expect(allowSt.mode & 0o777).toBe(0o600);
|
|
expect(sentSt.mode & 0o777).toBe(0o600);
|
|
|
|
const users = JSON.parse(fs.readFileSync(path.join(dir, 'users.json'), 'utf8'));
|
|
expect(Object.keys(users.users)).toHaveLength(1);
|
|
expect(users.users[users.order[0]].role).toBe('admin');
|
|
const allowlist = JSON.parse(fs.readFileSync(path.join(dir, 'authorized-users.json'), 'utf8'));
|
|
expect(allowlist.emails).toEqual(['dc101@x.com']);
|
|
const sentinel = JSON.parse(fs.readFileSync(path.join(dir, '.bootstrapped'), 'utf8'));
|
|
expect(sentinel.adminEmail).toBe('dc101@x.com');
|
|
|
|
const leftovers = fs.readdirSync(dir).filter(
|
|
(f) => f !== 'users.json' && f !== 'authorized-users.json' && f !== '.bootstrapped'
|
|
);
|
|
expect(leftovers).toEqual([]);
|
|
});
|
|
|
|
test('back-to-back mutations (login, allowlist add/remove, role set) never collide on tmp names', async () => {
|
|
const a = await store.login({ email: 'admin@x.com' });
|
|
expect(a.isBootstrap).toBe(true);
|
|
await store.addToAllowlist('b@x.com');
|
|
const b = await store.login({ email: 'b@x.com' });
|
|
expect(b.ok).toBe(true);
|
|
expect(b.role).toBe('operator');
|
|
await store.setRole(b.user.id, 'viewer');
|
|
await store.removeFromAllowlist('b@x.com');
|
|
|
|
const users = JSON.parse(fs.readFileSync(path.join(dir, 'users.json'), 'utf8'));
|
|
expect(users.users[b.user.id].role).toBe('viewer');
|
|
const allowlist = JSON.parse(fs.readFileSync(path.join(dir, 'authorized-users.json'), 'utf8'));
|
|
expect(allowlist.emails).toEqual(['admin@x.com']);
|
|
|
|
const leftovers = fs.readdirSync(dir).filter(
|
|
(f) => f !== 'users.json' && f !== 'authorized-users.json' && f !== '.bootstrapped'
|
|
);
|
|
expect(leftovers).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// DC-102: share-store migrated off its private _atomicWriteJSON copy onto
|
|
// the canonical writer. Store-level pins: shares.json AND the .share-secret
|
|
// signing key land as complete content at mode 0600 with no temp leftovers —
|
|
// a torn secret write would silently rotate the key and invalidate every
|
|
// outstanding share signature on next boot.
|
|
describe('DC-102 share-store on canonical atomic-write (real fs)', () => {
|
|
let dir, store;
|
|
|
|
beforeEach(() => {
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc102-share-'));
|
|
store = require('../src/security/share-store').createShareStore({ dataDir: dir });
|
|
});
|
|
afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) {} });
|
|
|
|
test('issued share + persisted signing secret land at 0600, complete, no temp leftovers', async () => {
|
|
const r = await store.issuePublic({ serviceId: 'svc', ttlMs: 60 * 60 * 1000 });
|
|
expect(r.ok).toBe(true);
|
|
|
|
const sharesFile = path.join(dir, 'shares.json');
|
|
const secretFile = path.join(dir, '.share-secret');
|
|
const sharesSt = fs.statSync(sharesFile);
|
|
const secretSt = fs.statSync(secretFile);
|
|
expect(sharesSt.mode & 0o777).toBe(0o600);
|
|
expect(secretSt.mode & 0o777).toBe(0o600);
|
|
|
|
// complete JSON — a torn write would fail JSON.parse right here
|
|
const data = JSON.parse(fs.readFileSync(sharesFile, 'utf8'));
|
|
expect(Object.keys(data.shares)).toHaveLength(1);
|
|
// complete secret — readable, 32+ bytes after trim, trailing newline kept
|
|
const secret = fs.readFileSync(secretFile, 'utf8');
|
|
expect(secret.trim().length).toBeGreaterThanOrEqual(32);
|
|
expect(secret.endsWith('\n')).toBe(true);
|
|
|
|
const leftovers = fs.readdirSync(dir).filter((f) => f !== 'shares.json' && f !== '.share-secret');
|
|
expect(leftovers).toEqual([]);
|
|
});
|
|
|
|
test('back-to-back mutations (issue x2, subscribe, tailscale use, revoke) never collide on tmp names', async () => {
|
|
const a = await store.issuePublic({ serviceId: 'svc', subscribeCap: 5 });
|
|
const b = await store.issueTailscale({ serviceId: 'svc', email: 'dc102@x.com' });
|
|
await store.recordPublicSubscribe(a.token, { email: 'sub@x.com' });
|
|
await store.recordTailscaleUse(b.token, { deviceId: 'device-1' });
|
|
await store.revoke(a.id);
|
|
|
|
// b remains outstanding and fully redeemable state on disk
|
|
const data = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
|
|
expect(Object.keys(data.shares)).toEqual([b.id]);
|
|
expect(data.shares[b.id].usedAt).toBeTruthy();
|
|
expect(data.shares[b.id].usedBy).toBe('device-1');
|
|
|
|
// signature verification still passes against the atomically persisted
|
|
// secret — getRaw checks hash + HMAC only (not used-state), so a rotated
|
|
// or torn secret would return null here.
|
|
const raw = await store.getRaw(b.token);
|
|
expect(raw).toBeTruthy();
|
|
expect(raw.id).toBe(b.id);
|
|
expect(raw.kind).toBe('tailscale');
|
|
|
|
const leftovers = fs.readdirSync(dir).filter((f) => f !== 'shares.json' && f !== '.share-secret');
|
|
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([]);
|
|
});
|
|
});
|
|
|
|
// 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);
|
|
}
|
|
});
|
|
});
|