Files
dashcaddy/dashcaddy-api/__tests__/atomic-write-dc099.test.js
T
Hermes 3c04a740e4
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
refactor(persistence): migrate bridge events file to canonical atomic-write util (DC-104) [glm-grade=A]
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
2026-08-23 01:57:20 -07:00

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);
}
});
});