/** * 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([]); }); });