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