diff --git a/dashcaddy-api/__tests__/atomic-write-dc099.test.js b/dashcaddy-api/__tests__/atomic-write-dc099.test.js index 84e9d3b..195de70 100644 --- a/dashcaddy-api/__tests__/atomic-write-dc099.test.js +++ b/dashcaddy-api/__tests__/atomic-write-dc099.test.js @@ -235,3 +235,65 @@ describe('DC-100 invite-store on canonical atomic-write (real fs)', () => { 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([]); + }); +}); diff --git a/dashcaddy-api/src/security/user-store.js b/dashcaddy-api/src/security/user-store.js index 1b07f99..ade1e36 100644 --- a/dashcaddy-api/src/security/user-store.js +++ b/dashcaddy-api/src/security/user-store.js @@ -29,8 +29,9 @@ * This is recorded by writing a sentinel file `data/.bootstrapped` with the * admin email so we never bootstrap twice (e.g. after a restore from backup). * - * Atomic writes: every persistence op writes to a .tmp file then renames. - * process restart loses nothing in flight because rename is atomic on POSIX. + * Atomic writes: every persistence op goes through the canonical shared + * atomic-write util (DC-099) — fsync'd same-dir tmp+rename, so a crash or + * process restart loses nothing in flight and never leaves a torn file. * * Concurrency: a single in-process mutex serializes mutating ops. We don't * need cross-process locks because this API is single-instance by design. @@ -42,6 +43,7 @@ const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const platformPaths = require('../../platform-paths'); +const { atomicWriteJSON } = require('../utils/atomic-write'); const ROLES = Object.freeze({ ADMIN: 'admin', @@ -76,12 +78,6 @@ function _resolveDataDir(opts) { return require('os').tmpdir(); } -function _atomicWriteJSON(filePath, data) { - const tmp = filePath + '.tmp.' + process.pid + '.' + Date.now(); - fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }); - fs.renameSync(tmp, filePath); -} - function _readJSON(filePath, fallback) { try { const raw = fs.readFileSync(filePath, 'utf8'); @@ -133,8 +129,8 @@ function createUserStore(opts = {}) { return data; } - function _saveUsers(data) { _atomicWriteJSON(usersFile, data); } - function _saveAllowlist(data) { _atomicWriteJSON(allowlistFile, data); } + function _saveUsers(data) { atomicWriteJSON(usersFile, data); } + function _saveAllowlist(data) { atomicWriteJSON(allowlistFile, data); } function _bootstrapDone() { try { return fs.existsSync(bootstrapSentinel); } @@ -142,7 +138,7 @@ function createUserStore(opts = {}) { } function _writeBootstrapSentinel(adminEmail) { - _atomicWriteJSON(bootstrapSentinel, { + atomicWriteJSON(bootstrapSentinel, { bootstrappedAt: _nowIso(), adminEmail: adminEmail.toLowerCase(), });