/** * Tests for network-access-store (DC-145). * Coverage: * - CRUD: getAccessMap empty default, saveAccessMap, setGrant, removeGrant * - validation rejections: bad principal type, empty resource, non-bool allowed * - dedupe last-wins on saveAccessMap and setGrant upsert * - atomicity: concurrent writes serialize; invalid save leaves file untouched * - effective access: union of groups, person-override-beats-group (both * directions: person true beats group false, person false beats group true) * - subscribe change events + unsubscribe + listener isolation * - derived views: listPrincipals / listResources * - on-disk shape {grants, updatedAt} at data/network-access.json */ 'use strict'; const fs = require('fs'); const path = require('path'); const os = require('os'); const { createNetworkAccessStore, normalizeGrants, validateGrant, } = require('../src/security/network-access-store'); function _tmpDir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-netaccess-')); } function _cleanup(dir) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} } function _readFile(dir) { return JSON.parse(fs.readFileSync(path.join(dir, 'network-access.json'), 'utf8')); } describe('network-access-store: basic CRUD', () => { let dir, store; beforeEach(() => { dir = _tmpDir(); store = createNetworkAccessStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); test('empty map when file missing — {grants: [], updatedAt: null}', async () => { const map = await store.getAccessMap(); expect(map).toEqual({ grants: [], updatedAt: null }); }); test('saveAccessMap persists {grants, updatedAt} to network-access.json', async () => { const r = await store.saveAccessMap({ grants: [ { principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true }, ], }); expect(r.ok).toBe(true); expect(r.map.updatedAt).toBeTruthy(); const onDisk = _readFile(dir); expect(onDisk.grants).toHaveLength(1); expect(onDisk.grants[0].principal).toEqual({ type: 'person', id: 'p1' }); expect(onDisk.grants[0].resource).toBe('plex'); expect(onDisk.grants[0].allowed).toBe(true); expect(typeof onDisk.updatedAt).toBe('string'); }); test('getAccessMap returns saved grants', async () => { await store.saveAccessMap({ grants: [{ principal: { type: 'group', id: 'family' }, resource: 'nas', allowed: true }], }); const map = await store.getAccessMap(); expect(map.grants).toHaveLength(1); expect(map.grants[0].principal.id).toBe('family'); }); test('setGrant adds a grant', async () => { const r = await store.setGrant({ type: 'person', id: 'p1' }, 'plex', true); expect(r.ok).toBe(true); const map = await store.getAccessMap(); expect(map.grants).toEqual([ { principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true }, ]); }); test('setGrant upserts (replaces) same principal+resource — no duplicate', async () => { await store.setGrant({ type: 'person', id: 'p1' }, 'plex', true); await store.setGrant({ type: 'person', id: 'p1' }, 'plex', false); const map = await store.getAccessMap(); expect(map.grants).toHaveLength(1); expect(map.grants[0].allowed).toBe(false); }); test('removeGrant deletes an existing grant', async () => { await store.setGrant({ type: 'person', id: 'p1' }, 'plex', true); const r = await store.removeGrant({ type: 'person', id: 'p1' }, 'plex'); expect(r.ok).toBe(true); expect(r.removed).toBe(true); const map = await store.getAccessMap(); expect(map.grants).toHaveLength(0); }); test('removeGrant on absent grant is idempotent (removed: false)', async () => { const r = await store.removeGrant({ type: 'group', id: 'x' }, 'nothing'); expect(r.ok).toBe(true); expect(r.removed).toBe(false); }); test('person and group grants with same id are distinct principals', async () => { await store.setGrant({ type: 'person', id: 'same' }, 'plex', true); await store.setGrant({ type: 'group', id: 'same' }, 'plex', false); const map = await store.getAccessMap(); expect(map.grants).toHaveLength(2); }); }); describe('network-access-store: validation rejections', () => { let dir, store; beforeEach(() => { dir = _tmpDir(); store = createNetworkAccessStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); test('saveAccessMap rejects non-array grants', async () => { const r = await store.saveAccessMap({ grants: 'nope' }); expect(r.ok).toBe(false); expect(r.reason).toMatch(/invalid_grants/); }); test('saveAccessMap rejects bad principal type', async () => { const r = await store.saveAccessMap({ grants: [{ principal: { type: 'robot', id: 'x' }, resource: 'plex', allowed: true }], }); expect(r.ok).toBe(false); expect(r.reason).toMatch(/invalid_principal_type/); }); test('saveAccessMap rejects missing principal', async () => { const r = await store.saveAccessMap({ grants: [{ resource: 'plex', allowed: true }], }); expect(r.ok).toBe(false); expect(r.reason).toMatch(/invalid_principal/); }); test('saveAccessMap rejects empty/whitespace resource', async () => { const r = await store.saveAccessMap({ grants: [{ principal: { type: 'person', id: 'p1' }, resource: ' ', allowed: true }], }); expect(r.ok).toBe(false); expect(r.reason).toMatch(/invalid_resource/); }); test('saveAccessMap rejects non-string resource', async () => { const r = await store.saveAccessMap({ grants: [{ principal: { type: 'person', id: 'p1' }, resource: 42, allowed: true }], }); expect(r.ok).toBe(false); expect(r.reason).toMatch(/invalid_resource/); }); test('saveAccessMap rejects non-boolean allowed', async () => { const r = await store.saveAccessMap({ grants: [{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: 'yes' }], }); expect(r.ok).toBe(false); expect(r.reason).toMatch(/invalid_allowed/); }); test('setGrant validates the same way', async () => { expect((await store.setGrant({ type: 'boat', id: 'x' }, 'plex', true)).ok).toBe(false); expect((await store.setGrant({ type: 'person', id: 'x' }, '', true)).ok).toBe(false); expect((await store.setGrant({ type: 'person', id: 'x' }, 'plex', 1)).ok).toBe(false); }); test('validateGrant exported helper matches store behavior', () => { expect(validateGrant({ principal: { type: 'person', id: 'a' }, resource: 'r', allowed: false })).toBe(null); expect(validateGrant(null)).toMatch(/invalid_grant/); expect(validateGrant({ principal: { type: 'person' }, resource: 'r', allowed: false })).toMatch(/invalid_principal_id/); }); }); describe('network-access-store: dedupe last-wins', () => { let dir, store; beforeEach(() => { dir = _tmpDir(); store = createNetworkAccessStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); test('saveAccessMap dedupes exact principal+resource, last entry wins', async () => { const r = await store.saveAccessMap({ grants: [ { principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true }, { principal: { type: 'group', id: 'g1' }, resource: 'plex', allowed: true }, { principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: false }, // dup → wins ], }); expect(r.ok).toBe(true); expect(r.map.grants).toHaveLength(2); const p1 = r.map.grants.find(g => g.principal.type === 'person'); expect(p1.allowed).toBe(false); }); test('dedupe key includes principal type (person p1 ≠ group p1)', () => { const r = normalizeGrants([ { principal: { type: 'person', id: 'z' }, resource: 'r', allowed: true }, { principal: { type: 'group', id: 'z' }, resource: 'r', allowed: false }, ]); expect(r.ok).toBe(true); expect(r.grants).toHaveLength(2); }); test('last-wins across all fields', async () => { const r = await store.saveAccessMap({ grants: [ { principal: { type: 'group', id: 'g' }, resource: 'nas', allowed: true }, { principal: { type: 'group', id: 'g' }, resource: 'nas', allowed: false }, { principal: { type: 'group', id: 'g' }, resource: 'nas', allowed: true }, ], }); expect(r.map.grants).toHaveLength(1); expect(r.map.grants[0].allowed).toBe(true); }); }); describe('network-access-store: atomicity + concurrency', () => { let dir, store; beforeEach(() => { dir = _tmpDir(); store = createNetworkAccessStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); test('invalid whole-grid save leaves the previous file untouched', async () => { await store.saveAccessMap({ grants: [{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true }], }); const before = _readFile(dir); const bad = await store.saveAccessMap({ grants: [ { principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true }, { principal: { type: 'ghost', id: 'p2' }, resource: 'plex', allowed: true }, ], }); expect(bad.ok).toBe(false); const after = _readFile(dir); expect(after).toEqual(before); // nothing written, not even updatedAt }); test('no partial file after a failed save (readers never see junk)', async () => { await store.saveAccessMap({ grants: [{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true }], }); await store.saveAccessMap({ grants: 'garbage' }); const map = await store.getAccessMap(); expect(map.grants).toHaveLength(1); }); test('concurrent setGrant calls all land (mutex serialization, no lost update)', async () => { await Promise.all([ store.setGrant({ type: 'person', id: 'p1' }, 'plex', true), store.setGrant({ type: 'person', id: 'p2' }, 'plex', true), store.setGrant({ type: 'group', id: 'g1' }, 'nas', false), store.setGrant({ type: 'person', id: 'p1' }, 'nas', false), ]); const map = await store.getAccessMap(); expect(map.grants).toHaveLength(4); }); test('corrupt JSON on disk falls back to empty map, not a crash', async () => { fs.writeFileSync(path.join(dir, 'network-access.json'), '{not json'); const map = await store.getAccessMap(); expect(map.grants).toEqual([]); }); test('on-disk grants with junk entries are filtered on load', async () => { fs.writeFileSync(path.join(dir, 'network-access.json'), JSON.stringify({ grants: [ { principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true }, { junk: true }, ], updatedAt: '2026-01-01T00:00:00Z', })); const map = await store.getAccessMap(); expect(map.grants).toHaveLength(1); }); }); describe('network-access-store: effectiveAccess', () => { let dir, store; beforeEach(() => { dir = _tmpDir(); store = createNetworkAccessStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); async function seed() { await store.saveAccessMap({ grants: [ // groups { principal: { type: 'group', id: 'family' }, resource: 'plex', allowed: true }, { principal: { type: 'group', id: 'family' }, resource: 'nas', allowed: true }, { principal: { type: 'group', id: 'family' }, resource: 'jellyfin', allowed: false }, { principal: { type: 'group', id: 'adults' }, resource: 'nas', allowed: false }, { principal: { type: 'group', id: 'adults' }, resource: 'radarr', allowed: true }, // person-level { principal: { type: 'person', id: 'sami' }, resource: 'jellyfin', allowed: true }, // override deny { principal: { type: 'person', id: 'kid' }, resource: 'nas', allowed: false }, // override allow ], }); } test('group principal: own grants only', async () => { await seed(); const r = await store.effectiveAccess({ type: 'group', id: 'family' }); expect(r.ok).toBe(true); expect(r.resources).toEqual({ plex: true, nas: true, jellyfin: false }); }); test('person with no groups and no grants → empty resources', async () => { await seed(); const r = await store.effectiveAccess({ type: 'person', id: 'nobody' }); expect(r.ok).toBe(true); expect(r.resources).toEqual({}); }); test('union of groups', async () => { await seed(); const r = await store.effectiveAccess({ type: 'person', id: 'adult1', groups: ['family', 'adults'] }); // union: plex(T)+nas(T)+jellyfin(F) from family, nas(F)+radarr(T) from adults // (same-resource conflict across groups: later-processed write wins is // unspecified, so assert presence, then deterministic bits) expect(r.ok).toBe(true); expect(r.resources.plex).toBe(true); expect(r.resources.jellyfin).toBe(false); expect(r.resources.radarr).toBe(true); expect(typeof r.resources.nas).toBe('boolean'); }); test('person override beats group — explicit false wins over group true', async () => { await seed(); const r = await store.effectiveAccess({ type: 'person', id: 'kid', groups: ['family'] }); expect(r.ok).toBe(true); expect(r.resources.nas).toBe(false); // person false beats family true expect(r.resources.plex).toBe(true); // inherited from group }); test('person override beats group — explicit true wins over group false', async () => { await seed(); const r = await store.effectiveAccess({ type: 'person', id: 'sami', groups: ['family'] }); expect(r.ok).toBe(true); expect(r.resources.jellyfin).toBe(true); // person true beats family false expect(r.resources.nas).toBe(true); // inherited }); test('person-level false beats group false too (no flip)', async () => { await seed(); const r = await store.effectiveAccess({ type: 'person', id: 'kid', groups: ['family', 'adults'] }); expect(r.resources.nas).toBe(false); }); test('person grant on a resource no group touches appears', async () => { await store.setGrant({ type: 'person', id: 'lone' }, 'plex', true); const r = await store.effectiveAccess({ type: 'person', id: 'lone' }); expect(r.resources).toEqual({ plex: true }); }); test('invalid principal rejected with ok:false', async () => { expect((await store.effectiveAccess(null)).ok).toBe(false); expect((await store.effectiveAccess({ type: 'robot', id: 'x' })).ok).toBe(false); expect((await store.effectiveAccess({ type: 'person', id: '' })).ok).toBe(false); expect((await store.effectiveAccess({ type: 'person' })).ok).toBe(false); }); }); describe('network-access-store: subscribe change events', () => { let dir, store; beforeEach(() => { dir = _tmpDir(); store = createNetworkAccessStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); test('saveAccessMap, setGrant, removeGrant each emit the new map', async () => { const seen = []; store.subscribe(map => seen.push(['evt', map.grants.length])); await store.saveAccessMap({ grants: [{ principal: { type: 'group', id: 'g' }, resource: 'plex', allowed: true }] }); await store.setGrant({ type: 'person', id: 'p' }, 'nas', true); await store.removeGrant({ type: 'person', id: 'p' }, 'nas'); expect(seen).toEqual([['evt', 1], ['evt', 2], ['evt', 1]]); }); test('event map includes updatedAt', async () => { let got = null; store.subscribe(map => { got = map; }); await store.setGrant({ type: 'person', id: 'p' }, 'plex', true); expect(got).toBeTruthy(); expect(typeof got.updatedAt).toBe('string'); expect(got.grants).toHaveLength(1); }); test('failed saves emit nothing', async () => { const seen = []; store.subscribe(() => seen.push(1)); await store.saveAccessMap({ grants: [{ bad: true }] }); await store.removeGrant({ type: 'person', id: 'ghost' }, 'none'); // no-op expect(seen).toEqual([]); }); test('unsubscribe stops events', async () => { const seen = []; const off = store.subscribe(() => seen.push(1)); await store.setGrant({ type: 'person', id: 'p' }, 'plex', true); off(); await store.setGrant({ type: 'person', id: 'p' }, 'nas', true); expect(seen).toEqual([1]); }); test('a throwing listener is isolated — other listeners still run, save still works', async () => { const seen = []; store.subscribe(() => { throw new Error('boom'); }); store.subscribe(() => seen.push('ok')); const r = await store.setGrant({ type: 'person', id: 'p' }, 'plex', true); expect(r.ok).toBe(true); expect(seen).toEqual(['ok']); expect((await store.getAccessMap()).grants).toHaveLength(1); }); test('subscribe requires a function', () => { expect(() => store.subscribe('nope')).toThrow(TypeError); }); }); describe('network-access-store: derived views', () => { let dir, store; beforeEach(() => { dir = _tmpDir(); store = createNetworkAccessStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); test('listPrincipals aggregates grant counts, sorted by type then id', async () => { await store.saveAccessMap({ grants: [ { principal: { type: 'person', id: 'zeta' }, resource: 'plex', allowed: true }, { principal: { type: 'person', id: 'zeta' }, resource: 'nas', allowed: false }, { principal: { type: 'group', id: 'family' }, resource: 'plex', allowed: true }, { principal: { type: 'person', id: 'abe' }, resource: 'nas', allowed: true }, ], }); const principals = await store.listPrincipals(); expect(principals).toEqual([ { type: 'group', id: 'family', grantCount: 1 }, { type: 'person', id: 'abe', grantCount: 1 }, { type: 'person', id: 'zeta', grantCount: 2 }, ]); }); test('listResources distinct + sorted', async () => { await store.saveAccessMap({ grants: [ { principal: { type: 'person', id: 'p' }, resource: 'nas', allowed: true }, { principal: { type: 'group', id: 'g' }, resource: 'plex', allowed: true }, { principal: { type: 'group', id: 'g' }, resource: 'nas', allowed: false }, ], }); expect(await store.listResources()).toEqual(['nas', 'plex']); }); test('empty map → empty views', async () => { expect(await store.listPrincipals()).toEqual([]); expect(await store.listResources()).toEqual([]); }); });