DC-144: DashCaddy-side groups + resources model/routes with cascade delete + tests
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user