merge dc/DC-145 into p7-wave1
This commit is contained in:
4 files changed
+1264
No files matched your search
@@ -0,0 +1,455 @@
|
||||
/**
|
||||
* 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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Route tests for /network/access (DC-145).
|
||||
* Hermetic: real network-access-store against a tmp dir, express + supertest,
|
||||
* no auth middleware (the factory is mounted under the authed apiRouter in
|
||||
* the real app — here we exercise the route contract only).
|
||||
*
|
||||
* Coverage:
|
||||
* - GET / (whole map incl. empty default)
|
||||
* - PUT / (whole-grid atomic save; validation errors → 400; bad payload
|
||||
* leaves file untouched)
|
||||
* - POST /grant + DELETE /grant (upsert/remove, 400 validation, 404
|
||||
* removing an absent grant)
|
||||
* - GET /effective/:principalType/:principalId (group + person via
|
||||
* ?groups=, person-override-beats-group, 400 on bad principalType)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const { createNetworkAccessStore } = require('../src/security/network-access-store');
|
||||
const networkAccessRoutes = require('../routes/network-access');
|
||||
|
||||
function asyncHandler(fn) {
|
||||
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
}
|
||||
|
||||
function createApp() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-netaccess-routes-'));
|
||||
const store = createNetworkAccessStore({ dataDir: dir });
|
||||
const router = networkAccessRoutes({ accessStore: store, asyncHandler });
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/v1/network/access', router);
|
||||
app.use((err, _req, res, _next) => {
|
||||
const status = err.statusCode || 500;
|
||||
res.status(status).json({ success: false, error: err.message });
|
||||
});
|
||||
return { app, store, dir };
|
||||
}
|
||||
|
||||
function cleanup(dir) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
// each test creates its own dir via createApp()
|
||||
});
|
||||
|
||||
describe('network-access routes: GET / (whole map)', () => {
|
||||
let ctx;
|
||||
afterEach(() => ctx && cleanup(ctx.dir));
|
||||
|
||||
test('empty map default → 200 {success, grants: [], updatedAt: null}', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app).get('/api/v1/network/access');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.grants).toEqual([]);
|
||||
expect(res.body.updatedAt).toBe(null);
|
||||
});
|
||||
|
||||
test('returns saved grants after PUT', async () => {
|
||||
ctx = createApp();
|
||||
await request(ctx.app)
|
||||
.put('/api/v1/network/access')
|
||||
.send({ grants: [{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true }] });
|
||||
const res = await request(ctx.app).get('/api/v1/network/access');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.grants).toHaveLength(1);
|
||||
expect(res.body.updatedAt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-access routes: PUT / (whole-grid atomic save)', () => {
|
||||
let ctx;
|
||||
afterEach(() => ctx && cleanup(ctx.dir));
|
||||
|
||||
test('valid grid saved and returned', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.put('/api/v1/network/access')
|
||||
.send({
|
||||
grants: [
|
||||
{ principal: { type: 'group', id: 'family' }, resource: 'plex', allowed: true },
|
||||
{ principal: { type: 'person', id: 'sami' }, resource: 'nas', allowed: false },
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.grants).toHaveLength(2);
|
||||
expect(typeof res.body.updatedAt).toBe('string');
|
||||
});
|
||||
|
||||
test('duplicates deduped last-wins server-side', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.put('/api/v1/network/access')
|
||||
.send({
|
||||
grants: [
|
||||
{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true },
|
||||
{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: false },
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.grants).toHaveLength(1);
|
||||
expect(res.body.grants[0].allowed).toBe(false);
|
||||
});
|
||||
|
||||
test('400 on bad principal type', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.put('/api/v1/network/access')
|
||||
.send({ grants: [{ principal: { type: 'robot', id: 'x' }, resource: 'plex', allowed: true }] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
expect(res.body.error).toMatch(/principal_type/);
|
||||
});
|
||||
|
||||
test('400 on empty resource', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.put('/api/v1/network/access')
|
||||
.send({ grants: [{ principal: { type: 'person', id: 'p1' }, resource: '', allowed: true }] });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('400 on non-boolean allowed', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.put('/api/v1/network/access')
|
||||
.send({ grants: [{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: 'true' }] });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('400 on non-object body / missing grants', async () => {
|
||||
ctx = createApp();
|
||||
const r1 = await request(ctx.app).put('/api/v1/network/access').send([1, 2]);
|
||||
expect(r1.status).toBe(400);
|
||||
const r2 = await request(ctx.app).put('/api/v1/network/access').send({});
|
||||
expect(r2.status).toBe(400);
|
||||
});
|
||||
|
||||
test('failed validation leaves previous map on disk untouched', async () => {
|
||||
ctx = createApp();
|
||||
await request(ctx.app)
|
||||
.put('/api/v1/network/access')
|
||||
.send({ grants: [{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true }] });
|
||||
const before = fs.readFileSync(path.join(ctx.dir, 'network-access.json'), 'utf8');
|
||||
await request(ctx.app)
|
||||
.put('/api/v1/network/access')
|
||||
.send({ grants: [{ principal: { type: 'ghost', id: 'p2' }, resource: 'plex', allowed: true }] });
|
||||
const after = fs.readFileSync(path.join(ctx.dir, 'network-access.json'), 'utf8');
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-access routes: POST /grant', () => {
|
||||
let ctx;
|
||||
afterEach(() => ctx && cleanup(ctx.dir));
|
||||
|
||||
test('creates a grant → 201 with normalized grant', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.post('/api/v1/network/access/grant')
|
||||
.send({ principal: { type: 'group', id: 'family' }, resource: 'plex', allowed: true });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.grant).toEqual({
|
||||
principal: { type: 'group', id: 'family' }, resource: 'plex', allowed: true,
|
||||
});
|
||||
const map = await ctx.store.getAccessMap();
|
||||
expect(map.grants).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('upsert: second POST replaces first (no duplicate)', async () => {
|
||||
ctx = createApp();
|
||||
await request(ctx.app)
|
||||
.post('/api/v1/network/access/grant')
|
||||
.send({ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true });
|
||||
const res = await request(ctx.app)
|
||||
.post('/api/v1/network/access/grant')
|
||||
.send({ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: false });
|
||||
expect(res.status).toBe(201);
|
||||
const map = await ctx.store.getAccessMap();
|
||||
expect(map.grants).toHaveLength(1);
|
||||
expect(map.grants[0].allowed).toBe(false);
|
||||
});
|
||||
|
||||
test('400 when principal missing', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.post('/api/v1/network/access/grant')
|
||||
.send({ resource: 'plex', allowed: true });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/principal/);
|
||||
});
|
||||
|
||||
test('400 when allowed not boolean', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.post('/api/v1/network/access/grant')
|
||||
.send({ principal: { type: 'person', id: 'p1' }, resource: 'plex' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/allowed/);
|
||||
});
|
||||
|
||||
test('400 when resource empty', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.post('/api/v1/network/access/grant')
|
||||
.send({ principal: { type: 'person', id: 'p1' }, resource: ' ', allowed: true });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-access routes: DELETE /grant', () => {
|
||||
let ctx;
|
||||
afterEach(() => ctx && cleanup(ctx.dir));
|
||||
|
||||
test('removes an existing grant → 200', async () => {
|
||||
ctx = createApp();
|
||||
await ctx.store.setGrant({ type: 'person', id: 'p1' }, 'plex', true);
|
||||
const res = await request(ctx.app)
|
||||
.delete('/api/v1/network/access/grant')
|
||||
.send({ principal: { type: 'person', id: 'p1' }, resource: 'plex' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.removed).toBe(true);
|
||||
expect((await ctx.store.getAccessMap()).grants).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('404 when grant absent', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.delete('/api/v1/network/access/grant')
|
||||
.send({ principal: { type: 'person', id: 'ghost' }, resource: 'plex' });
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
test('400 when principal invalid', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.delete('/api/v1/network/access/grant')
|
||||
.send({ principal: { type: 'ghost', id: 'x' }, resource: 'plex' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('400 when resource missing', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.delete('/api/v1/network/access/grant')
|
||||
.send({ principal: { type: 'person', id: 'p1' } });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('query-string form also works (bodyless DELETE)', async () => {
|
||||
ctx = createApp();
|
||||
await ctx.store.setGrant({ type: 'group', id: 'g' }, 'nas', true);
|
||||
const res = await request(ctx.app)
|
||||
.delete('/api/v1/network/access/grant?resource=nas&principal%5Btype%5D=group&principal%5Bid%5D=g');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.removed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-access routes: GET /effective/:type/:id', () => {
|
||||
let ctx;
|
||||
afterEach(() => ctx && cleanup(ctx.dir));
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = createApp();
|
||||
await ctx.store.saveAccessMap({
|
||||
grants: [
|
||||
{ principal: { type: 'group', id: 'family' }, resource: 'plex', allowed: true },
|
||||
{ principal: { type: 'group', id: 'family' }, resource: 'nas', allowed: false },
|
||||
{ principal: { type: 'person', id: 'sami' }, resource: 'nas', allowed: true }, // override
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('group effective access', async () => {
|
||||
const res = await request(ctx.app).get('/api/v1/network/access/effective/group/family');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.resources).toEqual({ plex: true, nas: false });
|
||||
expect(res.body.principal).toEqual({ type: 'group', id: 'family' });
|
||||
});
|
||||
|
||||
test('person with groups query param inherits group grants', async () => {
|
||||
const res = await request(ctx.app).get('/api/v1/network/access/effective/person/randomuser?groups=family');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.resources).toEqual({ plex: true, nas: false });
|
||||
});
|
||||
|
||||
test('person-level grant overrides group grant', async () => {
|
||||
const res = await request(ctx.app).get('/api/v1/network/access/effective/person/sami?groups=family');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.resources).toEqual({ plex: true, nas: true }); // person true beats group false
|
||||
});
|
||||
|
||||
test('person with no groups → only person grants', async () => {
|
||||
const res = await request(ctx.app).get('/api/v1/network/access/effective/person/sami');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.resources).toEqual({ nas: true });
|
||||
});
|
||||
|
||||
test('400 on invalid principalType', async () => {
|
||||
const res = await request(ctx.app).get('/api/v1/network/access/effective/robot/x');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/principalType|person.*group/);
|
||||
});
|
||||
|
||||
test('empty groups param ignored (not [""])', async () => {
|
||||
const res = await request(ctx.app).get('/api/v1/network/access/effective/person/sami?groups=');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.resources).toEqual({ nas: true });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user