/** * Tests for user-store (DC-048). * Coverage: * - bootstrap rule: first user becomes admin * - allowlist enforcement: emails not on the list are rejected * - login idempotency: existing user just bumps counters * - role updates with valid/invalid roles * - last-admin protection: cannot delete the only admin * - concurrent login safety: mutex serializes * - file persistence: writes are atomic and survive process kill */ 'use strict'; const fs = require('fs'); const path = require('path'); const os = require('os'); const { createUserStore, ROLES, VALID_ROLES } = require('../src/security/user-store'); function _tmpDir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-usertest-')); } function _cleanup(dir) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} } describe('user-store: bootstrap', () => { let dir, store; beforeEach(() => { dir = _tmpDir(); store = createUserStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); test('first login becomes admin (isBootstrap=true)', async () => { const r = await store.login({ email: 'alice@example.com', ip: '127.0.0.1' }); expect(r.ok).toBe(true); expect(r.isBootstrap).toBe(true); expect(r.role).toBe('admin'); expect(r.user.email).toBe('alice@example.com'); expect(r.user.id).toBeTruthy(); expect(r.user.loginCount).toBe(1); }); test('bootstrap sentinel written', async () => { await store.login({ email: 'a@x.com' }); expect(fs.existsSync(path.join(dir, '.bootstrapped'))).toBe(true); const sentinel = JSON.parse(fs.readFileSync(path.join(dir, '.bootstrapped'), 'utf8')); expect(sentinel.adminEmail).toBe('a@x.com'); }); test('bootstrap-admin email is added to allowlist', async () => { await store.login({ email: 'first@x.com' }); const allowlist = await store.listAllowlist(); expect(allowlist).toContain('first@x.com'); }); test('second login denied without allowlist', async () => { await store.login({ email: 'first@x.com' }); const r = await store.login({ email: 'second@x.com' }); expect(r.ok).toBe(false); expect(r.reason).toBe('not_authorized'); }); test('second login allowed if email is on allowlist', async () => { await store.login({ email: 'first@x.com' }); await store.addToAllowlist('friend@x.com'); const r = await store.login({ email: 'friend@x.com' }); expect(r.ok).toBe(true); expect(r.isBootstrap).toBe(false); expect(r.role).toBe('operator'); // not admin — bootstrap already happened }); test('replay bootstrap after delete restores allow-everyone', async () => { await store.login({ email: 'first@x.com' }); // Cannot fully replay — bootstrap sentinel persists. Verify the // invariant: once bootstrapped, even an empty allowlist rejects new // emails unless added explicitly. const r = await store.login({ email: 'random@x.com' }); expect(r.ok).toBe(false); }); }); describe('user-store: login idempotency', () => { let dir, store; beforeEach(() => { dir = _tmpDir(); store = createUserStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); test('existing user login bumps counters, does NOT bootstrap again', async () => { const r1 = await store.login({ email: 'a@x.com' }); const id = r1.user.id; const r2 = await store.login({ email: 'a@x.com', ip: '10.0.0.1' }); expect(r2.ok).toBe(true); expect(r2.isBootstrap).toBe(false); expect(r2.user.id).toBe(id); expect(r2.user.loginCount).toBe(2); expect(r2.user.lastLoginIp).toBe('10.0.0.1'); }); test('email normalized to lowercase', async () => { await store.login({ email: 'Alice@Example.COM' }); const users = await store.listUsers(); expect(users).toHaveLength(1); expect(users[0].email).toBe('alice@example.com'); }); }); describe('user-store: validation', () => { let dir, store; beforeEach(() => { dir = _tmpDir(); store = createUserStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); test('invalid email rejected', async () => { const r = await store.login({ email: 'not-an-email' }); expect(r.ok).toBe(false); expect(r.reason).toBe('invalid_email'); }); test('empty email rejected', async () => { const r = await store.login({ email: '' }); expect(r.ok).toBe(false); }); test('isEmailAuthorized returns true only when allowlist or bootstrap-pending', async () => { expect(await store.isEmailAuthorized('anyone@x.com')).toBe(true); // bootstrap pending await store.login({ email: 'first@x.com' }); expect(await store.isEmailAuthorized('anyone@x.com')).toBe(false); await store.addToAllowlist('friend@x.com'); expect(await store.isEmailAuthorized('friend@x.com')).toBe(true); expect(await store.isEmailAuthorized('stranger@x.com')).toBe(false); }); }); describe('user-store: roles + delete', () => { let dir, store; beforeEach(() => { dir = _tmpDir(); store = createUserStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); test('setRole updates an existing user', async () => { await store.login({ email: 'a@x.com' }); await store.addToAllowlist('b@x.com'); const r = await store.login({ email: 'b@x.com' }); const set = await store.setRole(r.user.id, 'viewer'); expect(set.ok).toBe(true); const got = await store.getUser(r.user.id); expect(got.role).toBe('viewer'); }); test('setRole rejects invalid role', async () => { await store.login({ email: 'a@x.com' }); const set = await store.setRole('nonexistent', 'superuser'); expect(set.ok).toBe(false); expect(set.reason).toBe('invalid_role'); }); test('deleteUser removes user + allowlist entry', async () => { await store.login({ email: 'a@x.com' }); await store.addToAllowlist('b@x.com'); const r = await store.login({ email: 'b@x.com' }); const del = await store.deleteUser(r.user.id); expect(del.ok).toBe(true); const users = await store.listUsers(); expect(users).toHaveLength(1); // only the admin const allowlist = await store.listAllowlist(); expect(allowlist).not.toContain('b@x.com'); }); test('deleteUser refuses to delete the last admin', async () => { const r = await store.login({ email: 'admin@x.com' }); const del = await store.deleteUser(r.user.id); expect(del.ok).toBe(false); expect(del.reason).toBe('last_admin'); }); test('deleteUser allows removing admin when another admin exists', async () => { await store.login({ email: 'admin1@x.com' }); await store.addToAllowlist('admin2@x.com'); const r2 = await store.login({ email: 'admin2@x.com' }); await store.setRole(r2.user.id, 'admin'); const r1 = await store.listUsers(); const admin1 = r1.find(u => u.email === 'admin1@x.com'); const del = await store.deleteUser(admin1.id); expect(del.ok).toBe(true); const remaining = await store.listUsers(); expect(remaining).toHaveLength(1); expect(remaining[0].role).toBe('admin'); }); }); describe('user-store: atomic writes', () => { let dir, store; beforeEach(() => { dir = _tmpDir(); store = createUserStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); test('users.json is well-formed after write', async () => { await store.login({ email: 'a@x.com' }); const raw = fs.readFileSync(path.join(dir, 'users.json'), 'utf8'); const parsed = JSON.parse(raw); expect(parsed.users).toBeTruthy(); expect(parsed.order).toHaveLength(1); }); test('corrupt users.json falls back to empty (no crash)', async () => { fs.writeFileSync(path.join(dir, 'users.json'), '{not json'); const users = await store.listUsers(); expect(users).toEqual([]); }); test('listUsers returns most-recent-first by createdAt order', async () => { await store.login({ email: 'a@x.com' }); await new Promise(r => setTimeout(r, 5)); await store.addToAllowlist('b@x.com'); await store.login({ email: 'b@x.com' }); const users = await store.listUsers(); expect(users[0].email).toBe('b@x.com'); expect(users[1].email).toBe('a@x.com'); }); }); describe('user-store: ROLES constants', () => { test('exports admin/operator/viewer roles', () => { expect(ROLES.ADMIN).toBe('admin'); expect(ROLES.OPERATOR).toBe('operator'); expect(ROLES.VIEWER).toBe('viewer'); expect(VALID_ROLES.has('admin')).toBe(true); expect(VALID_ROLES.has('operator')).toBe(true); expect(VALID_ROLES.has('viewer')).toBe(true); expect(VALID_ROLES.has('superuser')).toBe(false); }); });