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