DC-143: block/unblock/remove person + revoke device orchestration + tests
This commit is contained in:
4 files changed
+962
No files matched your search
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* 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(); }
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user