/** * Route tests for network-lifecycle routes (DC-143). * supertest against an in-process express app; mocked provider + real * temp-dir stores. Hermetic. * * Coverage: * - POST /network/people/:id/block contract + reauth-free response shape * - POST /network/people/:id/unblock: tailscale note (sign in again) vs * wireguard note (reconnect immediately) * - POST /network/people/:id/remove: 200 + purge * - POST /network/devices/:id/revoke: requires personId body * - LastAdminError → 409; ProviderNotActiveError → 503; * ProviderError → 502; missing person → 404 */ 'use strict'; const express = require('express'); const request = require('supertest'); const fs = require('fs'); const path = require('path'); const os = require('os'); const { createNetworkLifecycle } = require('../src/network/lifecycle'); const networkLifecycleRoutes = require('../routes/network-lifecycle'); const { createPeopleStore, ADMIN_GROUP } = require('../src/security/network-people-store'); const { createNetworkAccessStore } = require('../src/security/network-access-store'); const { ProviderError, ProviderNotActiveError } = require('../src/network/errors'); const _noopLog = { info: () => {}, warn: () => {}, error: () => {} }; function asyncHandler(fn) { return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); } async function addPerson(store, input) { const person = await store.upsertPerson(input); return person.id; } function makeProvider(id) { return { id: () => id, capabilities: () => ({}), blockPerson: async () => ({ blocked: true }), unblockPerson: async () => ({ unblocked: true }), removePerson: async () => ({ removed: true }), revokeDevice: async () => ({ revoked: true }), }; } function createApp({ providerId = 'tailscale', provider } = {}) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc143-routes-')); const peopleStore = createPeopleStore({ dataDir: dir, log: _noopLog }); const accessStore = createNetworkAccessStore({ dataDir: dir, log: _noopLog }); const lifecycle = createNetworkLifecycle({ provider: provider || makeProvider(providerId), peopleStore, accessStore, sessionKiller: async () => 1, auditLog: { log: async () => {} }, logger: _noopLog, snapshotDataDir: dir, }); const router = networkLifecycleRoutes({ lifecycle, asyncHandler, log: _noopLog, }); const app = express(); app.use(express.json()); app.use('/network', router); return { app, peopleStore, accessStore, dir }; } function _cleanup(dir) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} } describe('network-lifecycle routes', () => { let ctx; afterEach(() => { if (ctx) { _cleanup(ctx.dir); ctx = null; } }); test('POST /network/people/:id/block → 200, blocked + grantSnapshot', async () => { ctx = createApp(); await ctx.peopleStore.upsertPerson({ displayName: 'Admin', groups: [ADMIN_GROUP] }); const bob = await addPerson(ctx.peopleStore, { displayName: 'Bob', providerUserId: 'ts-bob' }); await ctx.accessStore.setGrant({ type: 'person', id: bob }, 'plex', true); const res = await request(ctx.app).post(`/network/people/${bob}/block`).expect(200); expect(res.body.success).toBe(true); expect(res.body.blocked).toBe(true); expect(res.body.grantSnapshot).toBe(1); expect((await ctx.peopleStore.getPerson(bob)).status).toBe('blocked'); }); test('POST /network/people/:id/unblock (tailscale) → reauthRequired true + sign-in note', async () => { ctx = createApp({ providerId: 'tailscale' }); await ctx.peopleStore.upsertPerson({ displayName: 'Admin', groups: [ADMIN_GROUP] }); const bob = await addPerson(ctx.peopleStore, { displayName: 'Bob' }); await request(ctx.app).post(`/network/people/${bob}/block`).expect(200); const res = await request(ctx.app).post(`/network/people/${bob}/unblock`).expect(200); expect(res.body.unblocked).toBe(true); expect(res.body.reauthRequired).toBe(true); expect(typeof res.body.note).toBe('string'); expect(res.body.note).toMatch(/sign in again/i); expect((await ctx.peopleStore.getPerson(bob)).status).toBe('active'); }); test('POST /network/people/:id/unblock (wireguard) → reauthRequired false + immediate note', async () => { ctx = createApp({ providerId: 'wireguard' }); await ctx.peopleStore.upsertPerson({ displayName: 'Admin', groups: [ADMIN_GROUP] }); const bob = await addPerson(ctx.peopleStore, { displayName: 'Bob' }); await request(ctx.app).post(`/network/people/${bob}/block`).expect(200); const res = await request(ctx.app).post(`/network/people/${bob}/unblock`).expect(200); expect(res.body.reauthRequired).toBe(false); expect(res.body.note).toMatch(/reconnect immediately/i); }); test('POST /network/people/:id/remove → 200 + person purged', async () => { ctx = createApp(); await ctx.peopleStore.upsertPerson({ displayName: 'Admin', groups: [ADMIN_GROUP] }); const bob = await addPerson(ctx.peopleStore, { displayName: 'Bob' }); await ctx.accessStore.setGrant({ type: 'person', id: bob }, 'plex', true); const res = await request(ctx.app).post(`/network/people/${bob}/remove`).expect(200); expect(res.body.removed).toBe(true); expect(await ctx.peopleStore.getPerson(bob)).toBeNull(); const map = await ctx.accessStore.getAccessMap(); expect(map.grants.filter(g => g.principal.id === bob)).toEqual([]); }); test('block/unblock/remove on last admin → 409', async () => { ctx = createApp(); const admin = await addPerson(ctx.peopleStore, { displayName: 'Admin', groups: [ADMIN_GROUP] }); await request(ctx.app).post(`/network/people/${admin}/block`).expect(409); await request(ctx.app).post(`/network/people/${admin}/remove`).expect(409); }); test('unknown person → 404', async () => { ctx = createApp(); await request(ctx.app).post('/network/people/nobody/block').expect(404); }); test('ProviderNotActiveError → 503', async () => { ctx = createApp({ provider: { ...makeProvider('tailscale'), blockPerson: async () => { throw new ProviderNotActiveError(); }, }, }); await ctx.peopleStore.upsertPerson({ displayName: 'Admin', groups: [ADMIN_GROUP] }); const bob = await addPerson(ctx.peopleStore, { displayName: 'Bob' }); await request(ctx.app).post(`/network/people/${bob}/block`).expect(503); }); test('generic ProviderError → 502', async () => { ctx = createApp({ provider: { ...makeProvider('tailscale'), blockPerson: async () => { throw new ProviderError('upstream 5xx', { code: 'server_error' }); }, }, }); await ctx.peopleStore.upsertPerson({ displayName: 'Admin', groups: [ADMIN_GROUP] }); const bob = await addPerson(ctx.peopleStore, { displayName: 'Bob' }); const res = await request(ctx.app).post(`/network/people/${bob}/block`).expect(502); expect(res.body.success).toBe(false); expect(res.body.error).toMatch(/upstream 5xx/); }); test('POST /network/devices/:id/revoke → requires personId, updates devices', async () => { ctx = createApp(); await ctx.peopleStore.upsertPerson({ displayName: 'Admin', groups: [ADMIN_GROUP] }); const bob = await addPerson(ctx.peopleStore, { displayName: 'Bob', devices: [{ id: 'dev-1' }, { id: 'dev-2' }], }); await request(ctx.app).post('/network/devices/dev-1/revoke').send({}).expect(400); const res = await request(ctx.app) .post('/network/devices/dev-1/revoke') .send({ personId: bob }) .expect(200); expect(res.body.revoked).toBe(true); expect((await ctx.peopleStore.getPerson(bob)).devices.map(d => d.id)).toEqual(['dev-2']); }); });