/** * Route tests for network-people routes (DC-141). * Coverage: * - contract for all verbs (GET list, GET one, POST, PATCH, DELETE, POST /sync) * - first-person-admin bootstrap through the API * - 404s for unknown ids * - validation errors (400) for bad bodies * - DELETE refuses the last active admin (409) * - sync: mocked provider merge, idempotency, protectedAt never * downgraded, blocked/removed not resurrected, missing registry → * clean inactive response * Hermetic — supertest against an in-process express app, 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, ADMIN_GROUP } = require('../src/security/network-people-store'); const networkPeopleRoutes = require('../routes/network-people'); function asyncHandler(fn) { return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); } function createApp({ peopleStore, networkRegistry } = {}) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-peopleroutes-')); const store = peopleStore || createPeopleStore({ dataDir: dir }); const factory = networkPeopleRoutes; const router = factory({ peopleStore: store, networkRegistry, asyncHandler, log: { info: () => {}, warn: () => {}, error: () => {} }, }); const app = express(); app.use(express.json()); app.use('/network/people', 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, store, dir }; } function _cleanup(dir) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} } function _mockRegistry(people) { return { getActiveProvider: () => ({ id: () => 'mock', listPeople: () => Promise.resolve(people), }), }; } describe('network-people routes: contract', () => { let dir, app, store; beforeEach(() => { ({ app, store, dir } = createApp()); }); afterEach(() => _cleanup(dir)); test('GET / lists people', async () => { await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' }); const res = await request(app).get('/network/people'); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(Array.isArray(res.body.data)).toBe(true); expect(res.body.count).toBe(1); expect(res.body.data[0].displayName).toBe('Alice'); }); test('GET /:id returns a person; 404 for unknown', async () => { const p = await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' }); const good = await request(app).get(`/network/people/${p.id}`); expect(good.status).toBe(200); expect(good.body.person.id).toBe(p.id); const bad = await request(app).get('/network/people/ghost'); expect(bad.status).toBe(404); expect(bad.body.success).toBe(false); }); test('POST / creates a person (201)', async () => { const res = await request(app) .post('/network/people') .send({ displayName: 'Alice', providerUserId: 'ts-1' }); expect(res.status).toBe(201); expect(res.body.success).toBe(true); expect(res.body.person.displayName).toBe('Alice'); expect(res.body.person.status).toBe('active'); }); test('POST / first person ever gets Admin group + protectedAt', async () => { const res = await request(app) .post('/network/people') .send({ displayName: 'Sami', providerUserId: 'ts-owner' }); expect(res.status).toBe(201); expect(res.body.person.groups).toEqual([ADMIN_GROUP]); expect(res.body.person.protectedAt).toBeTruthy(); const second = await request(app) .post('/network/people') .send({ displayName: 'Friend', providerUserId: 'ts-2' }); expect(second.body.person.groups).toEqual([]); expect(second.body.person.protectedAt).toBe(null); }); test('POST / validation errors: missing displayName (400)', async () => { const res = await request(app) .post('/network/people') .send({ providerUserId: 'ts-1' }); expect(res.status).toBe(400); expect(res.body.success).toBe(false); expect(res.body.error).toMatch(/displayName/); }); test('POST / validation errors: missing providerUserId (400)', async () => { const res = await request(app) .post('/network/people') .send({ displayName: 'Alice' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/providerUserId/); }); test('POST / validation errors: groups not an array (400)', async () => { const res = await request(app) .post('/network/people') .send({ displayName: 'Alice', providerUserId: 'ts-1', groups: 'Admin' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/groups/); }); test('PATCH /:id updates fields', async () => { const p = await store.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2' }); const res = await request(app) .patch(`/network/people/${p.id}`) .send({ displayName: 'Bobby', groups: ['Friends'] }); expect(res.status).toBe(200); expect(res.body.person.displayName).toBe('Bobby'); expect(res.body.person.groups).toEqual(['Friends']); }); test('PATCH /:id 404 for unknown id; 400 for bad status', async () => { const missing = await request(app).patch('/network/people/ghost').send({ displayName: 'X' }); expect(missing.status).toBe(404); const p = await store.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2' }); const bad = await request(app) .patch(`/network/people/${p.id}`) .send({ status: 'zombie' }); expect(bad.status).toBe(400); expect(bad.body.error).toMatch(/status/); }); test('PATCH /:id 400 for bad displayName', async () => { const p = await store.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2' }); const res = await request(app) .patch(`/network/people/${p.id}`) .send({ displayName: '' }); expect(res.status).toBe(400); }); test('DELETE /:id deletes a person', async () => { await store.upsertPerson({ displayName: 'Owner', providerUserId: 'ts-0' }); // bootstrap admin const p = await store.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2' }); const res = await request(app).delete(`/network/people/${p.id}`); expect(res.status).toBe(200); expect(res.body.deleted).toBe(true); expect(await store.getPerson(p.id)).toBe(null); }); test('DELETE /:id 404 for unknown id', async () => { const res = await request(app).delete('/network/people/ghost'); expect(res.status).toBe(404); }); test('DELETE /:id refuses the last active admin (409)', async () => { const solo = await store.upsertPerson({ displayName: 'Sami', providerUserId: 'ts-owner' }); expect(solo.groups).toEqual([ADMIN_GROUP]); // bootstrap admin const res = await request(app).delete(`/network/people/${solo.id}`); expect(res.status).toBe(409); expect(res.body.success).toBe(false); expect(res.body.error).toMatch(/last active admin/); // Person is still there. expect(await store.getPerson(solo.id)).toBeTruthy(); }); test('DELETE /:id succeeds when another active admin exists', async () => { const a = await store.upsertPerson({ displayName: 'Sami', providerUserId: 'ts-owner' }); const b = await store.upsertPerson({ displayName: 'Deputy', providerUserId: 'ts-2' }); await store.updatePerson(b.id, { groups: [ADMIN_GROUP] }); const res = await request(app).delete(`/network/people/${a.id}`); expect(res.status).toBe(200); }); }); describe('network-people routes: sync', () => { let dir, app, store; afterEach(() => _cleanup(dir)); test('no networkRegistry → synced:false, no throw', async () => { ({ app, store, dir } = createApp()); const res = await request(app).post('/network/people/sync'); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body.synced).toBe(false); expect(res.body.added).toBe(0); }); test('registry without getActiveProvider → synced:false', async () => { ({ app, store, dir } = createApp({ networkRegistry: {} })); const res = await request(app).post('/network/people/sync'); expect(res.status).toBe(200); expect(res.body.synced).toBe(false); }); test('getActiveProvider returning undefined → synced:false', async () => { ({ app, store, dir } = createApp({ networkRegistry: { getActiveProvider: () => undefined } })); const res = await request(app).post('/network/people/sync'); expect(res.status).toBe(200); expect(res.body.synced).toBe(false); }); test('adds missing people and updates displayNames of existing', async () => { ({ app, store, dir } = createApp({ networkRegistry: _mockRegistry([ { providerUserId: 'ts-1', displayName: 'Alice Updated' }, { providerUserId: 'ts-9', displayName: 'New Person' }, ]), })); await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' }); const res = await request(app).post('/network/people/sync'); expect(res.status).toBe(200); expect(res.body.synced).toBe(true); expect(res.body.added).toBe(1); expect(res.body.updated).toBe(1); const list = await store.listPeople(); const alice = list.find(p => p.providerUserId === 'ts-1'); const newbie = list.find(p => p.providerUserId === 'ts-9'); expect(alice.displayName).toBe('Alice Updated'); expect(newbie.displayName).toBe('New Person'); expect(newbie.groups).toEqual([]); }); test('idempotent: second sync changes nothing', async () => { ({ app, store, dir } = createApp({ networkRegistry: _mockRegistry([ { providerUserId: 'ts-1', displayName: 'Alice' }, { providerUserId: 'ts-2', displayName: 'Bob' }, ]), })); const first = await request(app).post('/network/people/sync'); expect(first.body.added).toBe(2); const second = await request(app).post('/network/people/sync'); expect(second.status).toBe(200); expect(second.body.added).toBe(0); expect(second.body.updated).toBe(0); const list = await store.listPeople(); expect(list).toHaveLength(2); }); test('never downgrades protectedAt on the bootstrap admin', async () => { ({ app, store, dir } = createApp({ networkRegistry: _mockRegistry([ { providerUserId: 'ts-owner', displayName: 'Sami' }, ]), })); const owner = await store.upsertPerson({ displayName: 'Sami', providerUserId: 'ts-owner' }); expect(owner.protectedAt).toBeTruthy(); const before = owner.protectedAt; await request(app).post('/network/people/sync'); await request(app).post('/network/people/sync'); const after = await store.getPerson(owner.id); expect(after.protectedAt).toBe(before); expect(after.groups).toEqual([ADMIN_GROUP]); }); test('blocked/removed people are NOT resurrected by sync', async () => { ({ app, store, dir } = createApp({ networkRegistry: _mockRegistry([ { providerUserId: 'ts-1', displayName: 'Blocked Person' }, ]), })); const p = await store.upsertPerson({ displayName: 'Blocked Person', providerUserId: 'ts-1' }); await store.updatePerson(p.id, { status: 'blocked' }); const res = await request(app).post('/network/people/sync'); expect(res.body.added).toBe(0); expect(res.body.updated).toBe(0); const after = await store.getPerson(p.id); expect(after.status).toBe('blocked'); expect((await store.listPeople())).toHaveLength(1); // no duplicate either }); test('skips malformed provider entries without failing the sync', async () => { ({ app, store, dir } = createApp({ networkRegistry: _mockRegistry([ null, { providerUserId: '', displayName: 'NoId' }, { providerUserId: 'ts-5', displayName: 'Good' }, ]), })); const res = await request(app).post('/network/people/sync'); expect(res.status).toBe(200); expect(res.body.added).toBe(1); }); });