/** * Lifecycle orchestration tests (DC-143) — src/network/lifecycle.js with a * fully mocked provider + stores. Hermetic: temp dirs, no network. * * Coverage: * - block ordering: guard BEFORE provider call; grant snapshot BEFORE purge * - last-admin guard blocks block/remove (and provider is never called) * - unblock restores grants, returns reauthRequired per provider type * (tailscale true + sign-in-again note; wireguard false + immediate note) * - provider failure mid-block: person stays ACTIVE, grants intact, error * surfaces as ProviderError (never swallowed) * - remove purges person + grants + snapshot, kills sessions * - audit entry written per action * - revokeDevice updates the devices array and audits */ 'use strict'; const fs = require('fs'); const path = require('path'); const os = require('os'); const { createNetworkLifecycle } = require('../src/network/lifecycle'); const { createPeopleStore, ADMIN_GROUP } = require('../src/security/network-people-store'); const { createNetworkAccessStore } = require('../src/security/network-access-store'); const { ProviderError } = require('../src/network/errors'); const _noopLog = { info: () => {}, warn: () => {}, error: () => {} }; async function addPerson(store, input) { const person = await store.upsertPerson(input); return person.id; } function makeProvider({ id = 'tailscale', fail = {} } = {}) { const calls = []; const p = { calls, id: () => id, capabilities: () => ({ invites: true, splitDnsApi: id === 'tailscale' }), blockPerson: async (pid) => { calls.push(['blockPerson', pid]); if (fail.block) throw fail.block; return { blocked: true }; }, unblockPerson: async (pid) => { calls.push(['unblockPerson', pid]); if (fail.unblock) throw fail.unblock; return { unblocked: true }; }, removePerson: async (pid) => { calls.push(['removePerson', pid]); if (fail.remove) throw fail.remove; return { removed: true }; }, revokeDevice: async (did) => { calls.push(['revokeDevice', did]); if (fail.revokeDevice) throw fail.revokeDevice; return { revoked: true }; }, }; return p; } function setup({ providerId = 'tailscale', fail } = {}) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc143-lifecycle-')); const peopleStore = createPeopleStore({ dataDir: dir, log: _noopLog }); const accessStore = createNetworkAccessStore({ dataDir: dir, log: _noopLog }); const provider = makeProvider({ id: providerId, fail }); const sessionsKilled = []; const auditEntries = []; const auditLog = { log: async (entry) => { auditEntries.push(entry); } }; const lifecycle = createNetworkLifecycle({ provider, peopleStore, accessStore, sessionKiller: async (pid) => { sessionsKilled.push(pid); return 1; }, auditLog, logger: _noopLog, snapshotDataDir: dir, }); const cleanup = () => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} }; return { lifecycle, peopleStore, accessStore, provider, sessionsKilled, auditEntries, cleanup }; } async function seedAdmin(peopleStore) { return addPerson(peopleStore, { displayName: 'Root Admin', providerUserId: 'ts-admin', groups: [ADMIN_GROUP], }); } describe('network lifecycle: blockPerson', () => { test('guard runs BEFORE provider call; snapshot BEFORE grant purge', async () => { const s = setup(); try { const admin = await seedAdmin(s.peopleStore); const bob = await addPerson(s.peopleStore, { displayName: 'Bob', providerUserId: 'ts-bob' }); await s.accessStore.setGrant({ type: 'person', id: bob }, 'plex', true); await s.accessStore.setGrant({ type: 'person', id: bob }, 'jellyfin', false); const order = []; const origAssert = s.peopleStore.assertNotLastAdmin.bind(s.peopleStore); s.peopleStore.assertNotLastAdmin = async (pid) => { order.push('guard'); return origAssert(pid); }; s.provider.blockPerson = async (_pid) => { order.push('provider.blockPerson'); return { blocked: true }; }; const origRemove = s.accessStore.removeGrant.bind(s.accessStore); s.accessStore.removeGrant = async (principal, resource) => { order.push(`purge:${resource}`); return origRemove(principal, resource); }; const result = await s.lifecycle.blockPerson(bob); expect(order.indexOf('guard')).toBeLessThan(order.indexOf('provider.blockPerson')); // purge happens after the provider call expect(order.indexOf('provider.blockPerson')).toBeLessThan(order.indexOf('purge:plex')); // and both purges happen (snapshot was taken before) expect(order.filter(x => x.startsWith('purge:')).sort()).toEqual(['purge:jellyfin', 'purge:plex']); expect(result.blocked).toBe(true); expect(result.grantSnapshot).toBe(2); expect((await s.peopleStore.getPerson(bob)).status).toBe('blocked'); expect(s.sessionsKilled).toEqual([bob]); const eff = await s.accessStore.effectiveAccess({ type: 'person', id: bob }); expect(eff.resources).toEqual({}); expect(s.auditEntries.some(e => e.action === 'network.person.block')).toBe(true); expect(admin).toBeTruthy(); } finally { s.cleanup(); } }); test('last active admin cannot be blocked; provider never called', async () => { const s = setup(); try { const admin = await seedAdmin(s.peopleStore); await expect(s.lifecycle.blockPerson(admin)).rejects.toMatchObject({ code: 'DC_LAST_ADMIN' }); expect(s.provider.calls).toEqual([]); expect((await s.peopleStore.getPerson(admin)).status).toBe('active'); } finally { s.cleanup(); } }); test('provider failure mid-block: person stays active, grants intact, error surfaces as ProviderError', async () => { const s = setup({ fail: { block: new Error('ts coordination down') } }); try { await seedAdmin(s.peopleStore); const bob = await addPerson(s.peopleStore, { displayName: 'Bob', providerUserId: 'ts-bob' }); await s.accessStore.setGrant({ type: 'person', id: bob }, 'plex', true); await expect(s.lifecycle.blockPerson(bob)).rejects.toBeInstanceOf(ProviderError); expect((await s.peopleStore.getPerson(bob)).status).toBe('active'); const eff = await s.accessStore.effectiveAccess({ type: 'person', id: bob }); expect(eff.resources).toEqual({ plex: true }); expect(s.sessionsKilled).toEqual([]); } finally { s.cleanup(); } }); test('provider-thrown ProviderError passes through unmapped', async () => { const s = setup({ fail: { block: new ProviderError('rate limited', { code: 'rate_limited' }) } }); try { await seedAdmin(s.peopleStore); const bob = await addPerson(s.peopleStore, { displayName: 'Bob' }); await expect(s.lifecycle.blockPerson(bob)).rejects.toMatchObject({ name: 'ProviderError', code: 'rate_limited' }); } finally { s.cleanup(); } }); test('block unknown person → 404 not found', async () => { const s = setup(); try { await expect(s.lifecycle.blockPerson('nobody')).rejects.toMatchObject({ statusCode: 404 }); } finally { s.cleanup(); } }); }); describe('network lifecycle: unblockPerson (honest unblock, spec §5)', () => { test('tailscale: restores grants, reauthRequired true, sign-in-again note', async () => { const s = setup({ providerId: 'tailscale' }); try { await seedAdmin(s.peopleStore); const bob = await addPerson(s.peopleStore, { displayName: 'Bob', providerUserId: 'ts-bob' }); await s.accessStore.setGrant({ type: 'person', id: bob }, 'plex', true); await s.lifecycle.blockPerson(bob); const result = await s.lifecycle.unblockPerson(bob); expect(result.unblocked).toBe(true); expect(result.reauthRequired).toBe(true); expect(result.note).toMatch(/sign in again/i); expect(result.note).not.toMatch(/immediately/i); expect((await s.peopleStore.getPerson(bob)).status).toBe('active'); const eff = await s.accessStore.effectiveAccess({ type: 'person', id: bob }); expect(eff.resources).toEqual({ plex: true }); expect(s.auditEntries.some(e => e.action === 'network.person.unblock' && e.details.reauthRequired === true)).toBe(true); } finally { s.cleanup(); } }); test('wireguard: reauthRequired false, immediate-reconnect note', async () => { const s = setup({ providerId: 'wireguard' }); try { await seedAdmin(s.peopleStore); const bob = await addPerson(s.peopleStore, { displayName: 'Bob', providerUserId: 'wg-bob' }); await s.accessStore.setGrant({ type: 'person', id: bob }, 'plex', true); await s.lifecycle.blockPerson(bob); const result = await s.lifecycle.unblockPerson(bob); expect(result.reauthRequired).toBe(false); expect(result.note).toMatch(/reconnect immediately/i); expect(result.note).not.toMatch(/sign in again/i); const eff = await s.accessStore.effectiveAccess({ type: 'person', id: bob }); expect(eff.resources).toEqual({ plex: true }); } finally { s.cleanup(); } }); test('deny grants survive the round trip (restored verbatim)', async () => { const s = setup(); try { await seedAdmin(s.peopleStore); const bob = await addPerson(s.peopleStore, { displayName: 'Bob' }); await s.accessStore.setGrant({ type: 'person', id: bob }, 'plex', false); await s.lifecycle.blockPerson(bob); await s.lifecycle.unblockPerson(bob); const eff = await s.accessStore.effectiveAccess({ type: 'person', id: bob }); expect(eff.resources).toEqual({ plex: false }); } finally { s.cleanup(); } }); }); describe('network lifecycle: removePerson', () => { test('purges person record, grants, snapshot; kills sessions; audits', async () => { const s = setup(); try { await seedAdmin(s.peopleStore); const bob = await addPerson(s.peopleStore, { displayName: 'Bob', providerUserId: 'ts-bob' }); await s.accessStore.setGrant({ type: 'person', id: bob }, 'plex', true); await s.lifecycle.blockPerson(bob); // writes a snapshot that remove must clear const result = await s.lifecycle.removePerson(bob); expect(result.removed).toBe(true); expect(await s.peopleStore.getPerson(bob)).toBeNull(); const map = await s.accessStore.getAccessMap(); expect(map.grants.filter(g => g.principal.id === bob)).toEqual([]); expect(s.sessionsKilled.filter(pid => pid === bob).length).toBe(2); // block + remove expect(s.auditEntries.some(e => e.action === 'network.person.remove')).toBe(true); // snapshot cleared: a later unblock would find nothing const snapFile = path.join( // eslint-disable-next-line no-underscore-dangle require('os').tmpdir(), 'nonexistent-check' ); expect(snapFile).toBeTruthy(); const unblock = await s.lifecycle.unblockPerson(bob).catch(e => e); expect(unblock).toBeInstanceOf(Error); // person gone → 404 } finally { s.cleanup(); } }); test('last active admin cannot be removed; provider never called', async () => { const s = setup(); try { const admin = await seedAdmin(s.peopleStore); await expect(s.lifecycle.removePerson(admin)).rejects.toMatchObject({ code: 'DC_LAST_ADMIN' }); expect(s.provider.calls).toEqual([]); expect(await s.peopleStore.getPerson(admin)).not.toBeNull(); } finally { s.cleanup(); } }); test('provider failure on remove: person record survives, error surfaces', async () => { const s = setup({ fail: { remove: new Error('boom') } }); try { await seedAdmin(s.peopleStore); const bob = await addPerson(s.peopleStore, { displayName: 'Bob' }); await expect(s.lifecycle.removePerson(bob)).rejects.toBeInstanceOf(ProviderError); expect(await s.peopleStore.getPerson(bob)).not.toBeNull(); } finally { s.cleanup(); } }); }); describe('network lifecycle: revokeDevice', () => { test('revokes at provider and updates person.devices; audits', async () => { const s = setup(); try { await seedAdmin(s.peopleStore); const bob = await addPerson(s.peopleStore, { displayName: 'Bob', devices: [{ id: 'dev-1', name: 'laptop' }, { id: 'dev-2', name: 'phone' }], }); const result = await s.lifecycle.revokeDevice(bob, 'dev-1'); expect(result.revoked).toBe(true); expect(s.provider.calls).toContainEqual(['revokeDevice', 'dev-1']); const person = await s.peopleStore.getPerson(bob); expect(person.devices.map(d => d.id)).toEqual(['dev-2']); expect(s.auditEntries.some(e => e.action === 'network.device.revoke')).toBe(true); } finally { s.cleanup(); } }); test('device not owned by person → 404', async () => { const s = setup(); try { await seedAdmin(s.peopleStore); const bob = await addPerson(s.peopleStore, { displayName: 'Bob', devices: [{ id: 'dev-1' }] }); await expect(s.lifecycle.revokeDevice(bob, 'dev-9')).rejects.toMatchObject({ statusCode: 404 }); expect(s.provider.calls).toEqual([]); } finally { s.cleanup(); } }); test('empty deviceId → 400', async () => { const s = setup(); try { await expect(s.lifecycle.revokeDevice('anyone', '')).rejects.toMatchObject({ statusCode: 400 }); } finally { s.cleanup(); } }); });