merge dc/DC-144 into p7-wave1

This commit is contained in:
Hermes committed 2026-10-03 21:38:34 -07:00
commit de6a2fa4ae
6 files changed
+1351

No files matched your search

@@ -0,0 +1,228 @@
/**
* Route tests for network-groups routes (DC-144).
* Coverage:
* - CRUD contract (GET list, POST create, PATCH rename, DELETE)
* - derived listing: 'Admin' always present; groups referenced from
* person.groups[] appear automatically
* - slug validation (bad ids rejected, DashCaddy-local strings only)
* - duplicate create → 409
* - 'Admin' cannot be renamed or deleted (409)
* - rename cascade: grant references re-pointed to the new id
* - delete cascade: group stripped from person.groups[] AND from
* every access grant referencing it
* Hermetic — supertest against an in-process express app over real
* stores in a temp dir, no network.
*/
'use strict';
const express = require('express');
const request = require('supertest');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { createPeopleStore } = require('../src/security/network-people-store');
const { createNetworkAccessStore } = require('../src/security/network-access-store');
const { createGroupsService } = require('../src/security/network-groups');
const networkGroupsRoutes = require('../routes/network-groups');
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-grouproutes-'));
const peopleStore = createPeopleStore({ dataDir: dir });
const accessStore = createNetworkAccessStore({ dataDir: dir });
const groupsService = createGroupsService({ dataDir: dir, peopleStore, accessStore });
const router = networkGroupsRoutes({
groupsService,
peopleStore,
accessStore,
asyncHandler,
log: { info: () => {}, warn: () => {}, error: () => {} },
});
const app = express();
app.use(express.json());
app.use('/network/groups', router);
app.use((err, _req, res, _next) => {
const status = err.statusCode || 500;
res.status(status).json({ success: false, error: err.message, code: err.code || null });
});
return { app, dir, peopleStore, accessStore, groupsService };
}
function _cleanup(dir) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}
describe('network-groups routes: contract', () => {
let dir, app, peopleStore;
beforeEach(() => {
({ app, dir, peopleStore } = createApp());
});
afterEach(() => _cleanup(dir));
test('GET / lists groups, Admin always present', async () => {
const res = await request(app).get('/network/groups');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.data)).toBe(true);
const ids = res.body.data.map(g => g.id);
expect(ids).toContain('Admin');
expect(res.body.count).toBe(ids.length);
});
test('GET / derives groups referenced from person.groups[]', async () => {
await peopleStore.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1', groups: ['family', 'Admin'] });
const res = await request(app).get('/network/groups');
const ids = res.body.data.map(g => g.id);
expect(ids).toContain('family');
const family = res.body.data.find(g => g.id === 'family');
expect(family.memberCount).toBe(1);
});
test('POST / creates a group', async () => {
const res = await request(app).post('/network/groups').send({ id: 'family', name: 'Family' });
expect(res.status).toBe(201);
expect(res.body.success).toBe(true);
expect(res.body.group).toMatchObject({ id: 'family', name: 'Family', memberCount: 0 });
});
test('POST / rejects invalid slugs (DashCaddy-local ids only)', async () => {
for (const bad of ['Family', 'has space', 'UPPER', 'under_score', '', 'a/b']) {
const res = await request(app).post('/network/groups').send({ id: bad, name: 'X' });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
}
});
test('POST / rejects missing/blank name', async () => {
const res = await request(app).post('/network/groups').send({ id: 'family', name: ' ' });
expect(res.status).toBe(400);
});
test('POST / duplicate id → 409 (including Admin)', async () => {
await request(app).post('/network/groups').send({ id: 'family', name: 'Family' });
const dup = await request(app).post('/network/groups').send({ id: 'family', name: 'Other' });
expect(dup.status).toBe(409);
const admin = await request(app).post('/network/groups').send({ id: 'Admin', name: 'Admin2' });
expect(admin.status).toBe(409);
});
test('PATCH /:id renames display name', async () => {
await request(app).post('/network/groups').send({ id: 'family', name: 'Family' });
const res = await request(app).patch('/network/groups/family').send({ name: 'Fam' });
expect(res.status).toBe(200);
expect(res.body.group.name).toBe('Fam');
expect(res.body.group.id).toBe('family');
});
test('PATCH /:id unknown group → 404', async () => {
const res = await request(app).patch('/network/groups/nope').send({ name: 'X' });
expect(res.status).toBe(404);
});
test('DELETE /:id deletes an empty group', async () => {
await request(app).post('/network/groups').send({ id: 'family', name: 'Family' });
const res = await request(app).delete('/network/groups/family');
expect(res.status).toBe(200);
expect(res.body.deleted).toBe(true);
const after = await request(app).get('/network/groups');
expect(after.body.data.map(g => g.id)).not.toContain('family');
});
test('DELETE /:id unknown → 404', async () => {
const res = await request(app).delete('/network/groups/nope');
expect(res.status).toBe(404);
});
});
describe('network-groups routes: Admin protection', () => {
let dir, app;
beforeEach(() => {
({ app, dir } = createApp());
});
afterEach(() => _cleanup(dir));
test('DELETE /Admin → 409', async () => {
const res = await request(app).delete('/network/groups/Admin');
expect(res.status).toBe(409);
expect(res.body.success).toBe(false);
});
test('PATCH /Admin → 409', async () => {
const res = await request(app).patch('/network/groups/Admin').send({ name: 'Root' });
expect(res.status).toBe(409);
});
});
describe('network-groups routes: cascades', () => {
let dir, app, peopleStore, accessStore;
beforeEach(() => {
({ app, dir, peopleStore, accessStore } = createApp());
});
afterEach(() => _cleanup(dir));
test('DELETE /:id removes the group from person.groups[] and grants', async () => {
await peopleStore.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1', groups: ['family'] });
await peopleStore.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2', groups: ['family', 'friends'] });
await accessStore.setGrant({ type: 'group', id: 'family' }, 'plex', true);
await accessStore.setGrant({ type: 'group', id: 'friends' }, 'plex', true); // must survive
const res = await request(app).delete('/network/groups/family');
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ deleted: true, id: 'family', removedFromPeople: 2, removedGrants: 1 });
const people = await peopleStore.listPeople();
for (const p of people) expect(p.groups).not.toContain('family');
const bob = people.find(p => p.displayName === 'Bob');
expect(bob.groups).toEqual(['friends']);
const map = await accessStore.getAccessMap();
const refs = map.grants.filter(g => g.principal.type === 'group' && g.principal.id === 'family');
expect(refs).toHaveLength(0);
expect(map.grants.some(g => g.principal.id === 'friends')).toBe(true);
});
test('PATCH /:id with new id re-points grant references and person.groups[]', async () => {
await peopleStore.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1', groups: ['family'] });
await accessStore.setGrant({ type: 'group', id: 'family' }, 'plex', true);
await accessStore.setGrant({ type: 'person', id: 'someone' }, 'plex', true); // untouched
const res = await request(app).patch('/network/groups/family').send({ id: 'household' });
expect(res.status).toBe(200);
expect(res.body.group.id).toBe('household');
expect(res.body.group.peopleUpdated).toBe(1);
expect(res.body.group.grantsUpdated).toBe(1);
const people = await peopleStore.listPeople();
expect(people[0].groups).toEqual(['household']);
const map = await accessStore.getAccessMap();
expect(map.grants.some(g => g.principal.type === 'group' && g.principal.id === 'household')).toBe(true);
expect(map.grants.some(g => g.principal.type === 'group' && g.principal.id === 'family')).toBe(false);
expect(map.grants.some(g => g.principal.type === 'person' && g.principal.id === 'someone')).toBe(true);
});
test('PATCH /:id refuses renaming onto an existing id (409)', async () => {
await peopleStore.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1', groups: ['family'] });
await peopleStore.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2', groups: ['friends'] });
const res = await request(app).patch('/network/groups/family').send({ id: 'friends' });
expect(res.status).toBe(409);
// and nothing moved
const people = await peopleStore.listPeople();
expect(people.find(p => p.displayName === 'Alice').groups).toEqual(['family']);
});
test('groups never leak into Tailscale ACL semantics: ids stay DashCaddy-local strings', async () => {
// The slug gate is the enforcement point — any id that could be
// interpreted as a foreign ACL tag/group reference (colons, emails,
// tag:prefixes) is rejected at create AND rename.
const badCreate = await request(app).post('/network/groups').send({ id: 'tag:server', name: 'X' });
expect(badCreate.status).toBe(400);
await request(app).post('/network/groups').send({ id: 'family', name: 'Family' });
const badRename = await request(app).patch('/network/groups/family').send({ id: 'user@corp' });
expect(badRename.status).toBe(400);
});
});
@@ -0,0 +1,238 @@
/**
* Route tests for network-resources routes (DC-144).
* Coverage:
* - CRUD contract (GET list, GET one, POST, PATCH, DELETE)
* - validation errors: bad slug name, port out of range / non-integer,
* empty host, bad type/protocol/sensitive
* - duplicate create → 409; unknown name → 404; name immutable on PATCH
* - sensitive flag round-trip (create true → read true → flip → false)
* - defaults: displayName falls back to name, type 'app', manual true
* - services-registry seeding: skipped silently when the services file
* is missing/unreadable; seeded entries are manual:false
* Hermetic — supertest against an in-process express app over a real
* store in a temp dir, no network.
*/
'use strict';
const express = require('express');
const request = require('supertest');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { createResourcesStore } = require('../src/security/network-resources');
const networkResourcesRoutes = require('../routes/network-resources');
function asyncHandler(fn) {
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
}
function createApp({ servicesFile } = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-resourceroutes-'));
const resourcesStore = createResourcesStore({ dataDir: dir, servicesFile });
const router = networkResourcesRoutes({
resourcesStore,
asyncHandler,
log: { info: () => {}, warn: () => {}, error: () => {} },
});
const app = express();
app.use(express.json());
app.use('/network/resources', router);
app.use((err, _req, res, _next) => {
const status = err.statusCode || 500;
res.status(status).json({ success: false, error: err.message, code: err.code || null });
});
return { app, dir, resourcesStore };
}
function _cleanup(dir) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}
describe('network-resources routes: contract', () => {
let dir, app;
beforeEach(() => {
({ app, dir } = createApp());
});
afterEach(() => _cleanup(dir));
test('GET / lists resources (empty initially)', async () => {
const res = await request(app).get('/network/resources');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data).toEqual([]);
expect(res.body.count).toBe(0);
});
test('POST / creates a resource with defaults', async () => {
const res = await request(app).post('/network/resources')
.send({ name: 'plex', host: 'plex.internal', port: 32400 });
expect(res.status).toBe(201);
expect(res.body.resource).toMatchObject({
name: 'plex',
displayName: 'plex',
type: 'app',
host: 'plex.internal',
port: 32400,
protocol: 'http',
sensitive: false,
manual: true,
});
});
test('POST / accepts explicit displayName/type/protocol/sensitive', async () => {
const res = await request(app).post('/network/resources')
.send({ name: 'nas', displayName: 'NAS Box', type: 'device', host: 'nas.internal', port: 5000, protocol: 'https', sensitive: true });
expect(res.status).toBe(201);
expect(res.body.resource).toMatchObject({ displayName: 'NAS Box', type: 'device', protocol: 'https', sensitive: true });
});
test('GET /:name returns one resource; 404 unknown', async () => {
await request(app).post('/network/resources').send({ name: 'plex', host: 'plex.internal', port: 32400 });
const hit = await request(app).get('/network/resources/plex');
expect(hit.status).toBe(200);
expect(hit.body.resource.name).toBe('plex');
const miss = await request(app).get('/network/resources/nope');
expect(miss.status).toBe(404);
});
test('POST / duplicate name → 409', async () => {
await request(app).post('/network/resources').send({ name: 'plex', host: 'plex.internal', port: 32400 });
const res = await request(app).post('/network/resources').send({ name: 'plex', host: 'other', port: 1 });
expect(res.status).toBe(409);
});
test('PATCH /:name updates fields', async () => {
await request(app).post('/network/resources').send({ name: 'plex', host: 'plex.internal', port: 32400 });
const res = await request(app).patch('/network/resources/plex').send({ port: 32401, displayName: 'Plex!' });
expect(res.status).toBe(200);
expect(res.body.resource).toMatchObject({ port: 32401, displayName: 'Plex!' });
});
test('PATCH /:name: name is immutable; 404 unknown', async () => {
await request(app).post('/network/resources').send({ name: 'plex', host: 'plex.internal', port: 32400 });
const rename = await request(app).patch('/network/resources/plex').send({ name: 'plex2' });
expect(rename.status).toBe(400);
const missing = await request(app).patch('/network/resources/nope').send({ port: 80 });
expect(missing.status).toBe(404);
});
test('DELETE /:name deletes; second delete → 404', async () => {
await request(app).post('/network/resources').send({ name: 'plex', host: 'plex.internal', port: 32400 });
const res = await request(app).delete('/network/resources/plex');
expect(res.status).toBe(200);
expect(res.body.deleted).toBe(true);
const again = await request(app).delete('/network/resources/plex');
expect(again.status).toBe(404);
});
});
describe('network-resources routes: validation', () => {
let dir, app;
beforeEach(() => {
({ app, dir } = createApp());
});
afterEach(() => _cleanup(dir));
test('rejects bad name slugs', async () => {
for (const bad of ['Plex', 'has space', 'under_score', '', 'a/b', 'dot.name']) {
const res = await request(app).post('/network/resources').send({ name: bad, host: 'h', port: 80 });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
}
});
test('rejects ports outside 1-65535 and non-integers', async () => {
for (const bad of [0, -1, 65536, 99999, 80.5, '80', null]) {
const res = await request(app).post('/network/resources').send({ name: 'plex', host: 'h', port: bad });
expect(res.status).toBe(400);
}
});
test('rejects empty/missing host', async () => {
const missing = await request(app).post('/network/resources').send({ name: 'plex', port: 80 });
expect(missing.status).toBe(400);
const blank = await request(app).post('/network/resources').send({ name: 'plex', host: ' ', port: 80 });
expect(blank.status).toBe(400);
});
test('rejects bad type/protocol/sensitive', async () => {
expect((await request(app).post('/network/resources').send({ name: 'plex', host: 'h', port: 80, type: 'vm' })).status).toBe(400);
expect((await request(app).post('/network/resources').send({ name: 'plex', host: 'h', port: 80, protocol: 'grpc' })).status).toBe(400);
expect((await request(app).post('/network/resources').send({ name: 'plex', host: 'h', port: 80, sensitive: 'yes' })).status).toBe(400);
});
test('accepts boundary ports 1 and 65535', async () => {
const lo = await request(app).post('/network/resources').send({ name: 'lo', host: 'h', port: 1 });
expect(lo.status).toBe(201);
const hi = await request(app).post('/network/resources').send({ name: 'hi', host: 'h', port: 65535 });
expect(hi.status).toBe(201);
});
});
describe('network-resources routes: sensitive round-trip + seeding', () => {
let dir, app;
afterEach(() => _cleanup(dir));
test('sensitive flag round-trips through create, read, update, re-read', async () => {
({ app, dir } = createApp());
await request(app).post('/network/resources')
.send({ name: 'vault', host: 'vault.internal', port: 8200, sensitive: true });
let res = await request(app).get('/network/resources/vault');
expect(res.status).toBe(200);
expect(res.body.resource.sensitive).toBe(true);
await request(app).patch('/network/resources/vault').send({ sensitive: false });
res = await request(app).get('/network/resources/vault');
expect(res.body.resource.sensitive).toBe(false);
// …and persists on disk (round-trip through a fresh store read).
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'network-resources.json'), 'utf8'));
expect(raw.resources.find(r => r.name === 'vault').sensitive).toBe(false);
});
test('seed skipped silently when services file is missing (manual entries only)', async () => {
const servicesFile = path.join(os.tmpdir(), `no-such-services-${Date.now()}.json`);
({ app, dir } = createApp({ servicesFile }));
const res = await request(app).get('/network/resources');
expect(res.status).toBe(200);
expect(res.body.data).toEqual([]);
expect(res.body.count).toBe(0);
// manual creation still works after a skipped seed
const create = await request(app).post('/network/resources')
.send({ name: 'manual', host: 'm.internal', port: 8080 });
expect(create.status).toBe(201);
expect(create.body.resource.manual).toBe(true);
});
test('seed skipped when services file is corrupt JSON (no crash)', async () => {
const badDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-badseed-'));
const servicesFile = path.join(badDir, 'services.json');
fs.writeFileSync(servicesFile, '{not json', 'utf8');
({ app, dir } = createApp({ servicesFile }));
const res = await request(app).get('/network/resources');
expect(res.status).toBe(200);
expect(res.body.data).toEqual([]);
_cleanup(badDir);
});
test('seeds from services registry when present (manual:false)', async () => {
const seedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-seed-'));
const servicesFile = path.join(seedDir, 'services.json');
fs.writeFileSync(servicesFile, JSON.stringify([
{ id: 'jellyfin', port: 8096 },
{ id: 'sonarr', port: 8989, host: 'sonarr.internal' },
{ id: 'bad id!', port: 80 }, // non-slug id → skipped
{ id: 'noport' }, // no port → skipped
]), 'utf8');
({ app, dir } = createApp({ servicesFile }));
const res = await request(app).get('/network/resources');
expect(res.status).toBe(200);
expect(res.body.count).toBe(2);
const jf = res.body.data.find(r => r.name === 'jellyfin');
expect(jf).toMatchObject({ host: 'localhost', port: 8096, manual: false, sensitive: false });
const so = res.body.data.find(r => r.name === 'sonarr');
expect(so).toMatchObject({ host: 'sonarr.internal', port: 8989, manual: false });
_cleanup(seedDir);
});
});