/** * Tests for network-people-store (DC-141). * Coverage: * - CRUD: upsert/get/update/delete/list round-trips * - atomicity: tmp+rename writes (no torn file), cache coherence * - first-person-admin rule: first person gets groups ['Admin'] + * protectedAt; later people do not * - last-admin guard: single active admin throws; passes when a second * active admin exists; throws again when the other admins are blocked * - findByProviderUserId match + miss * - corruption recovery: bad JSON restarts empty, .bak written */ 'use strict'; const fs = require('fs'); const path = require('path'); const os = require('os'); const { createPeopleStore, LastAdminError, ADMIN_GROUP, } = require('../src/security/network-people-store'); function _tmpDir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-peopletest-')); } function _cleanup(dir) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} } function _admin(name, opts = {}) { return { displayName: name, providerUserId: opts.puid || `puid-${name}`, ...opts, }; } describe('network-people-store: CRUD', () => { let dir, store; beforeEach(() => { dir = _tmpDir(); store = createPeopleStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); test('empty store lists zero people', async () => { const list = await store.listPeople(); expect(list).toEqual([]); }); test('upsert creates a person with defaults', async () => { // Seed a bootstrap admin first so the subject is a NON-first person // (first-person behavior is covered in its own describe block). await store.upsertPerson({ displayName: 'Owner', providerUserId: 'ts-0' }); const p = await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' }); expect(p.id).toBeTruthy(); expect(p.displayName).toBe('Alice'); expect(p.providerUserId).toBe('ts-1'); expect(Array.isArray(p.groups)).toBe(true); expect(Array.isArray(p.devices)).toBe(true); expect(p.status).toBe('active'); expect(p.createdAt).toBeTruthy(); expect(p.protectedAt).toBe(null); }); test('getPerson returns stored person; null for unknown id', async () => { const p = await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' }); const got = await store.getPerson(p.id); expect(got.id).toBe(p.id); expect(await store.getPerson('nope')).toBe(null); }); test('upsertPerson merges by id (update path)', async () => { const p = await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' }); const p2 = await store.upsertPerson({ id: p.id, displayName: 'Alice II', groups: ['Friends'] }); expect(p2.id).toBe(p.id); expect(p2.displayName).toBe('Alice II'); expect(p2.providerUserId).toBe('ts-1'); // retained expect(p2.createdAt).toBe(p.createdAt); // retained expect(p2.groups).toEqual(['Friends']); const list = await store.listPeople(); expect(list).toHaveLength(1); // merged, not duplicated }); test('updatePerson patches only provided fields', async () => { const p = await store.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2', groups: ['G'] }); const u = await store.updatePerson(p.id, { displayName: 'Bobby' }); expect(u.displayName).toBe('Bobby'); expect(u.groups).toEqual(['G']); // untouched by patch expect(await store.updatePerson('nope', { displayName: 'x' })).toBe(null); }); test('updatePerson cannot change id', async () => { const p = await store.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2' }); const u = await store.updatePerson(p.id, { id: 'evil', displayName: 'B' }); expect(u.id).toBe(p.id); const list = await store.listPeople(); expect(list).toHaveLength(1); }); test('deletePerson removes; false for unknown id', async () => { const p = await store.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2' }); expect(await store.deletePerson(p.id)).toBe(true); expect(await store.getPerson(p.id)).toBe(null); expect(await store.deletePerson(p.id)).toBe(false); }); test('returned persons are copies (mutating them does not corrupt store)', async () => { await store.upsertPerson({ displayName: 'Owner', providerUserId: 'ts-0' }); // bootstrap admin const p = await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' }); p.groups.push('Hacked'); const fresh = await store.getPerson(p.id); expect(fresh.groups).toEqual([]); }); }); describe('network-people-store: persistence + atomicity', () => { let dir, store; beforeEach(() => { dir = _tmpDir(); store = createPeopleStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); test('people survive a new store instance (round-trip through disk)', async () => { const p = await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' }); const store2 = createPeopleStore({ dataDir: dir }); const got = await store2.getPerson(p.id); expect(got.displayName).toBe('Alice'); expect(got.groups).toEqual(p.groups); }); test('write is tmp+rename: no leftover tmp files after save', async () => { await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' }); const files = fs.readdirSync(dir).filter(f => f.includes('.tmp-')); expect(files).toEqual([]); expect(fs.existsSync(path.join(dir, 'network-people.json'))).toBe(true); }); test('file contents are complete JSON (never torn)', async () => { await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' }); await store.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2' }); const raw = JSON.parse(fs.readFileSync(path.join(dir, 'network-people.json'), 'utf8')); expect(Object.keys(raw.people)).toHaveLength(2); }); test('rapid sequential writes are all durable (mutex serialization)', async () => { const names = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']; await Promise.all(names.map(n => store.upsertPerson({ displayName: n, providerUserId: `p-${n}` }))); const list = await store.listPeople(); expect(list).toHaveLength(names.length); const raw = JSON.parse(fs.readFileSync(path.join(dir, 'network-people.json'), 'utf8')); expect(Object.keys(raw.people)).toHaveLength(names.length); }); test('corrupt file: store recovers empty and writes .bak', async () => { await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' }); fs.writeFileSync(path.join(dir, 'network-people.json'), '{corrupted!!'); const store2 = createPeopleStore({ dataDir: dir }); const list = await store2.listPeople(); expect(list).toEqual([]); expect(fs.existsSync(path.join(dir, 'network-people.json.bak'))).toBe(true); }); }); describe('network-people-store: first-person-admin rule', () => { let dir, store; beforeEach(() => { dir = _tmpDir(); store = createPeopleStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); test('first person created gets Admin group + protectedAt', async () => { const first = await store.upsertPerson({ displayName: 'Sami', providerUserId: 'ts-owner' }); expect(first.groups).toEqual([ADMIN_GROUP]); expect(first.protectedAt).toBeTruthy(); expect(new Date(first.protectedAt).getTime()).not.toBeNaN(); }); test('second person created does NOT get Admin/protectedAt', async () => { await store.upsertPerson({ displayName: 'Sami', providerUserId: 'ts-owner' }); const second = await store.upsertPerson({ displayName: 'Friend', providerUserId: 'ts-2' }); expect(second.groups).toEqual([]); expect(second.protectedAt).toBe(null); }); test('rule applies only to a truly empty store (delete-then-create does not re-trigger)', async () => { const first = await store.upsertPerson({ displayName: 'Sami', providerUserId: 'ts-owner' }); const second = await store.upsertPerson({ displayName: 'Friend', providerUserId: 'ts-2' }); await store.deletePerson(first.id); const third = await store.upsertPerson({ displayName: 'Late', providerUserId: 'ts-3' }); expect(second.groups).toEqual([]); expect(third.groups).toEqual([]); expect(third.protectedAt).toBe(null); }); }); describe('network-people-store: last-admin guard', () => { let dir, store; beforeEach(() => { dir = _tmpDir(); store = createPeopleStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); test('throws LastAdminError for the only active admin', async () => { const solo = await store.upsertPerson(_admin('Sami')); // bootstrap admin await expect(store.assertNotLastAdmin(solo.id)).rejects.toBeInstanceOf(LastAdminError); }); test('passes when a second active admin exists', async () => { const a = await store.upsertPerson(_admin('Sami')); const b = await store.upsertPerson(_admin('Deputy')); await store.updatePerson(b.id, { groups: [ADMIN_GROUP] }); await expect(store.assertNotLastAdmin(a.id)).resolves.toBeUndefined(); await expect(store.assertNotLastAdmin(b.id)).resolves.toBeUndefined(); }); test('throws when the other admins are blocked (only one active left)', async () => { const a = await store.upsertPerson(_admin('Sami')); const b = await store.upsertPerson(_admin('Deputy')); await store.updatePerson(b.id, { groups: [ADMIN_GROUP] }); // Block every admin except a. await store.updatePerson(b.id, { status: 'blocked' }); await expect(store.assertNotLastAdmin(a.id)).rejects.toBeInstanceOf(LastAdminError); // The blocked admin himself is not protected by the guard (he's not // an ACTIVE admin of consequence — but he IS still the last *listed* // admin member; the guard protects active access, so blocking b again // is fine). await expect(store.assertNotLastAdmin(b.id)).resolves.toBeUndefined(); }); test('removed admins also do not count as active', async () => { const a = await store.upsertPerson(_admin('Sami')); const b = await store.upsertPerson(_admin('Deputy')); await store.updatePerson(b.id, { groups: [ADMIN_GROUP] }); await store.updatePerson(b.id, { status: 'removed' }); await expect(store.assertNotLastAdmin(a.id)).rejects.toBeInstanceOf(LastAdminError); }); test('non-admins pass freely', async () => { await store.upsertPerson(_admin('Sami')); const user = await store.upsertPerson({ displayName: 'Pleb', providerUserId: 'ts-pleb' }); await expect(store.assertNotLastAdmin(user.id)).resolves.toBeUndefined(); }); test('unknown ids pass (nothing to protect)', async () => { await expect(store.assertNotLastAdmin('ghost')).resolves.toBeUndefined(); }); test('LastAdminError carries code + personId', async () => { const solo = await store.upsertPerson(_admin('Sami')); try { await store.assertNotLastAdmin(solo.id); throw new Error('should have thrown'); } catch (err) { expect(err).toBeInstanceOf(LastAdminError); expect(err.code).toBe('DC_LAST_ADMIN'); expect(err.personId).toBe(solo.id); expect(err.statusCode).toBe(409); } }); }); describe('network-people-store: findByProviderUserId', () => { let dir, store; beforeEach(() => { dir = _tmpDir(); store = createPeopleStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); test('finds the person with that provider identity', async () => { await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1234' }); const found = await store.findByProviderUserId('ts-1234'); expect(found).toBeTruthy(); expect(found.displayName).toBe('Alice'); }); test('returns null on miss and for empty/invalid input', async () => { await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1234' }); expect(await store.findByProviderUserId('ts-9999')).toBe(null); expect(await store.findByProviderUserId('')).toBe(null); expect(await store.findByProviderUserId(null)).toBe(null); }); });