/** * 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 }); }); });