322 lines
12 KiB
JavaScript
322 lines
12 KiB
JavaScript
/**
|
|
* 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 });
|
|
});
|
|
});
|