190 lines
7.6 KiB
JavaScript
190 lines
7.6 KiB
JavaScript
/**
|
|
* 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']);
|
|
});
|
|
});
|