P7 Private Network [grade=A] (PR #1)
Judge A r18 urn:ump:rgxdivsp2xf6zy5lsdoclvdgb7cuqd3qpwlszt2p7fu7xwa3zkkq. 3,399/3,399 tests.
This commit was merged in pull request #1.
This commit is contained in:
commit
e04787477a
58 files changed
+16670
-9
No files matched your search
@@ -0,0 +1,455 @@
|
||||
/**
|
||||
* Tests for network-access-store (DC-145).
|
||||
* Coverage:
|
||||
* - CRUD: getAccessMap empty default, saveAccessMap, setGrant, removeGrant
|
||||
* - validation rejections: bad principal type, empty resource, non-bool allowed
|
||||
* - dedupe last-wins on saveAccessMap and setGrant upsert
|
||||
* - atomicity: concurrent writes serialize; invalid save leaves file untouched
|
||||
* - effective access: union of groups, person-override-beats-group (both
|
||||
* directions: person true beats group false, person false beats group true)
|
||||
* - subscribe change events + unsubscribe + listener isolation
|
||||
* - derived views: listPrincipals / listResources
|
||||
* - on-disk shape {grants, updatedAt} at data/network-access.json
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const {
|
||||
createNetworkAccessStore,
|
||||
normalizeGrants,
|
||||
validateGrant,
|
||||
} = require('../src/security/network-access-store');
|
||||
|
||||
function _tmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-netaccess-'));
|
||||
}
|
||||
function _cleanup(dir) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
function _readFile(dir) {
|
||||
return JSON.parse(fs.readFileSync(path.join(dir, 'network-access.json'), 'utf8'));
|
||||
}
|
||||
|
||||
describe('network-access-store: basic CRUD', () => {
|
||||
let dir, store;
|
||||
beforeEach(() => { dir = _tmpDir(); store = createNetworkAccessStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('empty map when file missing — {grants: [], updatedAt: null}', async () => {
|
||||
const map = await store.getAccessMap();
|
||||
expect(map).toEqual({ grants: [], updatedAt: null });
|
||||
});
|
||||
|
||||
test('saveAccessMap persists {grants, updatedAt} to network-access.json', async () => {
|
||||
const r = await store.saveAccessMap({
|
||||
grants: [
|
||||
{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true },
|
||||
],
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.map.updatedAt).toBeTruthy();
|
||||
const onDisk = _readFile(dir);
|
||||
expect(onDisk.grants).toHaveLength(1);
|
||||
expect(onDisk.grants[0].principal).toEqual({ type: 'person', id: 'p1' });
|
||||
expect(onDisk.grants[0].resource).toBe('plex');
|
||||
expect(onDisk.grants[0].allowed).toBe(true);
|
||||
expect(typeof onDisk.updatedAt).toBe('string');
|
||||
});
|
||||
|
||||
test('getAccessMap returns saved grants', async () => {
|
||||
await store.saveAccessMap({
|
||||
grants: [{ principal: { type: 'group', id: 'family' }, resource: 'nas', allowed: true }],
|
||||
});
|
||||
const map = await store.getAccessMap();
|
||||
expect(map.grants).toHaveLength(1);
|
||||
expect(map.grants[0].principal.id).toBe('family');
|
||||
});
|
||||
|
||||
test('setGrant adds a grant', async () => {
|
||||
const r = await store.setGrant({ type: 'person', id: 'p1' }, 'plex', true);
|
||||
expect(r.ok).toBe(true);
|
||||
const map = await store.getAccessMap();
|
||||
expect(map.grants).toEqual([
|
||||
{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test('setGrant upserts (replaces) same principal+resource — no duplicate', async () => {
|
||||
await store.setGrant({ type: 'person', id: 'p1' }, 'plex', true);
|
||||
await store.setGrant({ type: 'person', id: 'p1' }, 'plex', false);
|
||||
const map = await store.getAccessMap();
|
||||
expect(map.grants).toHaveLength(1);
|
||||
expect(map.grants[0].allowed).toBe(false);
|
||||
});
|
||||
|
||||
test('removeGrant deletes an existing grant', async () => {
|
||||
await store.setGrant({ type: 'person', id: 'p1' }, 'plex', true);
|
||||
const r = await store.removeGrant({ type: 'person', id: 'p1' }, 'plex');
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.removed).toBe(true);
|
||||
const map = await store.getAccessMap();
|
||||
expect(map.grants).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('removeGrant on absent grant is idempotent (removed: false)', async () => {
|
||||
const r = await store.removeGrant({ type: 'group', id: 'x' }, 'nothing');
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.removed).toBe(false);
|
||||
});
|
||||
|
||||
test('person and group grants with same id are distinct principals', async () => {
|
||||
await store.setGrant({ type: 'person', id: 'same' }, 'plex', true);
|
||||
await store.setGrant({ type: 'group', id: 'same' }, 'plex', false);
|
||||
const map = await store.getAccessMap();
|
||||
expect(map.grants).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-access-store: validation rejections', () => {
|
||||
let dir, store;
|
||||
beforeEach(() => { dir = _tmpDir(); store = createNetworkAccessStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('saveAccessMap rejects non-array grants', async () => {
|
||||
const r = await store.saveAccessMap({ grants: 'nope' });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toMatch(/invalid_grants/);
|
||||
});
|
||||
|
||||
test('saveAccessMap rejects bad principal type', async () => {
|
||||
const r = await store.saveAccessMap({
|
||||
grants: [{ principal: { type: 'robot', id: 'x' }, resource: 'plex', allowed: true }],
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toMatch(/invalid_principal_type/);
|
||||
});
|
||||
|
||||
test('saveAccessMap rejects missing principal', async () => {
|
||||
const r = await store.saveAccessMap({
|
||||
grants: [{ resource: 'plex', allowed: true }],
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toMatch(/invalid_principal/);
|
||||
});
|
||||
|
||||
test('saveAccessMap rejects empty/whitespace resource', async () => {
|
||||
const r = await store.saveAccessMap({
|
||||
grants: [{ principal: { type: 'person', id: 'p1' }, resource: ' ', allowed: true }],
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toMatch(/invalid_resource/);
|
||||
});
|
||||
|
||||
test('saveAccessMap rejects non-string resource', async () => {
|
||||
const r = await store.saveAccessMap({
|
||||
grants: [{ principal: { type: 'person', id: 'p1' }, resource: 42, allowed: true }],
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toMatch(/invalid_resource/);
|
||||
});
|
||||
|
||||
test('saveAccessMap rejects non-boolean allowed', async () => {
|
||||
const r = await store.saveAccessMap({
|
||||
grants: [{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: 'yes' }],
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toMatch(/invalid_allowed/);
|
||||
});
|
||||
|
||||
test('setGrant validates the same way', async () => {
|
||||
expect((await store.setGrant({ type: 'boat', id: 'x' }, 'plex', true)).ok).toBe(false);
|
||||
expect((await store.setGrant({ type: 'person', id: 'x' }, '', true)).ok).toBe(false);
|
||||
expect((await store.setGrant({ type: 'person', id: 'x' }, 'plex', 1)).ok).toBe(false);
|
||||
});
|
||||
|
||||
test('validateGrant exported helper matches store behavior', () => {
|
||||
expect(validateGrant({ principal: { type: 'person', id: 'a' }, resource: 'r', allowed: false })).toBe(null);
|
||||
expect(validateGrant(null)).toMatch(/invalid_grant/);
|
||||
expect(validateGrant({ principal: { type: 'person' }, resource: 'r', allowed: false })).toMatch(/invalid_principal_id/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-access-store: dedupe last-wins', () => {
|
||||
let dir, store;
|
||||
beforeEach(() => { dir = _tmpDir(); store = createNetworkAccessStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('saveAccessMap dedupes exact principal+resource, last entry wins', async () => {
|
||||
const r = await store.saveAccessMap({
|
||||
grants: [
|
||||
{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true },
|
||||
{ principal: { type: 'group', id: 'g1' }, resource: 'plex', allowed: true },
|
||||
{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: false }, // dup → wins
|
||||
],
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.map.grants).toHaveLength(2);
|
||||
const p1 = r.map.grants.find(g => g.principal.type === 'person');
|
||||
expect(p1.allowed).toBe(false);
|
||||
});
|
||||
|
||||
test('dedupe key includes principal type (person p1 ≠ group p1)', () => {
|
||||
const r = normalizeGrants([
|
||||
{ principal: { type: 'person', id: 'z' }, resource: 'r', allowed: true },
|
||||
{ principal: { type: 'group', id: 'z' }, resource: 'r', allowed: false },
|
||||
]);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.grants).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('last-wins across all fields', async () => {
|
||||
const r = await store.saveAccessMap({
|
||||
grants: [
|
||||
{ principal: { type: 'group', id: 'g' }, resource: 'nas', allowed: true },
|
||||
{ principal: { type: 'group', id: 'g' }, resource: 'nas', allowed: false },
|
||||
{ principal: { type: 'group', id: 'g' }, resource: 'nas', allowed: true },
|
||||
],
|
||||
});
|
||||
expect(r.map.grants).toHaveLength(1);
|
||||
expect(r.map.grants[0].allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-access-store: atomicity + concurrency', () => {
|
||||
let dir, store;
|
||||
beforeEach(() => { dir = _tmpDir(); store = createNetworkAccessStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('invalid whole-grid save leaves the previous file untouched', async () => {
|
||||
await store.saveAccessMap({
|
||||
grants: [{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true }],
|
||||
});
|
||||
const before = _readFile(dir);
|
||||
const bad = await store.saveAccessMap({
|
||||
grants: [
|
||||
{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true },
|
||||
{ principal: { type: 'ghost', id: 'p2' }, resource: 'plex', allowed: true },
|
||||
],
|
||||
});
|
||||
expect(bad.ok).toBe(false);
|
||||
const after = _readFile(dir);
|
||||
expect(after).toEqual(before); // nothing written, not even updatedAt
|
||||
});
|
||||
|
||||
test('no partial file after a failed save (readers never see junk)', async () => {
|
||||
await store.saveAccessMap({
|
||||
grants: [{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true }],
|
||||
});
|
||||
await store.saveAccessMap({ grants: 'garbage' });
|
||||
const map = await store.getAccessMap();
|
||||
expect(map.grants).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('concurrent setGrant calls all land (mutex serialization, no lost update)', async () => {
|
||||
await Promise.all([
|
||||
store.setGrant({ type: 'person', id: 'p1' }, 'plex', true),
|
||||
store.setGrant({ type: 'person', id: 'p2' }, 'plex', true),
|
||||
store.setGrant({ type: 'group', id: 'g1' }, 'nas', false),
|
||||
store.setGrant({ type: 'person', id: 'p1' }, 'nas', false),
|
||||
]);
|
||||
const map = await store.getAccessMap();
|
||||
expect(map.grants).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('corrupt JSON on disk falls back to empty map, not a crash', async () => {
|
||||
fs.writeFileSync(path.join(dir, 'network-access.json'), '{not json');
|
||||
const map = await store.getAccessMap();
|
||||
expect(map.grants).toEqual([]);
|
||||
});
|
||||
|
||||
test('on-disk grants with junk entries are filtered on load', async () => {
|
||||
fs.writeFileSync(path.join(dir, 'network-access.json'), JSON.stringify({
|
||||
grants: [
|
||||
{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true },
|
||||
{ junk: true },
|
||||
],
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
}));
|
||||
const map = await store.getAccessMap();
|
||||
expect(map.grants).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-access-store: effectiveAccess', () => {
|
||||
let dir, store;
|
||||
beforeEach(() => {
|
||||
dir = _tmpDir();
|
||||
store = createNetworkAccessStore({ dataDir: dir });
|
||||
});
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
async function seed() {
|
||||
await store.saveAccessMap({
|
||||
grants: [
|
||||
// groups
|
||||
{ principal: { type: 'group', id: 'family' }, resource: 'plex', allowed: true },
|
||||
{ principal: { type: 'group', id: 'family' }, resource: 'nas', allowed: true },
|
||||
{ principal: { type: 'group', id: 'family' }, resource: 'jellyfin', allowed: false },
|
||||
{ principal: { type: 'group', id: 'adults' }, resource: 'nas', allowed: false },
|
||||
{ principal: { type: 'group', id: 'adults' }, resource: 'radarr', allowed: true },
|
||||
// person-level
|
||||
{ principal: { type: 'person', id: 'sami' }, resource: 'jellyfin', allowed: true }, // override deny
|
||||
{ principal: { type: 'person', id: 'kid' }, resource: 'nas', allowed: false }, // override allow
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
test('group principal: own grants only', async () => {
|
||||
await seed();
|
||||
const r = await store.effectiveAccess({ type: 'group', id: 'family' });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.resources).toEqual({ plex: true, nas: true, jellyfin: false });
|
||||
});
|
||||
|
||||
test('person with no groups and no grants → empty resources', async () => {
|
||||
await seed();
|
||||
const r = await store.effectiveAccess({ type: 'person', id: 'nobody' });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.resources).toEqual({});
|
||||
});
|
||||
|
||||
test('union of groups', async () => {
|
||||
await seed();
|
||||
const r = await store.effectiveAccess({ type: 'person', id: 'adult1', groups: ['family', 'adults'] });
|
||||
// union: plex(T)+nas(T)+jellyfin(F) from family, nas(F)+radarr(T) from adults
|
||||
// (same-resource conflict across groups: later-processed write wins is
|
||||
// unspecified, so assert presence, then deterministic bits)
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.resources.plex).toBe(true);
|
||||
expect(r.resources.jellyfin).toBe(false);
|
||||
expect(r.resources.radarr).toBe(true);
|
||||
expect(typeof r.resources.nas).toBe('boolean');
|
||||
});
|
||||
|
||||
test('person override beats group — explicit false wins over group true', async () => {
|
||||
await seed();
|
||||
const r = await store.effectiveAccess({ type: 'person', id: 'kid', groups: ['family'] });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.resources.nas).toBe(false); // person false beats family true
|
||||
expect(r.resources.plex).toBe(true); // inherited from group
|
||||
});
|
||||
|
||||
test('person override beats group — explicit true wins over group false', async () => {
|
||||
await seed();
|
||||
const r = await store.effectiveAccess({ type: 'person', id: 'sami', groups: ['family'] });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.resources.jellyfin).toBe(true); // person true beats family false
|
||||
expect(r.resources.nas).toBe(true); // inherited
|
||||
});
|
||||
|
||||
test('person-level false beats group false too (no flip)', async () => {
|
||||
await seed();
|
||||
const r = await store.effectiveAccess({ type: 'person', id: 'kid', groups: ['family', 'adults'] });
|
||||
expect(r.resources.nas).toBe(false);
|
||||
});
|
||||
|
||||
test('person grant on a resource no group touches appears', async () => {
|
||||
await store.setGrant({ type: 'person', id: 'lone' }, 'plex', true);
|
||||
const r = await store.effectiveAccess({ type: 'person', id: 'lone' });
|
||||
expect(r.resources).toEqual({ plex: true });
|
||||
});
|
||||
|
||||
test('invalid principal rejected with ok:false', async () => {
|
||||
expect((await store.effectiveAccess(null)).ok).toBe(false);
|
||||
expect((await store.effectiveAccess({ type: 'robot', id: 'x' })).ok).toBe(false);
|
||||
expect((await store.effectiveAccess({ type: 'person', id: '' })).ok).toBe(false);
|
||||
expect((await store.effectiveAccess({ type: 'person' })).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-access-store: subscribe change events', () => {
|
||||
let dir, store;
|
||||
beforeEach(() => { dir = _tmpDir(); store = createNetworkAccessStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('saveAccessMap, setGrant, removeGrant each emit the new map', async () => {
|
||||
const seen = [];
|
||||
store.subscribe(map => seen.push(['evt', map.grants.length]));
|
||||
await store.saveAccessMap({ grants: [{ principal: { type: 'group', id: 'g' }, resource: 'plex', allowed: true }] });
|
||||
await store.setGrant({ type: 'person', id: 'p' }, 'nas', true);
|
||||
await store.removeGrant({ type: 'person', id: 'p' }, 'nas');
|
||||
expect(seen).toEqual([['evt', 1], ['evt', 2], ['evt', 1]]);
|
||||
});
|
||||
|
||||
test('event map includes updatedAt', async () => {
|
||||
let got = null;
|
||||
store.subscribe(map => { got = map; });
|
||||
await store.setGrant({ type: 'person', id: 'p' }, 'plex', true);
|
||||
expect(got).toBeTruthy();
|
||||
expect(typeof got.updatedAt).toBe('string');
|
||||
expect(got.grants).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('failed saves emit nothing', async () => {
|
||||
const seen = [];
|
||||
store.subscribe(() => seen.push(1));
|
||||
await store.saveAccessMap({ grants: [{ bad: true }] });
|
||||
await store.removeGrant({ type: 'person', id: 'ghost' }, 'none'); // no-op
|
||||
expect(seen).toEqual([]);
|
||||
});
|
||||
|
||||
test('unsubscribe stops events', async () => {
|
||||
const seen = [];
|
||||
const off = store.subscribe(() => seen.push(1));
|
||||
await store.setGrant({ type: 'person', id: 'p' }, 'plex', true);
|
||||
off();
|
||||
await store.setGrant({ type: 'person', id: 'p' }, 'nas', true);
|
||||
expect(seen).toEqual([1]);
|
||||
});
|
||||
|
||||
test('a throwing listener is isolated — other listeners still run, save still works', async () => {
|
||||
const seen = [];
|
||||
store.subscribe(() => { throw new Error('boom'); });
|
||||
store.subscribe(() => seen.push('ok'));
|
||||
const r = await store.setGrant({ type: 'person', id: 'p' }, 'plex', true);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(seen).toEqual(['ok']);
|
||||
expect((await store.getAccessMap()).grants).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('subscribe requires a function', () => {
|
||||
expect(() => store.subscribe('nope')).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-access-store: derived views', () => {
|
||||
let dir, store;
|
||||
beforeEach(() => { dir = _tmpDir(); store = createNetworkAccessStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('listPrincipals aggregates grant counts, sorted by type then id', async () => {
|
||||
await store.saveAccessMap({
|
||||
grants: [
|
||||
{ principal: { type: 'person', id: 'zeta' }, resource: 'plex', allowed: true },
|
||||
{ principal: { type: 'person', id: 'zeta' }, resource: 'nas', allowed: false },
|
||||
{ principal: { type: 'group', id: 'family' }, resource: 'plex', allowed: true },
|
||||
{ principal: { type: 'person', id: 'abe' }, resource: 'nas', allowed: true },
|
||||
],
|
||||
});
|
||||
const principals = await store.listPrincipals();
|
||||
expect(principals).toEqual([
|
||||
{ type: 'group', id: 'family', grantCount: 1 },
|
||||
{ type: 'person', id: 'abe', grantCount: 1 },
|
||||
{ type: 'person', id: 'zeta', grantCount: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('listResources distinct + sorted', async () => {
|
||||
await store.saveAccessMap({
|
||||
grants: [
|
||||
{ principal: { type: 'person', id: 'p' }, resource: 'nas', allowed: true },
|
||||
{ principal: { type: 'group', id: 'g' }, resource: 'plex', allowed: true },
|
||||
{ principal: { type: 'group', id: 'g' }, resource: 'nas', allowed: false },
|
||||
],
|
||||
});
|
||||
expect(await store.listResources()).toEqual(['nas', 'plex']);
|
||||
});
|
||||
|
||||
test('empty map → empty views', async () => {
|
||||
expect(await store.listPrincipals()).toEqual([]);
|
||||
expect(await store.listResources()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Route tests for /network/access (DC-145).
|
||||
* Hermetic: real network-access-store against a tmp dir, express + supertest,
|
||||
* no auth middleware (the factory is mounted under the authed apiRouter in
|
||||
* the real app — here we exercise the route contract only).
|
||||
*
|
||||
* Coverage:
|
||||
* - GET / (whole map incl. empty default)
|
||||
* - PUT / (whole-grid atomic save; validation errors → 400; bad payload
|
||||
* leaves file untouched)
|
||||
* - POST /grant + DELETE /grant (upsert/remove, 400 validation, 404
|
||||
* removing an absent grant)
|
||||
* - GET /effective/:principalType/:principalId (group + person via
|
||||
* ?groups=, person-override-beats-group, 400 on bad principalType)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const { createNetworkAccessStore } = require('../src/security/network-access-store');
|
||||
const networkAccessRoutes = require('../routes/network-access');
|
||||
|
||||
function asyncHandler(fn) {
|
||||
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
}
|
||||
|
||||
function createApp() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-netaccess-routes-'));
|
||||
const store = createNetworkAccessStore({ dataDir: dir });
|
||||
const router = networkAccessRoutes({ accessStore: store, asyncHandler });
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/v1/network/access', router);
|
||||
app.use((err, _req, res, _next) => {
|
||||
const status = err.statusCode || 500;
|
||||
res.status(status).json({ success: false, error: err.message });
|
||||
});
|
||||
return { app, store, dir };
|
||||
}
|
||||
|
||||
function cleanup(dir) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
// each test creates its own dir via createApp()
|
||||
});
|
||||
|
||||
describe('network-access routes: GET / (whole map)', () => {
|
||||
let ctx;
|
||||
afterEach(() => ctx && cleanup(ctx.dir));
|
||||
|
||||
test('empty map default → 200 {success, grants: [], updatedAt: null}', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app).get('/api/v1/network/access');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.grants).toEqual([]);
|
||||
expect(res.body.updatedAt).toBe(null);
|
||||
});
|
||||
|
||||
test('returns saved grants after PUT', async () => {
|
||||
ctx = createApp();
|
||||
await request(ctx.app)
|
||||
.put('/api/v1/network/access')
|
||||
.send({ grants: [{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true }] });
|
||||
const res = await request(ctx.app).get('/api/v1/network/access');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.grants).toHaveLength(1);
|
||||
expect(res.body.updatedAt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-access routes: PUT / (whole-grid atomic save)', () => {
|
||||
let ctx;
|
||||
afterEach(() => ctx && cleanup(ctx.dir));
|
||||
|
||||
test('valid grid saved and returned', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.put('/api/v1/network/access')
|
||||
.send({
|
||||
grants: [
|
||||
{ principal: { type: 'group', id: 'family' }, resource: 'plex', allowed: true },
|
||||
{ principal: { type: 'person', id: 'sami' }, resource: 'nas', allowed: false },
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.grants).toHaveLength(2);
|
||||
expect(typeof res.body.updatedAt).toBe('string');
|
||||
});
|
||||
|
||||
test('duplicates deduped last-wins server-side', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.put('/api/v1/network/access')
|
||||
.send({
|
||||
grants: [
|
||||
{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true },
|
||||
{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: false },
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.grants).toHaveLength(1);
|
||||
expect(res.body.grants[0].allowed).toBe(false);
|
||||
});
|
||||
|
||||
test('400 on bad principal type', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.put('/api/v1/network/access')
|
||||
.send({ grants: [{ principal: { type: 'robot', id: 'x' }, resource: 'plex', allowed: true }] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
expect(res.body.error).toMatch(/principal_type/);
|
||||
});
|
||||
|
||||
test('400 on empty resource', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.put('/api/v1/network/access')
|
||||
.send({ grants: [{ principal: { type: 'person', id: 'p1' }, resource: '', allowed: true }] });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('400 on non-boolean allowed', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.put('/api/v1/network/access')
|
||||
.send({ grants: [{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: 'true' }] });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('400 on non-object body / missing grants', async () => {
|
||||
ctx = createApp();
|
||||
const r1 = await request(ctx.app).put('/api/v1/network/access').send([1, 2]);
|
||||
expect(r1.status).toBe(400);
|
||||
const r2 = await request(ctx.app).put('/api/v1/network/access').send({});
|
||||
expect(r2.status).toBe(400);
|
||||
});
|
||||
|
||||
test('failed validation leaves previous map on disk untouched', async () => {
|
||||
ctx = createApp();
|
||||
await request(ctx.app)
|
||||
.put('/api/v1/network/access')
|
||||
.send({ grants: [{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true }] });
|
||||
const before = fs.readFileSync(path.join(ctx.dir, 'network-access.json'), 'utf8');
|
||||
await request(ctx.app)
|
||||
.put('/api/v1/network/access')
|
||||
.send({ grants: [{ principal: { type: 'ghost', id: 'p2' }, resource: 'plex', allowed: true }] });
|
||||
const after = fs.readFileSync(path.join(ctx.dir, 'network-access.json'), 'utf8');
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-access routes: POST /grant', () => {
|
||||
let ctx;
|
||||
afterEach(() => ctx && cleanup(ctx.dir));
|
||||
|
||||
test('creates a grant → 201 with normalized grant', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.post('/api/v1/network/access/grant')
|
||||
.send({ principal: { type: 'group', id: 'family' }, resource: 'plex', allowed: true });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.grant).toEqual({
|
||||
principal: { type: 'group', id: 'family' }, resource: 'plex', allowed: true,
|
||||
});
|
||||
const map = await ctx.store.getAccessMap();
|
||||
expect(map.grants).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('upsert: second POST replaces first (no duplicate)', async () => {
|
||||
ctx = createApp();
|
||||
await request(ctx.app)
|
||||
.post('/api/v1/network/access/grant')
|
||||
.send({ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true });
|
||||
const res = await request(ctx.app)
|
||||
.post('/api/v1/network/access/grant')
|
||||
.send({ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: false });
|
||||
expect(res.status).toBe(201);
|
||||
const map = await ctx.store.getAccessMap();
|
||||
expect(map.grants).toHaveLength(1);
|
||||
expect(map.grants[0].allowed).toBe(false);
|
||||
});
|
||||
|
||||
test('400 when principal missing', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.post('/api/v1/network/access/grant')
|
||||
.send({ resource: 'plex', allowed: true });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/principal/);
|
||||
});
|
||||
|
||||
test('400 when allowed not boolean', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.post('/api/v1/network/access/grant')
|
||||
.send({ principal: { type: 'person', id: 'p1' }, resource: 'plex' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/allowed/);
|
||||
});
|
||||
|
||||
test('400 when resource empty', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.post('/api/v1/network/access/grant')
|
||||
.send({ principal: { type: 'person', id: 'p1' }, resource: ' ', allowed: true });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-access routes: DELETE /grant', () => {
|
||||
let ctx;
|
||||
afterEach(() => ctx && cleanup(ctx.dir));
|
||||
|
||||
test('removes an existing grant → 200', async () => {
|
||||
ctx = createApp();
|
||||
await ctx.store.setGrant({ type: 'person', id: 'p1' }, 'plex', true);
|
||||
const res = await request(ctx.app)
|
||||
.delete('/api/v1/network/access/grant')
|
||||
.send({ principal: { type: 'person', id: 'p1' }, resource: 'plex' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.removed).toBe(true);
|
||||
expect((await ctx.store.getAccessMap()).grants).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('404 when grant absent', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.delete('/api/v1/network/access/grant')
|
||||
.send({ principal: { type: 'person', id: 'ghost' }, resource: 'plex' });
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
test('400 when principal invalid', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.delete('/api/v1/network/access/grant')
|
||||
.send({ principal: { type: 'ghost', id: 'x' }, resource: 'plex' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('400 when resource missing', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.delete('/api/v1/network/access/grant')
|
||||
.send({ principal: { type: 'person', id: 'p1' } });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('query-string form also works (bodyless DELETE)', async () => {
|
||||
ctx = createApp();
|
||||
await ctx.store.setGrant({ type: 'group', id: 'g' }, 'nas', true);
|
||||
const res = await request(ctx.app)
|
||||
.delete('/api/v1/network/access/grant?resource=nas&principal%5Btype%5D=group&principal%5Bid%5D=g');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.removed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-access routes: GET /effective/:type/:id', () => {
|
||||
let ctx;
|
||||
afterEach(() => ctx && cleanup(ctx.dir));
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = createApp();
|
||||
await ctx.store.saveAccessMap({
|
||||
grants: [
|
||||
{ principal: { type: 'group', id: 'family' }, resource: 'plex', allowed: true },
|
||||
{ principal: { type: 'group', id: 'family' }, resource: 'nas', allowed: false },
|
||||
{ principal: { type: 'person', id: 'sami' }, resource: 'nas', allowed: true }, // override
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('group effective access', async () => {
|
||||
const res = await request(ctx.app).get('/api/v1/network/access/effective/group/family');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.resources).toEqual({ plex: true, nas: false });
|
||||
expect(res.body.principal).toEqual({ type: 'group', id: 'family' });
|
||||
});
|
||||
|
||||
test('person with groups query param inherits group grants', async () => {
|
||||
const res = await request(ctx.app).get('/api/v1/network/access/effective/person/randomuser?groups=family');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.resources).toEqual({ plex: true, nas: false });
|
||||
});
|
||||
|
||||
test('person-level grant overrides group grant', async () => {
|
||||
const res = await request(ctx.app).get('/api/v1/network/access/effective/person/sami?groups=family');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.resources).toEqual({ plex: true, nas: true }); // person true beats group false
|
||||
});
|
||||
|
||||
test('person with no groups → only person grants', async () => {
|
||||
const res = await request(ctx.app).get('/api/v1/network/access/effective/person/sami');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.resources).toEqual({ nas: true });
|
||||
});
|
||||
|
||||
test('400 on invalid principalType', async () => {
|
||||
const res = await request(ctx.app).get('/api/v1/network/access/effective/robot/x');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/principalType|person.*group/);
|
||||
});
|
||||
|
||||
test('empty groups param ignored (not [""])', async () => {
|
||||
const res = await request(ctx.app).get('/api/v1/network/access/effective/person/sami?groups=');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.resources).toEqual({ nas: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Tests for src/network/acl-compiler.js — DC-148.
|
||||
*
|
||||
* Golden-policy cases, lockout adversarial cases (each hard rule), drift
|
||||
* detection both ways, baseline-section passthrough. 100% pure — no I/O,
|
||||
* no jest mocks needed.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/* eslint-disable require-await, no-unused-vars */
|
||||
// require-await: helper stubs are async to match real signatures without awaiting.
|
||||
// no-unused-vars: fixture records exist to document the people shape.
|
||||
|
||||
const {
|
||||
compilePolicy,
|
||||
assertNoLockout,
|
||||
detectDrift,
|
||||
compileAndValidate,
|
||||
hashAcls,
|
||||
LockoutError,
|
||||
} = require('../src/network/acl-compiler');
|
||||
|
||||
// ── fixtures ──────────────────────────────────────────────────────────────
|
||||
|
||||
const ADMIN = { id: 'p-admin', providerUserId: '1001', groups: ['Admin'], status: 'active' };
|
||||
const WIFE = { id: 'p-wife', providerUserId: '1002', groups: ['Family'], status: 'active' };
|
||||
const KID = { id: 'p-kid', providerUserId: '1003', groups: ['Family'], status: 'active' };
|
||||
const BLOCKED = { id: 'p-bad', providerUserId: '1004', groups: ['Family'], status: 'blocked' };
|
||||
const REMOVED = { id: 'p-gone', providerUserId: '1005', groups: ['Admin'], status: 'removed' };
|
||||
|
||||
const RESOURCES = [
|
||||
{ name: 'dashcaddy', host: 'dashcaddy.internal', ports: ['80', '443', '8080'] },
|
||||
{ name: 'plex', host: 'plex.internal', ports: ['32400'] },
|
||||
{ name: 'nas', host: 'nas.internal' }, // ports default → ['*']
|
||||
];
|
||||
|
||||
const HOST_DEVICE = 'dev-host-123';
|
||||
|
||||
function grants(...list) {
|
||||
return { grants: list.map(([type, id, resource, allowed]) =>
|
||||
({ principal: { type, id }, resource, allowed })) };
|
||||
}
|
||||
|
||||
const BASELINE = {
|
||||
ssh: [{ action: 'check', src: ['autogroup:member'], dst: ['autogroup:self'], users: ['autogroup:nonroot'] }],
|
||||
tagOwners: { 'tag:dc-host': ['autogroup:admin'] },
|
||||
};
|
||||
|
||||
// ── golden-policy: compilePolicy ──────────────────────────────────────────
|
||||
|
||||
describe('acl-compiler: golden policies', () => {
|
||||
test('single admin with all-resource grants → per-user src entries + host egress', () => {
|
||||
const map = grants(
|
||||
['person', 'p-admin', 'dashcaddy', true],
|
||||
['person', 'p-admin', 'plex', true],
|
||||
['person', 'p-admin', 'nas', true],
|
||||
);
|
||||
const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
||||
expect(policy.acls).toEqual([
|
||||
{ action: 'accept', src: ['1001'], dst: ['dashcaddy.internal:80', 'dashcaddy.internal:443', 'dashcaddy.internal:8080'], proto: ['tcp', 'udp'] },
|
||||
{ action: 'accept', src: ['1001'], dst: ['plex.internal:32400'], proto: ['tcp', 'udp'] },
|
||||
{ action: 'accept', src: ['1001'], dst: ['nas.internal:*'], proto: ['tcp', 'udp'] },
|
||||
{ action: 'accept', src: [HOST_DEVICE], dst: ['*:*'], proto: ['tcp', 'udp'] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('family group grant expands to each active member (no Tailscale groups)', () => {
|
||||
const map = grants(['group', 'Family', 'plex', true]);
|
||||
const policy = compilePolicy({ accessMap: map, people: [ADMIN, WIFE, KID, BLOCKED], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
||||
const srcs = policy.acls.map(e => e.src[0]);
|
||||
expect(srcs).toContain('1002');
|
||||
expect(srcs).toContain('1003');
|
||||
expect(srcs).not.toContain('1004'); // blocked compiles to nothing
|
||||
expect(srcs).not.toContain('1005'); // removed compiles to nothing
|
||||
// No group object is ever emitted in dst (free-plan rule)
|
||||
for (const e of policy.acls) {
|
||||
for (const d of e.dst) expect(d.startsWith('group:')).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('blocked person is absent even with an explicit personal allow grant', () => {
|
||||
const map = grants(
|
||||
['person', 'p-bad', 'plex', true],
|
||||
['group', 'Family', 'plex', true],
|
||||
);
|
||||
const policy = compilePolicy({ accessMap: map, people: [BLOCKED], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
||||
expect(policy.acls).toEqual([
|
||||
{ action: 'accept', src: [HOST_DEVICE], dst: ['*:*'], proto: ['tcp', 'udp'] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('person-level override beats group grant (deny and allow)', () => {
|
||||
const map = grants(
|
||||
['group', 'Family', 'plex', true],
|
||||
['person', 'p-kid', 'plex', false], // kid explicitly denied despite Family
|
||||
['group', 'Family', 'nas', false],
|
||||
['person', 'p-wife', 'nas', true], // wife explicitly allowed despite Family deny
|
||||
);
|
||||
const policy = compilePolicy({ accessMap: map, people: [WIFE, KID], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
||||
const wife = policy.acls.filter(e => e.src.includes('1002'));
|
||||
const kid = policy.acls.filter(e => e.src.includes('1003'));
|
||||
expect(wife.map(e => e.dst[0]).sort()).toEqual(['nas.internal:*', 'plex.internal:32400']); // nas via override + plex via group
|
||||
expect(kid).toEqual([]); // plex denied by override, nas denied by group
|
||||
});
|
||||
|
||||
test('default-deny: ungranted resources and people compile to nothing', () => {
|
||||
const map = grants(['group', 'Family', 'plex', true]);
|
||||
const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
||||
// only the host egress rule — no catch-all accept, nothing for ADMIN
|
||||
expect(policy.acls).toEqual([
|
||||
{ action: 'accept', src: [HOST_DEVICE], dst: ['*:*'], proto: ['tcp', 'udp'] },
|
||||
]);
|
||||
const flat = JSON.stringify(policy.acls);
|
||||
expect(flat.includes('*:*')).toBe(true); // host device egress only
|
||||
expect(policy.acls.filter(e => e.src.includes('1001'))).toEqual([]);
|
||||
});
|
||||
|
||||
test('grant for an unknown resource compiles to nothing', () => {
|
||||
const map = grants(['person', 'p-admin', 'ghost-app', true]);
|
||||
const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
||||
expect(policy.acls).toHaveLength(1); // host egress only
|
||||
});
|
||||
|
||||
test('person without providerUserId compiles to nothing', () => {
|
||||
const nobody = { ...ADMIN, id: 'p-noid', providerUserId: '' };
|
||||
const map = grants(['person', 'p-noid', 'plex', true]);
|
||||
const policy = compilePolicy({ accessMap: map, people: [nobody], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
||||
expect(policy.acls).toHaveLength(1); // host egress only
|
||||
});
|
||||
|
||||
test('baseline ssh + tagOwners pass through untouched; acls replaced', () => {
|
||||
const map = grants(['person', 'p-admin', 'plex', true]);
|
||||
const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE, baseline: BASELINE });
|
||||
expect(policy.ssh).toEqual(BASELINE.ssh);
|
||||
expect(policy.tagOwners).toEqual(BASELINE.tagOwners);
|
||||
expect(Object.keys(policy).sort()).toEqual(['acls', 'ssh', 'tagOwners']);
|
||||
});
|
||||
|
||||
test('baseline acls section is dropped (fully DC-managed)', () => {
|
||||
const withOldAcls = { ...BASELINE, acls: [{ action: 'accept', src: ['stale'], dst: ['x:*'] }] };
|
||||
const map = grants(['person', 'p-admin', 'plex', true]);
|
||||
const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE, baseline: withOldAcls });
|
||||
expect(policy.acls.some(e => e.src.includes('stale'))).toBe(false);
|
||||
});
|
||||
|
||||
test('input validation throws TypeError', () => {
|
||||
expect(() => compilePolicy()).toThrow(TypeError);
|
||||
expect(() => compilePolicy({ accessMap: {} })).toThrow(TypeError);
|
||||
expect(() => compilePolicy({ accessMap: { grants: [] }, people: [] })).toThrow(TypeError);
|
||||
expect(() => compilePolicy({ accessMap: { grants: [] }, people: [], resources: [] })).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
// ── lockout adversarial ───────────────────────────────────────────────────
|
||||
|
||||
describe('acl-compiler: lockout guard (assertNoLockout)', () => {
|
||||
const hostResource = RESOURCES[0]; // dashcaddy
|
||||
|
||||
test('last-admin-denied throws LockoutError', () => {
|
||||
// admin granted plex only — dashcaddy host resource NOT granted
|
||||
const map = grants(['person', 'p-admin', 'plex', true]);
|
||||
const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
||||
expect(() => assertNoLockout(policy, { adminUserIds: ['1001'], hostResource })).toThrow(LockoutError);
|
||||
expect(() => assertNoLockout(policy, { adminUserIds: ['1001'], hostResource }))
|
||||
.toThrow(/every active admin/);
|
||||
});
|
||||
|
||||
test('host-egress-missing throws LockoutError', () => {
|
||||
const map = grants(['person', 'p-admin', 'dashcaddy', true]);
|
||||
const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
||||
// simulate a policy that lost the egress rule
|
||||
policy.acls = policy.acls.filter(e => !e.dst.includes('*:*'));
|
||||
expect(() => assertNoLockout(policy, { adminUserIds: ['1001'], hostResource, hostDeviceId: HOST_DEVICE }))
|
||||
.toThrow(/egress/);
|
||||
});
|
||||
|
||||
test('two admins, one denied, one allowed → passes (not a lockout)', () => {
|
||||
const admin2 = { id: 'p-admin2', providerUserId: '1006', groups: ['Admin'], status: 'active' };
|
||||
const map = grants(
|
||||
['person', 'p-admin', 'plex', true], // admin1: NO dashcaddy
|
||||
['person', 'p-admin2', 'dashcaddy', true], // admin2: has dashcaddy
|
||||
);
|
||||
const policy = compilePolicy({ accessMap: map, people: [ADMIN, admin2], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
||||
expect(() => assertNoLockout(policy, { adminUserIds: ['1001', '1006'], hostResource })).not.toThrow();
|
||||
});
|
||||
|
||||
test('empty adminUserIds throws (no admin at all is a lockout)', () => {
|
||||
const map = grants(['person', 'p-wife', 'plex', true]);
|
||||
const policy = compilePolicy({ accessMap: map, people: [WIFE], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
||||
expect(() => assertNoLockout(policy, { adminUserIds: [], hostResource })).toThrow(LockoutError);
|
||||
expect(() => assertNoLockout(policy, { adminUserIds: [], hostResource })).toThrow(/no active admin/);
|
||||
});
|
||||
|
||||
test('admin allowed via group grant counts (Family granted dashcaddy)', () => {
|
||||
const adminViaGroup = { ...ADMIN, groups: ['Family'] };
|
||||
const map = grants(['group', 'Family', 'dashcaddy', true]);
|
||||
const policy = compilePolicy({ accessMap: map, people: [adminViaGroup], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
||||
expect(() => assertNoLockout(policy, { adminUserIds: ['1001'], hostResource })).not.toThrow();
|
||||
});
|
||||
|
||||
test('requires a policy object (TypeError)', () => {
|
||||
expect(() => assertNoLockout(null, {})).toThrow(TypeError);
|
||||
expect(() => assertNoLockout({}, {})).toThrow(TypeError);
|
||||
});
|
||||
|
||||
test('LockoutError shape: code, statusCode, name', () => {
|
||||
const map = grants(['person', 'p-admin', 'plex', true]);
|
||||
const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
||||
try {
|
||||
assertNoLockout(policy, { adminUserIds: ['1001'], hostResource });
|
||||
throw new Error('should have thrown');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(LockoutError);
|
||||
expect(err.name).toBe('LockoutError');
|
||||
expect(err.code).toBe('DC_ACL_LOCKOUT');
|
||||
expect(err.statusCode).toBe(409);
|
||||
expect(err.isOperational).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── drift detection ───────────────────────────────────────────────────────
|
||||
|
||||
describe('acl-compiler: drift detection (detectDrift)', () => {
|
||||
const map = grants(['person', 'p-admin', 'dashcaddy', true], ['person', 'p-admin', 'plex', true]);
|
||||
const { policy, hash } = compileAndValidate(_fullInput());
|
||||
|
||||
function _fullInput() {
|
||||
return { accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE, adminUserIds: ['1001'], hostResource: RESOURCES[0] };
|
||||
}
|
||||
|
||||
test('no drift: identical remote acls → drifted:false', () => {
|
||||
const remote = { acls: JSON.parse(JSON.stringify(policy.acls)) };
|
||||
const res = detectDrift(hash, remote);
|
||||
expect(res.drifted).toBe(false);
|
||||
expect(res.remoteHash).toBe(hash);
|
||||
});
|
||||
|
||||
test('drift: outside edit added a rule → drifted:true', () => {
|
||||
const remote = { acls: [...policy.acls, { action: 'accept', src: ['outsider'], dst: ['plex.internal:32400'] }] };
|
||||
expect(detectDrift(hash, remote).drifted).toBe(true);
|
||||
});
|
||||
|
||||
test('drift: rule removed remotely → drifted:true', () => {
|
||||
const remote = { acls: policy.acls.slice(0, -1) };
|
||||
expect(detectDrift(hash, remote).drifted).toBe(true);
|
||||
});
|
||||
|
||||
test('baseline sections changing is NOT drift (only acls hashed)', () => {
|
||||
const remote = { acls: JSON.parse(JSON.stringify(policy.acls)), ssh: [{ action: 'accept', src: ['x'], dst: ['y'] }] };
|
||||
expect(detectDrift(hash, remote).drifted).toBe(false);
|
||||
});
|
||||
|
||||
test('hash is order-stable (sorted keys) and content-sensitive', () => {
|
||||
expect(hashAcls(policy.acls)).toBe(hash);
|
||||
expect(hashAcls([])).not.toBe(hash);
|
||||
// key order inside entries does not matter
|
||||
const shuffled = policy.acls.map(e => ({ proto: e.proto, dst: e.dst, src: e.src, action: e.action }));
|
||||
expect(hashAcls(shuffled)).toBe(hash);
|
||||
});
|
||||
});
|
||||
|
||||
// ── compileAndValidate ────────────────────────────────────────────────────
|
||||
|
||||
describe('acl-compiler: compileAndValidate', () => {
|
||||
test('happy path returns { policy, hash } and passes the guard', () => {
|
||||
const map = grants(['person', 'p-admin', 'dashcaddy', true]);
|
||||
const out = compileAndValidate({
|
||||
accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE,
|
||||
adminUserIds: ['1001'], hostResource: RESOURCES[0],
|
||||
});
|
||||
expect(out.policy.acls.some(e => e.src.includes('1001'))).toBe(true);
|
||||
expect(out.hash).toMatch(/^sha256:[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test('throws LockoutError (no TypeError leak) when the guard trips', () => {
|
||||
const map = grants(['person', 'p-admin', 'plex', true]);
|
||||
expect(() => compileAndValidate({
|
||||
accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE,
|
||||
adminUserIds: ['1001'], hostResource: RESOURCES[0],
|
||||
})).toThrow(LockoutError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Tests for src/network/acl-push.js — DC-148.
|
||||
*
|
||||
* Hermetic: no network. The coord client is stubbed with the same
|
||||
* makeFetch rigor as __tests__/tailscale-coord.test.js — a route map
|
||||
* keyed by 'METHOD path' returning { status, body }, defaulting to 404.
|
||||
* dataDir points at a fresh tmp dir per test so the last-known-good
|
||||
* state file never leaks between tests.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/* eslint-disable require-await */
|
||||
// Helper stubs are async to match real client signatures without awaiting.
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { createAclPusher, AclPushError } = require('../src/network/acl-push');
|
||||
const { hashAcls } = require('../src/network/acl-compiler');
|
||||
|
||||
const POLICY = {
|
||||
acls: [
|
||||
{ action: 'accept', src: ['1001'], dst: ['dashcaddy.internal:443'], proto: ['tcp', 'udp'] },
|
||||
{ action: 'accept', src: ['dev-host-1'], dst: ['*:*'], proto: ['tcp', 'udp'] },
|
||||
],
|
||||
ssh: [{ action: 'check', src: ['autogroup:member'], dst: ['autogroup:self'], users: ['autogroup:nonroot'] }],
|
||||
tagOwners: { 'tag:dc-host': ['autogroup:admin'] },
|
||||
};
|
||||
const OTHER_POLICY = {
|
||||
acls: [{ action: 'accept', src: ['9999'], dst: ['x:*'], proto: ['tcp', 'udp'] }],
|
||||
};
|
||||
|
||||
/** Route-map fetch stub (tailscale-coord.test.js rigor). */
|
||||
function makeFetch(routes, { defaultStatus = 404, defaultBody = { message: 'no route' } } = {}) {
|
||||
const calls = [];
|
||||
const fn = jest.fn(async (method, apiPath, opts) => {
|
||||
calls.push({ method, path: apiPath, opts });
|
||||
const match = routes[method + ' ' + apiPath];
|
||||
if (match) {
|
||||
return {
|
||||
status: match.status,
|
||||
body: typeof match.body === 'string' ? match.body : JSON.stringify(match.body),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
};
|
||||
}
|
||||
return { status: defaultStatus, body: JSON.stringify(defaultBody) };
|
||||
});
|
||||
fn.calls = calls;
|
||||
return fn;
|
||||
}
|
||||
|
||||
/** Coord-client-shaped stub for the coord injection path. */
|
||||
function makeCoord(routes) {
|
||||
const fetchImpl = makeFetch(routes);
|
||||
return {
|
||||
fetchImpl,
|
||||
request: async (method, apiPath, opts) => {
|
||||
const res = await fetchImpl(method, '/api/v2' + apiPath, opts);
|
||||
if (res.status >= 400) {
|
||||
throw Object.assign(new Error(`coord ${res.status}`), { code: res.status === 400 ? 'bad_input' : 'http_error', status: res.status });
|
||||
}
|
||||
try { return JSON.parse(res.body); } catch (_) { return res.body; }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function tmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'dc-acl-push-'));
|
||||
}
|
||||
|
||||
function readState(dir) {
|
||||
return JSON.parse(fs.readFileSync(path.join(dir, 'network-acl-state.json'), 'utf8'));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── previewPolicy ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('acl-push: previewPolicy', () => {
|
||||
test('posts the full policy to /acl/preview and returns the body', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/-/acl/preview': { status: 200, body: { feasible: true, errors: [], warnings: [] } },
|
||||
});
|
||||
const pusher = createAclPusher({ fetchImpl, dataDir: tmpDir() });
|
||||
const out = await pusher.previewPolicy(POLICY);
|
||||
expect(out.feasible).toBe(true);
|
||||
expect(fetchImpl.calls).toHaveLength(1);
|
||||
const { method, path: p, opts } = fetchImpl.calls[0];
|
||||
expect(method).toBe('POST');
|
||||
expect(p).toBe('/api/v2/tailnet/-/acl/preview');
|
||||
expect(opts.body).toEqual(POLICY); // COMPLETE policy object, never partial
|
||||
});
|
||||
|
||||
test('preview errors[] → AclPushError acl_rejected (refuse to push)', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/-/acl/preview': {
|
||||
status: 200,
|
||||
body: { feasible: false, errors: [{ text: 'port 99999 invalid' }] },
|
||||
},
|
||||
});
|
||||
const pusher = createAclPusher({ fetchImpl, dataDir: tmpDir() });
|
||||
await expect(pusher.previewPolicy(POLICY)).rejects.toMatchObject({
|
||||
name: 'AclPushError', code: 'acl_rejected',
|
||||
});
|
||||
});
|
||||
|
||||
test('preview HTTP 400 → AclPushError (mapped)', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/-/acl/preview': { status: 400, body: { message: 'bad acl' } },
|
||||
});
|
||||
const pusher = createAclPusher({ fetchImpl, dataDir: tmpDir() });
|
||||
await expect(pusher.previewPolicy(POLICY)).rejects.toMatchObject({
|
||||
name: 'AclPushError', code: 'acl_rejected',
|
||||
});
|
||||
});
|
||||
|
||||
test('fetch throw → AclPushError coord_error (network failure mapped)', async () => {
|
||||
const fetchImpl = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
|
||||
const pusher = createAclPusher({ fetchImpl, dataDir: tmpDir() });
|
||||
await expect(pusher.previewPolicy(POLICY)).rejects.toMatchObject({
|
||||
name: 'AclPushError', code: 'coord_error',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── pushPolicy ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('acl-push: pushPolicy', () => {
|
||||
test('posts the complete policy and stores last-known-good body + hash', async () => {
|
||||
const dir = tmpDir();
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' },
|
||||
});
|
||||
const pusher = createAclPusher({ fetchImpl, dataDir: dir });
|
||||
const out = await pusher.pushPolicy(POLICY);
|
||||
expect(out.pushed).toBe(true);
|
||||
expect(out.hash).toBe(hashAcls(POLICY.acls));
|
||||
// request shape: complete object
|
||||
expect(fetchImpl.calls[0].opts.body).toEqual(POLICY);
|
||||
expect(fetchImpl.calls[0].opts.body.ssh).toBeDefined();
|
||||
expect(fetchImpl.calls[0].opts.body.tagOwners).toBeDefined();
|
||||
// state file persisted atomically with policy + hash
|
||||
const state = readState(dir);
|
||||
expect(state.policy).toEqual(POLICY);
|
||||
expect(state.hash).toBe(hashAcls(POLICY.acls));
|
||||
expect(state.pushedAt).toBeTruthy();
|
||||
expect(pusher.getLastKnownGood().hash).toBe(hashAcls(POLICY.acls));
|
||||
});
|
||||
|
||||
test('failed push (500) stores nothing', async () => {
|
||||
const dir = tmpDir();
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/-/acl': { status: 500, body: { message: 'boom' } },
|
||||
});
|
||||
const pusher = createAclPusher({ fetchImpl, dataDir: dir });
|
||||
await expect(pusher.pushPolicy(POLICY)).rejects.toMatchObject({ code: 'http_500' });
|
||||
expect(pusher.getLastKnownGood()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── rollback ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('acl-push: rollback', () => {
|
||||
test('re-pushes the stored last-known-good policy', async () => {
|
||||
const dir = tmpDir();
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' },
|
||||
});
|
||||
const pusher = createAclPusher({ fetchImpl, dataDir: dir });
|
||||
await pusher.pushPolicy(POLICY);
|
||||
fetchImpl.calls.length = 0;
|
||||
const out = await pusher.rollback();
|
||||
expect(out.rolledBack).toBe(true);
|
||||
expect(out.hash).toBe(hashAcls(POLICY.acls));
|
||||
// the rollback push carries the ORIGINAL stored body verbatim
|
||||
expect(fetchImpl.calls[0].opts.body).toEqual(POLICY);
|
||||
});
|
||||
|
||||
test('rollback without a stored policy throws no_last_known_good', async () => {
|
||||
const pusher = createAclPusher({ fetchImpl: makeFetch({}), dataDir: tmpDir() });
|
||||
await expect(pusher.rollback()).rejects.toMatchObject({ code: 'no_last_known_good' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── pushIfNoDrift ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('acl-push: pushIfNoDrift', () => {
|
||||
test('no drift → previews then pushes and updates state', async () => {
|
||||
const dir = tmpDir();
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/acl': { status: 200, body: POLICY },
|
||||
'POST /api/v2/tailnet/-/acl/preview': { status: 200, body: { feasible: true, errors: [] } },
|
||||
'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' },
|
||||
});
|
||||
const pusher = createAclPusher({ fetchImpl, dataDir: dir });
|
||||
await pusher.pushPolicy(POLICY); // seed stored hash
|
||||
fetchImpl.calls.length = 0;
|
||||
|
||||
const out = await pusher.pushIfNoDrift(POLICY);
|
||||
expect(out).toEqual({ pushed: true, hash: hashAcls(POLICY.acls) });
|
||||
const paths = fetchImpl.calls.map(c => c.method + ' ' + c.path);
|
||||
expect(paths).toEqual([
|
||||
'GET /api/v2/tailnet/-/acl',
|
||||
'POST /api/v2/tailnet/-/acl/preview',
|
||||
'POST /api/v2/tailnet/-/acl',
|
||||
]);
|
||||
});
|
||||
|
||||
test('drift (remote acls changed outside DashCaddy) → {drifted:true, pushed:false}, NO push', async () => {
|
||||
const dir = tmpDir();
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' },
|
||||
'GET /api/v2/tailnet/-/acl': { status: 200, body: OTHER_POLICY }, // outside edit
|
||||
'POST /api/v2/tailnet/-/acl/preview': { status: 200, body: { feasible: true, errors: [] } },
|
||||
});
|
||||
const pusher = createAclPusher({ fetchImpl, dataDir: dir });
|
||||
await pusher.pushPolicy(POLICY);
|
||||
fetchImpl.calls.length = 0;
|
||||
|
||||
const out = await pusher.pushIfNoDrift(POLICY);
|
||||
expect(out).toEqual({ drifted: true, pushed: false });
|
||||
// only the GET happened — never preview, never a silent overwrite push
|
||||
expect(fetchImpl.calls.map(c => c.method)).toEqual(['GET']);
|
||||
});
|
||||
|
||||
test('baseline-only remote change is NOT drift (ssh edited outside)', async () => {
|
||||
const dir = tmpDir();
|
||||
const editedBaseline = { ...POLICY, ssh: [{ action: 'accept', src: ['x'], dst: ['y'] }] };
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/acl': { status: 200, body: editedBaseline },
|
||||
'POST /api/v2/tailnet/-/acl/preview': { status: 200, body: { feasible: true, errors: [] } },
|
||||
'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' },
|
||||
});
|
||||
const pusher = createAclPusher({ fetchImpl, dataDir: dir });
|
||||
await pusher.pushPolicy(POLICY);
|
||||
fetchImpl.calls.length = 0;
|
||||
|
||||
const out = await pusher.pushIfNoDrift(POLICY);
|
||||
expect(out.pushed).toBe(true);
|
||||
});
|
||||
|
||||
test('first-ever push (no stored state) skips the drift check', async () => {
|
||||
const dir = tmpDir();
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/-/acl/preview': { status: 200, body: { feasible: true, errors: [] } },
|
||||
'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' },
|
||||
});
|
||||
const pusher = createAclPusher({ fetchImpl, dataDir: dir });
|
||||
const out = await pusher.pushIfNoDrift(POLICY);
|
||||
expect(out.pushed).toBe(true);
|
||||
expect(fetchImpl.calls.map(c => c.method)).toEqual(['POST', 'POST']); // no GET
|
||||
});
|
||||
|
||||
test('preview failure during pushIfNoDrift refuses the push (throws)', async () => {
|
||||
const dir = tmpDir();
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/-/acl/preview': { status: 200, body: { feasible: false, errors: [{ text: 'nope' }] } },
|
||||
'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' },
|
||||
});
|
||||
const pusher = createAclPusher({ fetchImpl, dataDir: dir });
|
||||
await expect(pusher.pushIfNoDrift(POLICY)).rejects.toMatchObject({ code: 'acl_rejected' });
|
||||
expect(fetchImpl.calls.map(c => c.method)).toEqual(['POST']); // preview only, no push
|
||||
});
|
||||
|
||||
test('remote read failure during drift check maps to AclPushError', async () => {
|
||||
const dir = tmpDir();
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' },
|
||||
'GET /api/v2/tailnet/-/acl': { status: 500, body: { message: 'ctrl down' } },
|
||||
});
|
||||
const pusher = createAclPusher({ fetchImpl, dataDir: dir });
|
||||
await pusher.pushPolicy(POLICY);
|
||||
fetchImpl.calls.length = 0;
|
||||
await expect(pusher.pushIfNoDrift(POLICY)).rejects.toMatchObject({ name: 'AclPushError' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── coord injection + misc ────────────────────────────────────────────────
|
||||
|
||||
describe('acl-push: injectable coord client + config', () => {
|
||||
test('coord.request is used instead of fetchImpl when both given', async () => {
|
||||
const coord = makeCoord({
|
||||
'POST /api/v2/tailnet/-/acl/preview': { status: 200, body: { feasible: true, errors: [] } },
|
||||
});
|
||||
const fetchImpl = jest.fn();
|
||||
const pusher = createAclPusher({ coord: { request: coord.request }, fetchImpl, dataDir: tmpDir() });
|
||||
const out = await pusher.previewPolicy(POLICY);
|
||||
expect(out.feasible).toBe(true);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('coord error (TailscaleCoordError-like) is mapped with its code', async () => {
|
||||
const coordErr = Object.assign(new Error('unauthorized'), { code: 'unauthorized', status: 401 });
|
||||
const coord = { request: async () => { throw coordErr; } };
|
||||
const pusher = createAclPusher({ coord, dataDir: tmpDir() });
|
||||
await expect(pusher.previewPolicy(POLICY)).rejects.toMatchObject({ code: 'unauthorized' });
|
||||
});
|
||||
|
||||
test('no coord and no fetchImpl → TypeError', () => {
|
||||
expect(() => createAclPusher({ dataDir: tmpDir() })).toThrow(TypeError);
|
||||
});
|
||||
|
||||
test('custom tailnet name is used in URLs', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/example.ts.net/acl/preview': { status: 200, body: { feasible: true, errors: [] } },
|
||||
});
|
||||
const pusher = createAclPusher({ fetchImpl, dataDir: tmpDir(), tailnet: 'example.ts.net' });
|
||||
await pusher.previewPolicy(POLICY);
|
||||
expect(fetchImpl.calls[0].path).toBe('/api/v2/tailnet/example.ts.net/acl/preview');
|
||||
});
|
||||
|
||||
test('AclPushError is exported and operational', () => {
|
||||
const e = new AclPushError('x', { code: 'test' });
|
||||
expect(e.name).toBe('AclPushError');
|
||||
expect(e.code).toBe('test');
|
||||
expect(e.isOperational).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* DC-147: CA naming + name constraint + backup hardening tests.
|
||||
*
|
||||
* Hermetic where possible: real openssl runs in tmp dirs (available in the
|
||||
* test env); the whole openssl-dependent suite is gated behind a capability
|
||||
* probe so CI without openssl skips cleanly. Route-level tests for
|
||||
* POST /api/ca/backup drive the real router with mocked platform paths.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const {
|
||||
opensslAvailable,
|
||||
setupNamedRootCA,
|
||||
verifyNameConstraint,
|
||||
assertLeafCompliance,
|
||||
backupRootKey,
|
||||
restoreRootKeyBackup,
|
||||
validateCommonName,
|
||||
normalizeTld
|
||||
} = require('../src/network/ca-setup');
|
||||
|
||||
const HAS_OPENSSL = opensslAvailable();
|
||||
const OPENSSL_GATED = HAS_OPENSSL ? describe : describe.skip;
|
||||
|
||||
let tmpDir;
|
||||
const CN = 'Ahmed Family Certificate Authority';
|
||||
|
||||
beforeAll(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc147-ca-'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Generate a leaf signed by `root` with configurable extensions/days. */
|
||||
function makeLeaf(root, name, extContent, days, cn = 'app.home') {
|
||||
const key = path.join(tmpDir, `${name}.key`);
|
||||
const csr = path.join(tmpDir, `${name}.csr`);
|
||||
const crt = path.join(tmpDir, `${name}.crt`);
|
||||
execFileSync('openssl', ['genrsa', '-out', key, '2048'], { stdio: 'pipe' });
|
||||
execFileSync('openssl', ['req', '-new', '-key', key, '-out', csr, '-subj', `/CN=${cn}`], { stdio: 'pipe' });
|
||||
const extArgs = extContent
|
||||
? ['-extfile', (() => { const f = path.join(tmpDir, `${name}.cnf`); fs.writeFileSync(f, extContent); return f; })()]
|
||||
: [];
|
||||
execFileSync('openssl', [
|
||||
'x509', '-req', '-in', csr,
|
||||
'-CA', root.rootCertPath, '-CAkey', root.rootKeyPath, '-CAcreateserial',
|
||||
'-out', crt, '-days', String(days), '-sha256', ...extArgs
|
||||
], { stdio: 'pipe' });
|
||||
return crt;
|
||||
}
|
||||
|
||||
describe('DC-147 input validation (hermetic)', () => {
|
||||
test('validateCommonName accepts friendly names, rejects injection', () => {
|
||||
expect(validateCommonName('Ahmed Family Certificate Authority')).toBe('Ahmed Family Certificate Authority');
|
||||
expect(validateCommonName(' padded ')).toBe('padded');
|
||||
expect(() => validateCommonName('')).toThrow();
|
||||
expect(() => validateCommonName(null)).toThrow();
|
||||
expect(() => validateCommonName('x'.repeat(65))).toThrow();
|
||||
expect(() => validateCommonName('Bad;/CN=evil')).toThrow();
|
||||
expect(() => validateCommonName('New\nline')).toThrow();
|
||||
});
|
||||
|
||||
test('normalizeTld strips dots and rejects bad labels', () => {
|
||||
expect(normalizeTld('.home')).toBe('home');
|
||||
expect(normalizeTld('home')).toBe('home');
|
||||
expect(normalizeTld('.HOME')).toBe('home');
|
||||
expect(() => normalizeTld('')).toThrow();
|
||||
expect(() => normalizeTld('.')).toThrow();
|
||||
expect(() => normalizeTld('two.words')).toThrow();
|
||||
expect(() => normalizeTld('bad/tld')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
OPENSSL_GATED('DC-147 setupNamedRootCA (real openssl, tmp dir)', () => {
|
||||
test('generated root has critical nameConstraints pinned to the TLD (x509 -text)', async () => {
|
||||
const r = await setupNamedRootCA({ commonName: CN, privateTld: '.home', dir: tmpDir });
|
||||
|
||||
// Raw openssl parse — independent of verifyNameConstraint.
|
||||
const text = execFileSync('openssl', ['x509', '-in', r.rootCertPath, '-text', '-noout']).toString();
|
||||
expect(text).toMatch(/X509v3 Name Constraints:\s+critical/);
|
||||
expect(text).toMatch(/Permitted:\s*\n\s+DNS:\.\s*home/);
|
||||
expect(text).toContain(`CN = ${CN}`);
|
||||
expect(text).toMatch(/CA:TRUE/);
|
||||
|
||||
// cert-info.json mirrors the existing flow's shape.
|
||||
const info = JSON.parse(fs.readFileSync(path.join(tmpDir, 'cert-info.json'), 'utf8'));
|
||||
expect(info.name).toBe(CN);
|
||||
expect(info.fingerprint).toMatch(/^[0-9A-F:]{95}$/);
|
||||
expect(info.serialNumber).toMatch(/^[0-9A-F]+$/);
|
||||
expect(info.validFrom).toBeTruthy();
|
||||
expect(info.validUntil).toBeTruthy();
|
||||
expect(info.nameConstraints).toEqual({ critical: true, permitted: ['.home'] });
|
||||
|
||||
// Root key perms tight.
|
||||
expect((fs.statSync(r.rootKeyPath).mode & 0o777)).toBe(0o600);
|
||||
|
||||
// Root can only sign names under .home — a leaf for another TLD must fail
|
||||
// path validation (name constraint enforced by openssl verify).
|
||||
const foreignLeaf = makeLeaf(r, 'foreign', null, 30, 'app.example.com');
|
||||
let verifyOk = true;
|
||||
try {
|
||||
execFileSync('openssl', ['verify', '-CAfile', r.rootCertPath, foreignLeaf], { stdio: 'pipe' });
|
||||
} catch {
|
||||
verifyOk = false;
|
||||
}
|
||||
expect(verifyOk).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
test('second setup attempt without force REFUSES', async () => {
|
||||
const dir2 = fs.mkdtempSync(path.join(os.tmpdir(), 'dc147-ca2-'));
|
||||
try {
|
||||
await setupNamedRootCA({ commonName: CN, privateTld: '.home', dir: dir2 });
|
||||
await expect(setupNamedRootCA({ commonName: 'Renamed CA', privateTld: '.home', dir: dir2 }))
|
||||
.rejects.toMatchObject({ code: 'DC147_ROOT_EXISTS' });
|
||||
// force without the exact confirmName still refuses.
|
||||
await expect(setupNamedRootCA({ commonName: 'Renamed CA', privateTld: '.home', dir: dir2, force: true, confirmName: 'Wrong Name' }))
|
||||
.rejects.toMatchObject({ code: 'DC147_ROOT_EXISTS' });
|
||||
// force + correct confirmName proceeds and archives the old root.
|
||||
const r2 = await setupNamedRootCA({ commonName: 'Renamed CA', privateTld: '.home', dir: dir2, force: true, confirmName: CN });
|
||||
expect(r2.certInfo.name).toBe('Renamed CA');
|
||||
expect(fs.readdirSync(dir2).some((f) => f.startsWith('root.crt.replaced-'))).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(dir2, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
OPENSSL_GATED('DC-147 verifyNameConstraint', () => {
|
||||
test('true for the pinned TLD, false for others / non-constrained certs', async () => {
|
||||
const r = await setupNamedRootCA({ commonName: CN, privateTld: '.home', dir: path.join(tmpDir, 'nc') });
|
||||
expect(verifyNameConstraint(r.rootCertPath, '.home')).toBe(true);
|
||||
expect(verifyNameConstraint(r.rootCertPath, 'home')).toBe(true);
|
||||
expect(verifyNameConstraint(r.rootCertPath, '.lan')).toBe(false);
|
||||
expect(verifyNameConstraint(r.rootCertPath, 'hom')).toBe(false);
|
||||
|
||||
// A plain self-signed cert with no nameConstraints.
|
||||
const plainKey = path.join(tmpDir, 'plain.key');
|
||||
const plainCrt = path.join(tmpDir, 'plain.crt');
|
||||
execFileSync('openssl', ['genrsa', '-out', plainKey, '2048'], { stdio: 'pipe' });
|
||||
execFileSync('openssl', ['req', '-x509', '-new', '-key', plainKey, '-subj', '/CN=plain', '-days', '30', '-out', plainCrt], { stdio: 'pipe' });
|
||||
expect(verifyNameConstraint(plainCrt, 'home')).toBe(false);
|
||||
|
||||
expect(verifyNameConstraint(path.join(tmpDir, 'no-such-cert.crt'), 'home')).toBe(false);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
OPENSSL_GATED('DC-147 assertLeafCompliance', () => {
|
||||
test('passes for a compliant leaf (SAN + serverAuth + ≤398 days)', async () => {
|
||||
const root = await setupNamedRootCA({ commonName: CN, privateTld: '.home', dir: path.join(tmpDir, 'leaf-ok') });
|
||||
const leaf = makeLeaf(root, 'good-leaf',
|
||||
'subjectAltName=DNS:app.home\nextendedKeyUsage=serverAuth\n', 365);
|
||||
const result = assertLeafCompliance(leaf);
|
||||
expect(result.compliant).toBe(true);
|
||||
expect(result.violations).toEqual([]);
|
||||
}, 30000);
|
||||
|
||||
test('FAILS for a 720-day no-SAN leaf (deliberately non-compliant)', async () => {
|
||||
const root = await setupNamedRootCA({ commonName: CN, privateTld: '.home', dir: path.join(tmpDir, 'leaf-bad') });
|
||||
const leaf = makeLeaf(root, 'bad-leaf', null, 720);
|
||||
const result = assertLeafCompliance(leaf);
|
||||
expect(result.compliant).toBe(false);
|
||||
expect(result.violations.join(' ')).toMatch(/subjectAltName/);
|
||||
expect(result.violations.join(' ')).toMatch(/serverAuth/);
|
||||
expect(result.violations.join(' ')).toMatch(/398/);
|
||||
}, 30000);
|
||||
|
||||
test('FAILS for a long-validity leaf that otherwise has SAN + EKU', async () => {
|
||||
const root = await setupNamedRootCA({ commonName: CN, privateTld: '.home', dir: path.join(tmpDir, 'leaf-long') });
|
||||
const leaf = makeLeaf(root, 'long-leaf',
|
||||
'subjectAltName=DNS:app.home\nextendedKeyUsage=serverAuth\n', 500);
|
||||
const result = assertLeafCompliance(leaf);
|
||||
expect(result.compliant).toBe(false);
|
||||
expect(result.violations).toHaveLength(1);
|
||||
expect(result.violations[0]).toMatch(/398/);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
OPENSSL_GATED('DC-147 encrypted root-key backup', () => {
|
||||
test('backupRootKey returns ciphertext + checksum; round-trip decrypt proves the passphrase works', async () => {
|
||||
const dir = path.join(tmpDir, 'backup');
|
||||
const root = await setupNamedRootCA({ commonName: CN, privateTld: '.home', dir });
|
||||
const plaintextKey = fs.readFileSync(root.rootKeyPath, 'utf8');
|
||||
expect(plaintextKey).toContain('PRIVATE KEY');
|
||||
|
||||
const passphrase = 'correct horse battery staple';
|
||||
const backup = backupRootKey({ dir, passphrase });
|
||||
|
||||
// Ciphertext never contains the plaintext key marker.
|
||||
expect(typeof backup.ciphertext).toBe('string');
|
||||
expect(backup.ciphertext).not.toContain('PRIVATE KEY');
|
||||
expect(backup.ciphertext.length).toBeGreaterThan(0);
|
||||
// sha256 checksum of the ciphertext.
|
||||
expect(backup.sha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
const crypto = require('crypto');
|
||||
expect(crypto.createHash('sha256').update(backup.ciphertext, 'utf8').digest('hex')).toBe(backup.sha256);
|
||||
// The key itself does not leak anywhere in the payload.
|
||||
expect(backup.ciphertext).not.toContain(plaintextKey.slice(0, 40));
|
||||
|
||||
// Round-trip: decrypting with the passphrase reproduces the exact key.
|
||||
const restored = restoreRootKeyBackup({ ciphertext: backup.ciphertext, passphrase }).toString('utf8');
|
||||
expect(restored).toBe(plaintextKey);
|
||||
|
||||
// Wrong passphrase must fail.
|
||||
let wrongOk = true;
|
||||
try {
|
||||
restoreRootKeyBackup({ ciphertext: backup.ciphertext, passphrase: 'wrong passphrase!' });
|
||||
} catch {
|
||||
wrongOk = false;
|
||||
}
|
||||
expect(wrongOk).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
test('rejects short/missing passphrase and missing root key', () => {
|
||||
expect(() => backupRootKey({ dir: tmpDir, passphrase: 'short' })).toThrow(/8 characters/);
|
||||
expect(() => backupRootKey({ dir: tmpDir, passphrase: undefined })).toThrow();
|
||||
let missingErr;
|
||||
try { backupRootKey({ dir: path.join(tmpDir, 'definitely-empty'), passphrase: 'long-enough-pass' }); }
|
||||
catch (e) { missingErr = e; }
|
||||
expect(missingErr).toMatchObject({ code: 'DC147_ROOT_KEY_MISSING' });
|
||||
});
|
||||
});
|
||||
|
||||
if (!HAS_OPENSSL) {
|
||||
test('openssl capability notice', () => {
|
||||
// Gated: this environment lacks a working openssl binary; the real-cert
|
||||
// blocks above are skipped rather than failing.
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* forward_auth enforcement tests — DC-154 (spec §4 Layer 2).
|
||||
*
|
||||
* Hermetic: the app is a bare Express mount of routes/network-forward-auth
|
||||
* with a fully mocked provider.whois, stores, sessionValidator, and auditLog.
|
||||
* No disk, no network, no app context.
|
||||
*/
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
const { createForwardAuthService } = require('../src/network/forward-auth');
|
||||
const forwardAuthRoutes = require('../routes/network-forward-auth');
|
||||
const { parseForwardedFor } = forwardAuthRoutes;
|
||||
|
||||
// ── fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
const PERSON = 'person:sami';
|
||||
const DEVICE = 'wgd:abc123';
|
||||
const OTHER_PERSON = 'person:guest';
|
||||
|
||||
const RESOURCES = [
|
||||
{ name: 'plex', displayName: 'Plex', sensitive: false },
|
||||
{ name: 'vault', displayName: 'Vault', sensitive: true },
|
||||
{ name: 'nas', displayName: 'NAS', sensitive: false },
|
||||
];
|
||||
|
||||
function buildDeps(overrides = {}) {
|
||||
const auditEntries = [];
|
||||
const deps = {
|
||||
provider: {
|
||||
id: () => 'wireguard',
|
||||
whois(ip) {
|
||||
if (ip === '10.53.0.7') return { personId: PERSON, deviceId: DEVICE };
|
||||
if (ip === '10.53.0.9') return { personId: OTHER_PERSON, deviceId: 'wgd:other' };
|
||||
return null;
|
||||
},
|
||||
},
|
||||
accessStore: {
|
||||
effectiveAccess(principal) {
|
||||
// person:sami is allowed plex+vault; person:guest only plex.
|
||||
const map = {
|
||||
[PERSON]: { plex: true, 'plex.internal': true, vault: true, nas: false },
|
||||
[OTHER_PERSON]: { plex: true, vault: false, nas: false },
|
||||
};
|
||||
return { ok: true, resources: map[principal.id] || {} };
|
||||
},
|
||||
},
|
||||
resourcesStore: {
|
||||
listResources() { return Promise.resolve(RESOURCES.map(r => ({ ...r }))); },
|
||||
},
|
||||
peopleStore: {
|
||||
getPerson(id) {
|
||||
if (id === PERSON) return { id, groups: ['admins'] };
|
||||
if (id === OTHER_PERSON) return { id, groups: ['guests'] };
|
||||
return null;
|
||||
},
|
||||
},
|
||||
sessionValidator: () => false,
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
auditLog: {
|
||||
logForwardAuth(entry) { auditEntries.push(entry); },
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
deps.auditEntries = auditEntries;
|
||||
return deps;
|
||||
}
|
||||
|
||||
function buildApp(deps, opts = {}) {
|
||||
const app = express();
|
||||
const service = createForwardAuthService(deps);
|
||||
app.use('/network/forward-auth', forwardAuthRoutes({
|
||||
service,
|
||||
// supertest connects over loopback: that IS the trusted reverse proxy
|
||||
// in the test harness (mirrors Caddy on-host in production).
|
||||
trustedProxies: opts.trustedProxies !== undefined ? opts.trustedProxies : ['127.0.0.1', '::1'],
|
||||
}));
|
||||
return app;
|
||||
}
|
||||
|
||||
// Convenience: one request with the usual headers.
|
||||
function hit(app, { ip = '10.53.0.7', host = 'plex.sami', method = 'get', session = false } = {}) {
|
||||
const req = request(app)[method]('/network/forward-auth/')
|
||||
.set('X-Forwarded-For', ip)
|
||||
.set('X-Forwarded-Host', host);
|
||||
if (session) req.set('Cookie', 'dc_session=valid');
|
||||
return req;
|
||||
}
|
||||
|
||||
// ── service: decision matrix ────────────────────────────────────────────────
|
||||
|
||||
describe('DC-154 forward_auth service (createForwardAuthService.check)', () => {
|
||||
test('unknown device → 401 { error: "unknown-device" } + WWW-Authenticate', async () => {
|
||||
const svc = createForwardAuthService(buildDeps());
|
||||
const d = await svc.check({ sourceIp: '10.99.99.99', host: 'plex.sami', requestPath: '/' });
|
||||
expect(d.status).toBe(401);
|
||||
expect(d.body).toEqual({ error: 'unknown-device' });
|
||||
expect(d.headers['WWW-Authenticate']).toMatch(/ForwardAuth/);
|
||||
});
|
||||
|
||||
test('whois throwing is treated as unknown device, not a 500', async () => {
|
||||
const deps = buildDeps({
|
||||
provider: { whois() { return Promise.reject(new Error('cli down')); } },
|
||||
});
|
||||
const svc = createForwardAuthService(deps);
|
||||
const d = await svc.check({ sourceIp: '10.53.0.7', host: 'plex.sami', requestPath: '/' });
|
||||
expect(d.status).toBe(401);
|
||||
expect(d.body).toEqual({ error: 'unknown-device' });
|
||||
});
|
||||
|
||||
test('unmatched host → 404 { error: "unknown-resource" }', async () => {
|
||||
const svc = createForwardAuthService(buildDeps());
|
||||
const d = await svc.check({ sourceIp: '10.53.0.7', host: 'nope.sami', requestPath: '/' });
|
||||
expect(d.status).toBe(404);
|
||||
expect(d.body).toEqual({ error: 'unknown-resource' });
|
||||
});
|
||||
|
||||
test('denied by access map → 403 { error: "forbidden", resource, personId }', async () => {
|
||||
const svc = createForwardAuthService(buildDeps());
|
||||
// guest (10.53.0.9) is not granted vault
|
||||
const d = await svc.check({ sourceIp: '10.53.0.9', host: 'vault.sami', requestPath: '/' });
|
||||
expect(d.status).toBe(403);
|
||||
expect(d.body).toEqual({ error: 'forbidden', resource: 'vault', personId: OTHER_PERSON });
|
||||
});
|
||||
|
||||
test('allowed non-sensitive → 200 tailnet identity + X-Forwarded headers', async () => {
|
||||
const svc = createForwardAuthService(buildDeps());
|
||||
const d = await svc.check({ sourceIp: '10.53.0.7', host: 'plex.sami', requestPath: '/' });
|
||||
expect(d.status).toBe(200);
|
||||
expect(d.body).toEqual({ authenticated: 'tailnet', personId: PERSON, deviceId: DEVICE });
|
||||
expect(d.headers['X-Forwarded-User']).toBe(PERSON);
|
||||
expect(d.headers['X-Forwarded-Person']).toBe(PERSON);
|
||||
});
|
||||
|
||||
test('allowed sensitive without session → 401 { error: "step-up-required", loginUrl }', async () => {
|
||||
const svc = createForwardAuthService(buildDeps());
|
||||
const d = await svc.check({ sourceIp: '10.53.0.7', host: 'vault.sami', requestPath: '/', req: {} });
|
||||
expect(d.status).toBe(401);
|
||||
expect(d.body).toEqual({ error: 'step-up-required', loginUrl: '/auth/login' });
|
||||
});
|
||||
|
||||
test('allowed sensitive with valid session → 200 { authenticated: "step-up" } + headers', async () => {
|
||||
const deps = buildDeps({ sessionValidator: () => true });
|
||||
const svc = createForwardAuthService(deps);
|
||||
const d = await svc.check({ sourceIp: '10.53.0.7', host: 'vault.sami', requestPath: '/', req: {} });
|
||||
expect(d.status).toBe(200);
|
||||
expect(d.body).toEqual({ authenticated: 'step-up', personId: PERSON });
|
||||
expect(d.headers['X-Forwarded-User']).toBe(PERSON);
|
||||
});
|
||||
|
||||
test('multi-label resource names match by leading-subdomain prefix (plex.internal)', async () => {
|
||||
const deps = buildDeps({
|
||||
resourcesStore: {
|
||||
listResources() {
|
||||
return [
|
||||
{ name: 'plex', sensitive: false },
|
||||
{ name: 'plex.internal', sensitive: false },
|
||||
];
|
||||
},
|
||||
},
|
||||
});
|
||||
const svc = createForwardAuthService(deps);
|
||||
const d = await svc.check({ sourceIp: '10.53.0.7', host: 'plex.internal.sami', requestPath: '/' });
|
||||
expect(d.status).toBe(200);
|
||||
// longest matching name wins — not the shorter 'plex'
|
||||
const audit = deps.auditEntries.find(e => e.event === 'forward_auth_allow');
|
||||
expect(audit.resource).toBe('plex.internal');
|
||||
});
|
||||
});
|
||||
|
||||
// ── service: audit entries ──────────────────────────────────────────────────
|
||||
|
||||
describe('DC-154 audit entries per outcome', () => {
|
||||
test('allow → { event: "forward_auth_allow", personId, resource, host }', async () => {
|
||||
const deps = buildDeps();
|
||||
const svc = createForwardAuthService(deps);
|
||||
await svc.check({ sourceIp: '10.53.0.7', host: 'plex.sami', requestPath: '/' });
|
||||
expect(deps.auditEntries).toHaveLength(1);
|
||||
expect(deps.auditEntries[0]).toEqual(expect.objectContaining({
|
||||
event: 'forward_auth_allow',
|
||||
personId: PERSON,
|
||||
resource: 'plex',
|
||||
host: 'plex.sami',
|
||||
}));
|
||||
});
|
||||
|
||||
test('deny (forbidden) → forward_auth_deny with resource + personId', async () => {
|
||||
const deps = buildDeps();
|
||||
const svc = createForwardAuthService(deps);
|
||||
await svc.check({ sourceIp: '10.53.0.9', host: 'vault.sami', requestPath: '/' });
|
||||
expect(deps.auditEntries[0]).toEqual(expect.objectContaining({
|
||||
event: 'forward_auth_deny',
|
||||
personId: OTHER_PERSON,
|
||||
resource: 'vault',
|
||||
host: 'vault.sami',
|
||||
}));
|
||||
});
|
||||
|
||||
test('deny (unknown resource) → forward_auth_deny with null resource', async () => {
|
||||
const deps = buildDeps();
|
||||
const svc = createForwardAuthService(deps);
|
||||
await svc.check({ sourceIp: '10.53.0.7', host: 'nope.sami', requestPath: '/' });
|
||||
expect(deps.auditEntries[0].event).toBe('forward_auth_deny');
|
||||
expect(deps.auditEntries[0].resource).toBeNull();
|
||||
expect(deps.auditEntries[0].personId).toBe(PERSON);
|
||||
});
|
||||
|
||||
test('sensitive step-up both outcomes → forward_auth_step_up', async () => {
|
||||
const deps = buildDeps({ sessionValidator: () => false });
|
||||
const svc = createForwardAuthService(deps);
|
||||
await svc.check({ sourceIp: '10.53.0.7', host: 'vault.sami', requestPath: '/', req: {} });
|
||||
expect(deps.auditEntries[0].event).toBe('forward_auth_step_up');
|
||||
expect(deps.auditEntries[0].outcome).toBe('required');
|
||||
|
||||
deps.auditEntries.length = 0;
|
||||
const okDeps = buildDeps({ sessionValidator: () => true });
|
||||
const svc2 = createForwardAuthService(okDeps);
|
||||
await svc2.check({ sourceIp: '10.53.0.7', host: 'vault.sami', requestPath: '/', req: {} });
|
||||
expect(okDeps.auditEntries[0]).toEqual(expect.objectContaining({
|
||||
event: 'forward_auth_step_up',
|
||||
outcome: 'granted',
|
||||
}));
|
||||
});
|
||||
|
||||
test('audit failures never break the auth decision', async () => {
|
||||
const deps = buildDeps({
|
||||
auditLog: { logForwardAuth() { throw new Error('audit sink down'); } },
|
||||
});
|
||||
const svc = createForwardAuthService(deps);
|
||||
const d = await svc.check({ sourceIp: '10.53.0.7', host: 'plex.sami', requestPath: '/' });
|
||||
expect(d.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ── route: HTTP surface ─────────────────────────────────────────────────────
|
||||
|
||||
describe('DC-154 route (GET/POST /network/forward-auth/)', () => {
|
||||
test('GET unknown device → 401 + WWW-Authenticate header', async () => {
|
||||
const app = buildApp(buildDeps());
|
||||
const res = await hit(app, { ip: '10.99.99.99' });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: 'unknown-device' });
|
||||
expect(res.headers['www-authenticate']).toBeDefined();
|
||||
});
|
||||
|
||||
test('GET unknown resource → 404', async () => {
|
||||
const app = buildApp(buildDeps());
|
||||
const res = await hit(app, { host: 'nope.sami' });
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'unknown-resource' });
|
||||
});
|
||||
|
||||
test('GET denied → 403 with resource + personId', async () => {
|
||||
const app = buildApp(buildDeps());
|
||||
const res = await hit(app, { ip: '10.53.0.9', host: 'vault.sami' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toBe('forbidden');
|
||||
expect(res.body.resource).toBe('vault');
|
||||
expect(res.body.personId).toBe(OTHER_PERSON);
|
||||
});
|
||||
|
||||
test('GET allowed non-sensitive → 200 tailnet + X-Forwarded-User/Person headers', async () => {
|
||||
const app = buildApp(buildDeps());
|
||||
const res = await hit(app, {});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ authenticated: 'tailnet', personId: PERSON, deviceId: DEVICE });
|
||||
expect(res.headers['x-forwarded-user']).toBe(PERSON);
|
||||
expect(res.headers['x-forwarded-person']).toBe(PERSON);
|
||||
});
|
||||
|
||||
test('POST works identically (Caddy may use either verb)', async () => {
|
||||
const app = buildApp(buildDeps());
|
||||
const res = await hit(app, { method: 'post' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.authenticated).toBe('tailnet');
|
||||
});
|
||||
|
||||
test('sensitive without session → 401 step-up-required + loginUrl', async () => {
|
||||
const app = buildApp(buildDeps());
|
||||
const res = await hit(app, { host: 'vault.sami' });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: 'step-up-required', loginUrl: '/auth/login' });
|
||||
});
|
||||
|
||||
test('sensitive with valid session → 200 step-up', async () => {
|
||||
const app = buildApp(buildDeps({ sessionValidator: (req) => !!(req && req.headers) }));
|
||||
const res = await hit(app, { host: 'vault.sami' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ authenticated: 'step-up', personId: PERSON });
|
||||
});
|
||||
|
||||
test('Host header used when X-Forwarded-Host absent', async () => {
|
||||
const app = buildApp(buildDeps());
|
||||
const res = await request(app).get('/network/forward-auth/')
|
||||
.set('X-Forwarded-For', '10.53.0.7')
|
||||
.set('Host', 'plex.sami:8080'); // express maps Host → req.headers.host
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.authenticated).toBe('tailnet');
|
||||
});
|
||||
|
||||
test('falls back to req.ip when X-Forwarded-For absent', async () => {
|
||||
const app = buildApp(buildDeps());
|
||||
const res = await request(app).get('/network/forward-auth/')
|
||||
.set('X-Forwarded-Host', 'plex.sami');
|
||||
// supertest connects from 127.0.0.1 — an unknown device → 401, proving
|
||||
// the fallback consulted req.ip rather than crashing on a missing header.
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: 'unknown-device' });
|
||||
});
|
||||
|
||||
test('no provider active → 503 { error: "network provider not active" } (lazy mount semantics)', async () => {
|
||||
// Mirror of the app.js p7ForwardAuthLazy stub: provider absent → 503
|
||||
// before the router is ever built. Verified here by mounting the same
|
||||
// lazy wrapper shape the app uses.
|
||||
const app = express();
|
||||
const lazy = (req, res, next) => {
|
||||
const router = null; // registry returns no router when no provider active
|
||||
if (!router) {
|
||||
const prov = null; // registry returns null when no provider active
|
||||
if (!prov) return res.status(503).json({ error: 'network provider not active' });
|
||||
}
|
||||
return router(req, res, next);
|
||||
};
|
||||
app.use('/network/forward-auth', lazy);
|
||||
const res = await request(app).get('/network/forward-auth/')
|
||||
.set('X-Forwarded-For', '10.53.0.7');
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body).toEqual({ error: 'network provider not active' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── X-Forwarded-For parsing ─────────────────────────────────────────────────
|
||||
|
||||
describe('DC-154 trust boundary (forwarded headers only from trusted proxy)', () => {
|
||||
test('DIRECT request with spoofed X-Forwarded-For (no trusted proxy) → identity NOT inherited → 401 unknown-device', async () => {
|
||||
const app = buildApp(buildDeps(), { trustedProxies: [] }); // trust disabled
|
||||
const res = await request(app).get('/network/forward-auth/')
|
||||
.set('X-Forwarded-For', '10.53.0.7') // spoofed: a KNOWN allowed device
|
||||
.set('X-Forwarded-Host', 'plex.sami');
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: 'unknown-device' });
|
||||
});
|
||||
|
||||
test('Spoofed X-Forwarded-Host from an untrusted peer is ignored (socket identity used instead)', async () => {
|
||||
const app = buildApp(buildDeps(), { trustedProxies: [] });
|
||||
const res = await request(app).get('/network/forward-auth/')
|
||||
.set('X-Forwarded-For', '10.53.0.7')
|
||||
.set('X-Forwarded-Host', 'vault.sami');
|
||||
// socket addr (127.0.0.1) is not a known device → unknown-device, NOT
|
||||
// a vault authorization decision based on the spoofed host+ip pair.
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: 'unknown-device' });
|
||||
});
|
||||
|
||||
test('Trusted proxy present → headers honored as before (regression)', async () => {
|
||||
const app = buildApp(buildDeps()); // loopback trusted
|
||||
const res = await request(app).get('/network/forward-auth/')
|
||||
.set('X-Forwarded-For', '10.53.0.7, 127.0.0.1')
|
||||
.set('X-Forwarded-Host', 'plex.sami');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.personId).toBe(PERSON);
|
||||
});
|
||||
|
||||
test('XFF chain from trusted proxy picks the first entry (original client)', async () => {
|
||||
const app = buildApp(buildDeps());
|
||||
const res = await request(app).get('/network/forward-auth/')
|
||||
.set('X-Forwarded-For', '10.53.0.9, 127.0.0.1') // first = other person's device
|
||||
.set('X-Forwarded-Host', 'plex.sami');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.personId).toBe(OTHER_PERSON);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DC-154 parseForwardedFor', () => {
|
||||
test('single entry', () => {
|
||||
expect(parseForwardedFor('203.0.113.7')).toEqual(['203.0.113.7']);
|
||||
});
|
||||
|
||||
test('chained entries (client first) preserve order', () => {
|
||||
expect(parseForwardedFor('203.0.113.7, 10.53.0.1, 10.53.0.2'))
|
||||
.toEqual(['203.0.113.7', '10.53.0.1', '10.53.0.2']);
|
||||
});
|
||||
|
||||
test('missing header → []', () => {
|
||||
expect(parseForwardedFor(undefined)).toEqual([]);
|
||||
expect(parseForwardedFor('')).toEqual([]);
|
||||
});
|
||||
|
||||
test('port-bearing and ipv6 entries are normalized', () => {
|
||||
expect(parseForwardedFor('203.0.113.7:54321')).toEqual(['203.0.113.7']);
|
||||
expect(parseForwardedFor('[fd00::1]:443')).toEqual(['fd00::1']);
|
||||
});
|
||||
|
||||
test('route uses the FIRST chained entry as the client IP', async () => {
|
||||
const deps = buildDeps();
|
||||
const app = buildApp(deps);
|
||||
// 10.53.0.7 = PERSON; middle hop 10.53.0.1 is unknown — if the route
|
||||
// picked the wrong hop the whois would 401 instead of allowing.
|
||||
const res = await request(app).get('/network/forward-auth/')
|
||||
.set('X-Forwarded-For', '10.53.0.7, 10.53.0.1')
|
||||
.set('X-Forwarded-Host', 'plex.sami');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.personId).toBe(PERSON);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Route tests for network-groups routes (DC-144).
|
||||
* Coverage:
|
||||
* - CRUD contract (GET list, POST create, PATCH rename, DELETE)
|
||||
* - derived listing: 'Admin' always present; groups referenced from
|
||||
* person.groups[] appear automatically
|
||||
* - slug validation (bad ids rejected, DashCaddy-local strings only)
|
||||
* - duplicate create → 409
|
||||
* - 'Admin' cannot be renamed or deleted (409)
|
||||
* - rename cascade: grant references re-pointed to the new id
|
||||
* - delete cascade: group stripped from person.groups[] AND from
|
||||
* every access grant referencing it
|
||||
* Hermetic — supertest against an in-process express app over real
|
||||
* stores in a temp dir, 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 } = require('../src/security/network-people-store');
|
||||
const { createNetworkAccessStore } = require('../src/security/network-access-store');
|
||||
const { createGroupsService } = require('../src/security/network-groups');
|
||||
const networkGroupsRoutes = require('../routes/network-groups');
|
||||
|
||||
function asyncHandler(fn) {
|
||||
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
}
|
||||
|
||||
function createApp() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-grouproutes-'));
|
||||
const peopleStore = createPeopleStore({ dataDir: dir });
|
||||
const accessStore = createNetworkAccessStore({ dataDir: dir });
|
||||
const groupsService = createGroupsService({ dataDir: dir, peopleStore, accessStore });
|
||||
const router = networkGroupsRoutes({
|
||||
groupsService,
|
||||
peopleStore,
|
||||
accessStore,
|
||||
asyncHandler,
|
||||
log: { info: () => {}, warn: () => {}, error: () => {} },
|
||||
});
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/network/groups', 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, dir, peopleStore, accessStore, groupsService };
|
||||
}
|
||||
|
||||
function _cleanup(dir) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
|
||||
describe('network-groups routes: contract', () => {
|
||||
let dir, app, peopleStore;
|
||||
beforeEach(() => {
|
||||
({ app, dir, peopleStore } = createApp());
|
||||
});
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('GET / lists groups, Admin always present', async () => {
|
||||
const res = await request(app).get('/network/groups');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(Array.isArray(res.body.data)).toBe(true);
|
||||
const ids = res.body.data.map(g => g.id);
|
||||
expect(ids).toContain('Admin');
|
||||
expect(res.body.count).toBe(ids.length);
|
||||
});
|
||||
|
||||
test('GET / derives groups referenced from person.groups[]', async () => {
|
||||
await peopleStore.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1', groups: ['family', 'Admin'] });
|
||||
const res = await request(app).get('/network/groups');
|
||||
const ids = res.body.data.map(g => g.id);
|
||||
expect(ids).toContain('family');
|
||||
const family = res.body.data.find(g => g.id === 'family');
|
||||
expect(family.memberCount).toBe(1);
|
||||
});
|
||||
|
||||
test('POST / creates a group', async () => {
|
||||
const res = await request(app).post('/network/groups').send({ id: 'family', name: 'Family' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.group).toMatchObject({ id: 'family', name: 'Family', memberCount: 0 });
|
||||
});
|
||||
|
||||
test('POST / rejects invalid slugs (DashCaddy-local ids only)', async () => {
|
||||
for (const bad of ['Family', 'has space', 'UPPER', 'under_score', '', 'a/b']) {
|
||||
const res = await request(app).post('/network/groups').send({ id: bad, name: 'X' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('POST / rejects missing/blank name', async () => {
|
||||
const res = await request(app).post('/network/groups').send({ id: 'family', name: ' ' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('POST / duplicate id → 409 (including Admin)', async () => {
|
||||
await request(app).post('/network/groups').send({ id: 'family', name: 'Family' });
|
||||
const dup = await request(app).post('/network/groups').send({ id: 'family', name: 'Other' });
|
||||
expect(dup.status).toBe(409);
|
||||
const admin = await request(app).post('/network/groups').send({ id: 'Admin', name: 'Admin2' });
|
||||
expect(admin.status).toBe(409);
|
||||
});
|
||||
|
||||
test('PATCH /:id renames display name', async () => {
|
||||
await request(app).post('/network/groups').send({ id: 'family', name: 'Family' });
|
||||
const res = await request(app).patch('/network/groups/family').send({ name: 'Fam' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.group.name).toBe('Fam');
|
||||
expect(res.body.group.id).toBe('family');
|
||||
});
|
||||
|
||||
test('PATCH /:id unknown group → 404', async () => {
|
||||
const res = await request(app).patch('/network/groups/nope').send({ name: 'X' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
test('DELETE /:id deletes an empty group', async () => {
|
||||
await request(app).post('/network/groups').send({ id: 'family', name: 'Family' });
|
||||
const res = await request(app).delete('/network/groups/family');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.deleted).toBe(true);
|
||||
const after = await request(app).get('/network/groups');
|
||||
expect(after.body.data.map(g => g.id)).not.toContain('family');
|
||||
});
|
||||
|
||||
test('DELETE /:id unknown → 404', async () => {
|
||||
const res = await request(app).delete('/network/groups/nope');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-groups routes: Admin protection', () => {
|
||||
let dir, app;
|
||||
beforeEach(() => {
|
||||
({ app, dir } = createApp());
|
||||
});
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('DELETE /Admin → 409', async () => {
|
||||
const res = await request(app).delete('/network/groups/Admin');
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
test('PATCH /Admin → 409', async () => {
|
||||
const res = await request(app).patch('/network/groups/Admin').send({ name: 'Root' });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-groups routes: cascades', () => {
|
||||
let dir, app, peopleStore, accessStore;
|
||||
beforeEach(() => {
|
||||
({ app, dir, peopleStore, accessStore } = createApp());
|
||||
});
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('DELETE /:id removes the group from person.groups[] and grants', async () => {
|
||||
await peopleStore.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1', groups: ['family'] });
|
||||
await peopleStore.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2', groups: ['family', 'friends'] });
|
||||
await accessStore.setGrant({ type: 'group', id: 'family' }, 'plex', true);
|
||||
await accessStore.setGrant({ type: 'group', id: 'friends' }, 'plex', true); // must survive
|
||||
|
||||
const res = await request(app).delete('/network/groups/family');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ deleted: true, id: 'family', removedFromPeople: 2, removedGrants: 1 });
|
||||
|
||||
const people = await peopleStore.listPeople();
|
||||
for (const p of people) expect(p.groups).not.toContain('family');
|
||||
const bob = people.find(p => p.displayName === 'Bob');
|
||||
expect(bob.groups).toEqual(['friends']);
|
||||
|
||||
const map = await accessStore.getAccessMap();
|
||||
const refs = map.grants.filter(g => g.principal.type === 'group' && g.principal.id === 'family');
|
||||
expect(refs).toHaveLength(0);
|
||||
expect(map.grants.some(g => g.principal.id === 'friends')).toBe(true);
|
||||
});
|
||||
|
||||
test('PATCH /:id with new id re-points grant references and person.groups[]', async () => {
|
||||
await peopleStore.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1', groups: ['family'] });
|
||||
await accessStore.setGrant({ type: 'group', id: 'family' }, 'plex', true);
|
||||
await accessStore.setGrant({ type: 'person', id: 'someone' }, 'plex', true); // untouched
|
||||
|
||||
const res = await request(app).patch('/network/groups/family').send({ id: 'household' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.group.id).toBe('household');
|
||||
expect(res.body.group.peopleUpdated).toBe(1);
|
||||
expect(res.body.group.grantsUpdated).toBe(1);
|
||||
|
||||
const people = await peopleStore.listPeople();
|
||||
expect(people[0].groups).toEqual(['household']);
|
||||
|
||||
const map = await accessStore.getAccessMap();
|
||||
expect(map.grants.some(g => g.principal.type === 'group' && g.principal.id === 'household')).toBe(true);
|
||||
expect(map.grants.some(g => g.principal.type === 'group' && g.principal.id === 'family')).toBe(false);
|
||||
expect(map.grants.some(g => g.principal.type === 'person' && g.principal.id === 'someone')).toBe(true);
|
||||
});
|
||||
|
||||
test('PATCH /:id refuses renaming onto an existing id (409)', async () => {
|
||||
await peopleStore.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1', groups: ['family'] });
|
||||
await peopleStore.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2', groups: ['friends'] });
|
||||
const res = await request(app).patch('/network/groups/family').send({ id: 'friends' });
|
||||
expect(res.status).toBe(409);
|
||||
// and nothing moved
|
||||
const people = await peopleStore.listPeople();
|
||||
expect(people.find(p => p.displayName === 'Alice').groups).toEqual(['family']);
|
||||
});
|
||||
|
||||
test('groups never leak into Tailscale ACL semantics: ids stay DashCaddy-local strings', async () => {
|
||||
// The slug gate is the enforcement point — any id that could be
|
||||
// interpreted as a foreign ACL tag/group reference (colons, emails,
|
||||
// tag:prefixes) is rejected at create AND rename.
|
||||
const badCreate = await request(app).post('/network/groups').send({ id: 'tag:server', name: 'X' });
|
||||
expect(badCreate.status).toBe(400);
|
||||
await request(app).post('/network/groups').send({ id: 'family', name: 'Family' });
|
||||
const badRename = await request(app).patch('/network/groups/family').send({ id: 'user@corp' });
|
||||
expect(badRename.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Route tests for network-invites routes (DC-142).
|
||||
* Coverage:
|
||||
* - GET / lists invites via provider; empty list when provider lacks
|
||||
* listInvites (capability degradation)
|
||||
* - POST / without sendEmail → deliveredVia 'manual' (default NO email)
|
||||
* - POST / with sendEmail:true + emailSent → deliveredVia 'email'
|
||||
* - POST / provider email failure (emailSent:false or email_send_failed
|
||||
* with link on the error) → deliveredVia 'failed', link still present
|
||||
* - DELETE /:id → 204; not_found maps to 404; bad_input maps to 400
|
||||
* - 503 + code provider_not_active when no provider is connected
|
||||
* - no raw token / 'uinv/' ever reaches the log mock (redaction)
|
||||
* Hermetic — supertest against an in-process express app, mocked provider,
|
||||
* no network.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/* eslint-disable require-await -- provider mocks resolve settled values on
|
||||
purpose; the route under test does the awaiting. */
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const networkInvitesRoutes = require('../routes/network-invites');
|
||||
const { ProviderError, ProviderNotActiveError } = require('../src/network/errors');
|
||||
|
||||
const INVITE_URL = 'https://login.tailscale.com/uinv/abc123secretcode';
|
||||
|
||||
function asyncHandler(fn) {
|
||||
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
}
|
||||
|
||||
/** Capturing logger — every call is recorded so tests can grep for leaks. */
|
||||
function captureLog() {
|
||||
const calls = [];
|
||||
const mk = (level) => (...args) => calls.push({ level, args });
|
||||
return { info: mk('info'), warn: mk('warn'), error: mk('error'), calls };
|
||||
}
|
||||
|
||||
function makeProvider(overrides = {}) {
|
||||
const provider = {
|
||||
id: () => 'mock',
|
||||
listInvites: async () => [{ id: 'inv-1', inviteUrl: INVITE_URL }],
|
||||
createInvite: async () => await Promise.resolve({ inviteUrl: INVITE_URL, id: 'inv-2' }),
|
||||
revokeInvite: async () => ({ revoked: true }),
|
||||
...overrides,
|
||||
};
|
||||
return provider;
|
||||
}
|
||||
|
||||
function createApp({ provider, registry } = {}) {
|
||||
const log = captureLog();
|
||||
const networkRegistry = registry || {
|
||||
getActiveProvider: provider
|
||||
? () => provider
|
||||
: () => { throw new ProviderNotActiveError(); },
|
||||
};
|
||||
const router = networkInvitesRoutes({ networkRegistry, asyncHandler, log });
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/network/invites', 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, log };
|
||||
}
|
||||
|
||||
describe('network-invites routes: contract', () => {
|
||||
test('GET / lists active invites via provider', async () => {
|
||||
const { app } = createApp({ provider: makeProvider() });
|
||||
const res = await request(app).get('/network/invites');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.count).toBe(1);
|
||||
expect(res.body.data[0].id).toBe('inv-1');
|
||||
expect(res.body.supported).toBe(true);
|
||||
});
|
||||
|
||||
test('GET / returns empty list when provider lacks listInvites', async () => {
|
||||
const provider = makeProvider();
|
||||
delete provider.listInvites;
|
||||
const { app } = createApp({ provider });
|
||||
const res = await request(app).get('/network/invites');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.count).toBe(0);
|
||||
expect(res.body.data).toEqual([]);
|
||||
expect(res.body.supported).toBe(false);
|
||||
});
|
||||
|
||||
test('POST / without sendEmail creates link-only invite (deliveredVia manual)', async () => {
|
||||
const createInvite = jest.fn(async ({ email }) => {
|
||||
expect(email).toBeUndefined(); // link-first: no email key at all
|
||||
return { inviteUrl: INVITE_URL, id: 'inv-2' };
|
||||
});
|
||||
const { app } = createApp({ provider: makeProvider({ createInvite }) });
|
||||
const res = await request(app)
|
||||
.post('/network/invites')
|
||||
.send({ displayName: 'Sami', role: 'member' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.inviteUrl).toBe(INVITE_URL);
|
||||
expect(res.body.deliveredVia).toBe('manual');
|
||||
expect(createInvite).toHaveBeenCalledTimes(1);
|
||||
expect(createInvite).toHaveBeenCalledWith({ role: 'member' });
|
||||
});
|
||||
|
||||
test('POST / response always includes shareText — a human sentence containing the link', async () => {
|
||||
const { app } = createApp({ provider: makeProvider() });
|
||||
const res = await request(app)
|
||||
.post('/network/invites')
|
||||
.send({ displayName: 'Sami', role: 'member' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(typeof res.body.shareText).toBe('string');
|
||||
expect(res.body.shareText).toContain('Sami invited you to join the family network');
|
||||
expect(res.body.shareText).toContain(INVITE_URL);
|
||||
// A sentence, not a JSON blob:
|
||||
expect(res.body.shareText.startsWith('{')).toBe(false);
|
||||
});
|
||||
|
||||
test('POST / rejects invalid bodies (400)', async () => {
|
||||
const { app } = createApp({ provider: makeProvider() });
|
||||
const noName = await request(app).post('/network/invites').send({ role: 'member' });
|
||||
expect(noName.status).toBe(400);
|
||||
expect(noName.body.error).toMatch(/displayName/);
|
||||
|
||||
const badRole = await request(app)
|
||||
.post('/network/invites')
|
||||
.send({ displayName: 'Sami', role: 'superadmin' });
|
||||
expect(badRole.status).toBe(400);
|
||||
expect(badRole.body.error).toMatch(/role/);
|
||||
|
||||
const badEmail = await request(app)
|
||||
.post('/network/invites')
|
||||
.send({ displayName: 'Sami', role: 'member', email: 'not-an-email' });
|
||||
expect(badEmail.status).toBe(400);
|
||||
expect(badEmail.body.error).toMatch(/email/);
|
||||
|
||||
const sendNoAddr = await request(app)
|
||||
.post('/network/invites')
|
||||
.send({ displayName: 'Sami', role: 'member', sendEmail: true });
|
||||
expect(sendNoAddr.status).toBe(400);
|
||||
expect(sendNoAddr.body.error).toMatch(/sendEmail/);
|
||||
});
|
||||
|
||||
test('sendEmail is strict opt-in: truthy non-true values do not trigger email', async () => {
|
||||
const createInvite = jest.fn(async ({ email }) => {
|
||||
if (email) return { inviteUrl: INVITE_URL, id: 'inv-e', emailSent: true };
|
||||
return { inviteUrl: INVITE_URL, id: 'inv-m' };
|
||||
});
|
||||
const { app } = createApp({ provider: makeProvider({ createInvite }) });
|
||||
const res = await request(app)
|
||||
.post('/network/invites')
|
||||
.send({ displayName: 'Sami', role: 'member', email: 'x@y.io', sendEmail: 'yes' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.deliveredVia).toBe('manual');
|
||||
expect(createInvite).toHaveBeenCalledWith({ role: 'member' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-invites routes: email leg', () => {
|
||||
test('POST / with sendEmail:true and provider-confirmed send → deliveredVia email', async () => {
|
||||
const createInvite = jest.fn(async () => ({
|
||||
inviteUrl: INVITE_URL,
|
||||
id: 'inv-3',
|
||||
emailSent: true,
|
||||
}));
|
||||
const { app } = createApp({ provider: makeProvider({ createInvite }) });
|
||||
const res = await request(app)
|
||||
.post('/network/invites')
|
||||
.send({ displayName: 'Sami', role: 'admin', email: 'friend@example.com', sendEmail: true });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.deliveredVia).toBe('email');
|
||||
expect(res.body.inviteUrl).toBe(INVITE_URL);
|
||||
expect(createInvite).toHaveBeenCalledWith({ role: 'admin', email: 'friend@example.com' });
|
||||
});
|
||||
|
||||
test('provider does not confirm send (emailSent false) → deliveredVia failed, link present', async () => {
|
||||
const createInvite = jest.fn(async () => await Promise.resolve({ inviteUrl: INVITE_URL, id: 'inv-4' }));
|
||||
const { app } = createApp({ provider: makeProvider({ createInvite }) });
|
||||
const res = await request(app)
|
||||
.post('/network/invites')
|
||||
.send({ displayName: 'Sami', role: 'member', email: 'f@example.com', sendEmail: true });
|
||||
expect(res.status).toBe(201); // not a total failure — link still returned
|
||||
expect(res.body.deliveredVia).toBe('failed');
|
||||
expect(res.body.inviteUrl).toBe(INVITE_URL);
|
||||
expect(res.body.shareText).toContain(INVITE_URL);
|
||||
});
|
||||
|
||||
test('provider throws email_send_failed with link on the error → deliveredVia failed, link present', async () => {
|
||||
const err = new ProviderError('provider email send failed', {
|
||||
code: 'email_send_failed',
|
||||
});
|
||||
err.inviteUrl = INVITE_URL; // provider minted the invite, email leg failed
|
||||
const createInvite = jest.fn(async () => { throw err; });
|
||||
const { app } = createApp({ provider: makeProvider({ createInvite }) });
|
||||
const res = await request(app)
|
||||
.post('/network/invites')
|
||||
.send({ displayName: 'Sami', role: 'member', email: 'f@example.com', sendEmail: true });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.deliveredVia).toBe('failed');
|
||||
expect(res.body.inviteUrl).toBe(INVITE_URL);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-invites routes: revoke', () => {
|
||||
test('DELETE /:id returns 204 on success', async () => {
|
||||
const revokeInvite = jest.fn(async (id) => {
|
||||
expect(id).toBe('inv-9');
|
||||
return { revoked: true };
|
||||
});
|
||||
const { app } = createApp({ provider: makeProvider({ revokeInvite }) });
|
||||
const res = await request(app).delete('/network/invites/inv-9');
|
||||
expect(res.status).toBe(204);
|
||||
expect(revokeInvite).toHaveBeenCalledWith('inv-9');
|
||||
});
|
||||
|
||||
test('DELETE maps provider not_found → 404', async () => {
|
||||
const revokeInvite = async () => {
|
||||
throw new ProviderError('invite not found', { code: 'not_found' });
|
||||
};
|
||||
const { app } = createApp({ provider: makeProvider({ revokeInvite }) });
|
||||
const res = await request(app).delete('/network/invites/ghost');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
test('DELETE maps provider bad_input → 400', async () => {
|
||||
const revokeInvite = async () => {
|
||||
throw new ProviderError('revokeInvite: id is required', { code: 'bad_input' });
|
||||
};
|
||||
const { app } = createApp({ provider: makeProvider({ revokeInvite }) });
|
||||
const res = await request(app).delete('/network/invites/x');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
test('DELETE maps upstream 5xx ProviderError → 502', async () => {
|
||||
const revokeInvite = async () => {
|
||||
throw new ProviderError('upstream unavailable', { code: 'server_error' });
|
||||
};
|
||||
const { app } = createApp({ provider: makeProvider({ revokeInvite }) });
|
||||
const res = await request(app).delete('/network/invites/x');
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-invites routes: provider inactive', () => {
|
||||
[['GET', '/network/invites', null],
|
||||
['POST', '/network/invites', { displayName: 'Sami', role: 'member' }],
|
||||
['DELETE', '/network/invites/inv-1', null]].forEach(([method, url, body]) => {
|
||||
test(`${method} returns 503 provider_not_active when no provider connected`, async () => {
|
||||
const { app } = createApp({}); // registry throws ProviderNotActiveError
|
||||
const req = request(app)[method.toLowerCase()](url);
|
||||
if (body) req.send(body);
|
||||
const res = await req;
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.success).toBe(false);
|
||||
expect(res.body.code).toBe('provider_not_active');
|
||||
});
|
||||
});
|
||||
|
||||
test('missing registry entirely also yields 503', async () => {
|
||||
const { app } = createApp({ registry: {} }); // no getActiveProvider fn
|
||||
const res = await request(app).get('/network/invites');
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.code).toBe('provider_not_active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-invites routes: no raw tokens in logs', () => {
|
||||
function assertNoLeak(log) {
|
||||
expect(log.calls.length).toBeGreaterThan(0); // logging happened…
|
||||
for (const { args } of log.calls) {
|
||||
for (const arg of args) {
|
||||
if (typeof arg !== 'string') continue;
|
||||
expect(arg).not.toContain('uinv/'); // …but never the raw link
|
||||
expect(arg).not.toContain('abc123secretcode');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('create (manual) never logs the raw invite URL', async () => {
|
||||
const { app, log } = createApp({ provider: makeProvider() });
|
||||
const res = await request(app)
|
||||
.post('/network/invites')
|
||||
.send({ displayName: 'Sami', role: 'member' });
|
||||
expect(res.status).toBe(201);
|
||||
assertNoLeak(log);
|
||||
});
|
||||
|
||||
test('create (email failed) never logs the raw invite URL', async () => {
|
||||
const createInvite = async () => await Promise.resolve({ inviteUrl: INVITE_URL, id: 'inv-f' });
|
||||
const { app, log } = createApp({ provider: makeProvider({ createInvite }) });
|
||||
const res = await request(app)
|
||||
.post('/network/invites')
|
||||
.send({ displayName: 'Sami', role: 'member', email: 'f@example.com', sendEmail: true });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.deliveredVia).toBe('failed');
|
||||
assertNoLeak(log); // warn fired — but with redacted fields only
|
||||
});
|
||||
|
||||
test('redaction helper replaces link-bearing field values with [REDACTED]', async () => {
|
||||
const { app, log } = createApp({ provider: makeProvider() });
|
||||
await request(app).post('/network/invites').send({ displayName: 'Sami', role: 'member' });
|
||||
const createCall = log.calls.find((c) => c.args[1] === 'invite created');
|
||||
expect(createCall).toBeTruthy();
|
||||
const fields = createCall.args[2];
|
||||
expect(fields.inviteUrl).toBeUndefined();
|
||||
expect(JSON.stringify(fields)).not.toContain('uinv/');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 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']);
|
||||
});
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Tests for network-people-store (DC-141).
|
||||
* Coverage:
|
||||
* - CRUD: upsert/get/update/delete/list round-trips
|
||||
* - atomicity: tmp+rename writes (no torn file), cache coherence
|
||||
* - first-person-admin rule: first person gets groups ['Admin'] +
|
||||
* protectedAt; later people do not
|
||||
* - last-admin guard: single active admin throws; passes when a second
|
||||
* active admin exists; throws again when the other admins are blocked
|
||||
* - findByProviderUserId match + miss
|
||||
* - corruption recovery: bad JSON restarts empty, .bak written
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const {
|
||||
createPeopleStore,
|
||||
LastAdminError,
|
||||
ADMIN_GROUP,
|
||||
} = require('../src/security/network-people-store');
|
||||
|
||||
function _tmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-peopletest-'));
|
||||
}
|
||||
|
||||
function _cleanup(dir) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
|
||||
function _admin(name, opts = {}) {
|
||||
return {
|
||||
displayName: name,
|
||||
providerUserId: opts.puid || `puid-${name}`,
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
||||
describe('network-people-store: CRUD', () => {
|
||||
let dir, store;
|
||||
beforeEach(() => { dir = _tmpDir(); store = createPeopleStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('empty store lists zero people', async () => {
|
||||
const list = await store.listPeople();
|
||||
expect(list).toEqual([]);
|
||||
});
|
||||
|
||||
test('upsert creates a person with defaults', async () => {
|
||||
// Seed a bootstrap admin first so the subject is a NON-first person
|
||||
// (first-person behavior is covered in its own describe block).
|
||||
await store.upsertPerson({ displayName: 'Owner', providerUserId: 'ts-0' });
|
||||
const p = await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' });
|
||||
expect(p.id).toBeTruthy();
|
||||
expect(p.displayName).toBe('Alice');
|
||||
expect(p.providerUserId).toBe('ts-1');
|
||||
expect(Array.isArray(p.groups)).toBe(true);
|
||||
expect(Array.isArray(p.devices)).toBe(true);
|
||||
expect(p.status).toBe('active');
|
||||
expect(p.createdAt).toBeTruthy();
|
||||
expect(p.protectedAt).toBe(null);
|
||||
});
|
||||
|
||||
test('getPerson returns stored person; null for unknown id', async () => {
|
||||
const p = await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' });
|
||||
const got = await store.getPerson(p.id);
|
||||
expect(got.id).toBe(p.id);
|
||||
expect(await store.getPerson('nope')).toBe(null);
|
||||
});
|
||||
|
||||
test('upsertPerson merges by id (update path)', async () => {
|
||||
const p = await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' });
|
||||
const p2 = await store.upsertPerson({ id: p.id, displayName: 'Alice II', groups: ['Friends'] });
|
||||
expect(p2.id).toBe(p.id);
|
||||
expect(p2.displayName).toBe('Alice II');
|
||||
expect(p2.providerUserId).toBe('ts-1'); // retained
|
||||
expect(p2.createdAt).toBe(p.createdAt); // retained
|
||||
expect(p2.groups).toEqual(['Friends']);
|
||||
const list = await store.listPeople();
|
||||
expect(list).toHaveLength(1); // merged, not duplicated
|
||||
});
|
||||
|
||||
test('updatePerson patches only provided fields', async () => {
|
||||
const p = await store.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2', groups: ['G'] });
|
||||
const u = await store.updatePerson(p.id, { displayName: 'Bobby' });
|
||||
expect(u.displayName).toBe('Bobby');
|
||||
expect(u.groups).toEqual(['G']); // untouched by patch
|
||||
expect(await store.updatePerson('nope', { displayName: 'x' })).toBe(null);
|
||||
});
|
||||
|
||||
test('updatePerson cannot change id', async () => {
|
||||
const p = await store.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2' });
|
||||
const u = await store.updatePerson(p.id, { id: 'evil', displayName: 'B' });
|
||||
expect(u.id).toBe(p.id);
|
||||
const list = await store.listPeople();
|
||||
expect(list).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('deletePerson removes; false for unknown id', async () => {
|
||||
const p = await store.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2' });
|
||||
expect(await store.deletePerson(p.id)).toBe(true);
|
||||
expect(await store.getPerson(p.id)).toBe(null);
|
||||
expect(await store.deletePerson(p.id)).toBe(false);
|
||||
});
|
||||
|
||||
test('returned persons are copies (mutating them does not corrupt store)', async () => {
|
||||
await store.upsertPerson({ displayName: 'Owner', providerUserId: 'ts-0' }); // bootstrap admin
|
||||
const p = await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' });
|
||||
p.groups.push('Hacked');
|
||||
const fresh = await store.getPerson(p.id);
|
||||
expect(fresh.groups).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-people-store: persistence + atomicity', () => {
|
||||
let dir, store;
|
||||
beforeEach(() => { dir = _tmpDir(); store = createPeopleStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('people survive a new store instance (round-trip through disk)', async () => {
|
||||
const p = await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' });
|
||||
const store2 = createPeopleStore({ dataDir: dir });
|
||||
const got = await store2.getPerson(p.id);
|
||||
expect(got.displayName).toBe('Alice');
|
||||
expect(got.groups).toEqual(p.groups);
|
||||
});
|
||||
|
||||
test('write is tmp+rename: no leftover tmp files after save', async () => {
|
||||
await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' });
|
||||
const files = fs.readdirSync(dir).filter(f => f.includes('.tmp-'));
|
||||
expect(files).toEqual([]);
|
||||
expect(fs.existsSync(path.join(dir, 'network-people.json'))).toBe(true);
|
||||
});
|
||||
|
||||
test('file contents are complete JSON (never torn)', async () => {
|
||||
await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' });
|
||||
await store.upsertPerson({ displayName: 'Bob', providerUserId: 'ts-2' });
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'network-people.json'), 'utf8'));
|
||||
expect(Object.keys(raw.people)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('rapid sequential writes are all durable (mutex serialization)', async () => {
|
||||
const names = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'];
|
||||
await Promise.all(names.map(n => store.upsertPerson({ displayName: n, providerUserId: `p-${n}` })));
|
||||
const list = await store.listPeople();
|
||||
expect(list).toHaveLength(names.length);
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'network-people.json'), 'utf8'));
|
||||
expect(Object.keys(raw.people)).toHaveLength(names.length);
|
||||
});
|
||||
|
||||
test('corrupt file: store recovers empty and writes .bak', async () => {
|
||||
await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1' });
|
||||
fs.writeFileSync(path.join(dir, 'network-people.json'), '{corrupted!!');
|
||||
const store2 = createPeopleStore({ dataDir: dir });
|
||||
const list = await store2.listPeople();
|
||||
expect(list).toEqual([]);
|
||||
expect(fs.existsSync(path.join(dir, 'network-people.json.bak'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-people-store: first-person-admin rule', () => {
|
||||
let dir, store;
|
||||
beforeEach(() => { dir = _tmpDir(); store = createPeopleStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('first person created gets Admin group + protectedAt', async () => {
|
||||
const first = await store.upsertPerson({ displayName: 'Sami', providerUserId: 'ts-owner' });
|
||||
expect(first.groups).toEqual([ADMIN_GROUP]);
|
||||
expect(first.protectedAt).toBeTruthy();
|
||||
expect(new Date(first.protectedAt).getTime()).not.toBeNaN();
|
||||
});
|
||||
|
||||
test('second person created does NOT get Admin/protectedAt', async () => {
|
||||
await store.upsertPerson({ displayName: 'Sami', providerUserId: 'ts-owner' });
|
||||
const second = await store.upsertPerson({ displayName: 'Friend', providerUserId: 'ts-2' });
|
||||
expect(second.groups).toEqual([]);
|
||||
expect(second.protectedAt).toBe(null);
|
||||
});
|
||||
|
||||
test('rule applies only to a truly empty store (delete-then-create does not re-trigger)', async () => {
|
||||
const first = await store.upsertPerson({ displayName: 'Sami', providerUserId: 'ts-owner' });
|
||||
const second = await store.upsertPerson({ displayName: 'Friend', providerUserId: 'ts-2' });
|
||||
await store.deletePerson(first.id);
|
||||
const third = await store.upsertPerson({ displayName: 'Late', providerUserId: 'ts-3' });
|
||||
expect(second.groups).toEqual([]);
|
||||
expect(third.groups).toEqual([]);
|
||||
expect(third.protectedAt).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-people-store: last-admin guard', () => {
|
||||
let dir, store;
|
||||
beforeEach(() => { dir = _tmpDir(); store = createPeopleStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('throws LastAdminError for the only active admin', async () => {
|
||||
const solo = await store.upsertPerson(_admin('Sami')); // bootstrap admin
|
||||
await expect(store.assertNotLastAdmin(solo.id)).rejects.toBeInstanceOf(LastAdminError);
|
||||
});
|
||||
|
||||
test('passes when a second active admin exists', async () => {
|
||||
const a = await store.upsertPerson(_admin('Sami'));
|
||||
const b = await store.upsertPerson(_admin('Deputy'));
|
||||
await store.updatePerson(b.id, { groups: [ADMIN_GROUP] });
|
||||
await expect(store.assertNotLastAdmin(a.id)).resolves.toBeUndefined();
|
||||
await expect(store.assertNotLastAdmin(b.id)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('throws when the other admins are blocked (only one active left)', async () => {
|
||||
const a = await store.upsertPerson(_admin('Sami'));
|
||||
const b = await store.upsertPerson(_admin('Deputy'));
|
||||
await store.updatePerson(b.id, { groups: [ADMIN_GROUP] });
|
||||
// Block every admin except a.
|
||||
await store.updatePerson(b.id, { status: 'blocked' });
|
||||
await expect(store.assertNotLastAdmin(a.id)).rejects.toBeInstanceOf(LastAdminError);
|
||||
// The blocked admin himself is not protected by the guard (he's not
|
||||
// an ACTIVE admin of consequence — but he IS still the last *listed*
|
||||
// admin member; the guard protects active access, so blocking b again
|
||||
// is fine).
|
||||
await expect(store.assertNotLastAdmin(b.id)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('removed admins also do not count as active', async () => {
|
||||
const a = await store.upsertPerson(_admin('Sami'));
|
||||
const b = await store.upsertPerson(_admin('Deputy'));
|
||||
await store.updatePerson(b.id, { groups: [ADMIN_GROUP] });
|
||||
await store.updatePerson(b.id, { status: 'removed' });
|
||||
await expect(store.assertNotLastAdmin(a.id)).rejects.toBeInstanceOf(LastAdminError);
|
||||
});
|
||||
|
||||
test('non-admins pass freely', async () => {
|
||||
await store.upsertPerson(_admin('Sami'));
|
||||
const user = await store.upsertPerson({ displayName: 'Pleb', providerUserId: 'ts-pleb' });
|
||||
await expect(store.assertNotLastAdmin(user.id)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('unknown ids pass (nothing to protect)', async () => {
|
||||
await expect(store.assertNotLastAdmin('ghost')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('LastAdminError carries code + personId', async () => {
|
||||
const solo = await store.upsertPerson(_admin('Sami'));
|
||||
try {
|
||||
await store.assertNotLastAdmin(solo.id);
|
||||
throw new Error('should have thrown');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(LastAdminError);
|
||||
expect(err.code).toBe('DC_LAST_ADMIN');
|
||||
expect(err.personId).toBe(solo.id);
|
||||
expect(err.statusCode).toBe(409);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-people-store: findByProviderUserId', () => {
|
||||
let dir, store;
|
||||
beforeEach(() => { dir = _tmpDir(); store = createPeopleStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('finds the person with that provider identity', async () => {
|
||||
await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1234' });
|
||||
const found = await store.findByProviderUserId('ts-1234');
|
||||
expect(found).toBeTruthy();
|
||||
expect(found.displayName).toBe('Alice');
|
||||
});
|
||||
|
||||
test('returns null on miss and for empty/invalid input', async () => {
|
||||
await store.upsertPerson({ displayName: 'Alice', providerUserId: 'ts-1234' });
|
||||
expect(await store.findByProviderUserId('ts-9999')).toBe(null);
|
||||
expect(await store.findByProviderUserId('')).toBe(null);
|
||||
expect(await store.findByProviderUserId(null)).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,613 @@
|
||||
/**
|
||||
* Tests for src/network/providers/tailscale.js (DC-140).
|
||||
*
|
||||
* Hermetic: the coordination client is a jest.fn() stub object, the local
|
||||
* manager is a jest.mock of ../managers/tailscale-manager (same rigor as
|
||||
* __tests__/tailscale-manager.test.js), and the whois execFile call is a
|
||||
* plain stub — no network, no child processes.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
jest.mock('../src/managers/tailscale-manager', () => ({
|
||||
getSummary: jest.fn(),
|
||||
getStatus: jest.fn(),
|
||||
}));
|
||||
|
||||
const tailscaleManager = require('../src/managers/tailscale-manager');
|
||||
const { createTailscaleProvider, toProviderError } = require('../src/network/providers/tailscale');
|
||||
const { ProviderError } = require('../src/network/errors');
|
||||
const { TailscaleCoordError } = require('../src/managers/tailscale-coord');
|
||||
|
||||
/** Coord client stub factory: every method is a jest.fn resolving []. */
|
||||
function makeCoord(overrides = {}) {
|
||||
return {
|
||||
listDevices: jest.fn(async () => []),
|
||||
listUsers: jest.fn(async () => []),
|
||||
deleteDevice: jest.fn(async () => ({ success: true })),
|
||||
request: jest.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function devicesFixture() {
|
||||
return [
|
||||
{ id: 'dev1', name: 'laptop.tail3e209.ts.net', addresses: ['100.91.55.51'], user: '1001', os: 'linux' },
|
||||
{ id: 'dev2', name: 'phone.tail3e209.ts.net', addresses: ['100.92.60.10'], user: '1001', os: 'android' },
|
||||
{ id: 'dev3', name: 'nas.tail3e209.ts.net', addresses: ['100.93.7.7'], user: '2002', os: 'linux' },
|
||||
];
|
||||
}
|
||||
|
||||
/** execFile stub (promise-style, matching promisify(execFile)) with canned stdout. */
|
||||
function makeExec(stdoutByIp = {}, { failAll = false } = {}) {
|
||||
const calls = [];
|
||||
const fn = (bin, args) => {
|
||||
calls.push({ bin, args });
|
||||
const ip = args && args[1];
|
||||
if (failAll) return Promise.reject(new Error('tailscale CLI not available'));
|
||||
return Promise.resolve({ stdout: stdoutByIp[ip] || '' });
|
||||
};
|
||||
fn.calls = calls;
|
||||
return fn;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
tailscaleManager.getSummary.mockResolvedValue({ installed: true, connected: true, peerCount: 1 });
|
||||
tailscaleManager.getStatus.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe('tailscale provider: id + capabilities', () => {
|
||||
test('id() returns "tailscale"', () => {
|
||||
expect(createTailscaleProvider().id()).toBe('tailscale');
|
||||
});
|
||||
|
||||
test('capabilities() flags invites + splitDnsApi, no perPeerFirewall, not selfHosted', () => {
|
||||
expect(createTailscaleProvider().capabilities()).toEqual({
|
||||
invites: true,
|
||||
splitDnsApi: true,
|
||||
perPeerFirewall: false,
|
||||
selfHosted: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailscale provider: getStatus()', () => {
|
||||
test('wraps the local manager summary with provider framing', async () => {
|
||||
tailscaleManager.getSummary.mockResolvedValue({ installed: true, connected: true, peerCount: 4 });
|
||||
const p = createTailscaleProvider({ localManager: tailscaleManager });
|
||||
const status = await p.getStatus();
|
||||
expect(status).toMatchObject({ provider: 'tailscale', installed: true, connected: true });
|
||||
expect(status.summary).toEqual({ installed: true, connected: true, peerCount: 4 });
|
||||
});
|
||||
|
||||
test('degrades to installed:false when the CLI is missing', async () => {
|
||||
tailscaleManager.getSummary.mockResolvedValue({ installed: false, connected: false });
|
||||
const p = createTailscaleProvider({ localManager: tailscaleManager });
|
||||
const status = await p.getStatus();
|
||||
expect(status.installed).toBe(false);
|
||||
expect(status.connected).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailscale provider: listDevices()', () => {
|
||||
test('merges API devices with local online state by tailnet IP', async () => {
|
||||
const coord = makeCoord({ listDevices: jest.fn(async () => devicesFixture()) });
|
||||
tailscaleManager.getStatus.mockResolvedValue({
|
||||
Peer: {
|
||||
'nodekey:laptop': { TailscaleIPs: ['100.91.55.51'], Online: true },
|
||||
},
|
||||
});
|
||||
const p = createTailscaleProvider({ coordClient: coord, localManager: tailscaleManager });
|
||||
const devices = await p.listDevices();
|
||||
expect(devices).toHaveLength(3);
|
||||
expect(devices.find((d) => d.id === 'dev1')).toMatchObject({ ip: '100.91.55.51', online: true, userId: '1001' });
|
||||
expect(devices.find((d) => d.id === 'dev3')).toMatchObject({ online: false });
|
||||
});
|
||||
|
||||
test('works when local status is unavailable (all online false, no throw)', async () => {
|
||||
const coord = makeCoord({ listDevices: jest.fn(async () => devicesFixture()) });
|
||||
tailscaleManager.getStatus.mockResolvedValue(null);
|
||||
const p = createTailscaleProvider({ coordClient: coord, localManager: tailscaleManager });
|
||||
const devices = await p.listDevices();
|
||||
expect(devices).toHaveLength(3);
|
||||
expect(devices.every((d) => d.online === false)).toBe(true);
|
||||
});
|
||||
|
||||
test('surfaces coord not-configured as ProviderError not_configured', async () => {
|
||||
const p = createTailscaleProvider(); // no coord client attached
|
||||
await expect(p.listDevices()).rejects.toMatchObject({
|
||||
name: 'ProviderError',
|
||||
code: 'not_configured',
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailscale provider: listPeople()', () => {
|
||||
test('projects API users to { id, displayName, email, createdAt }', async () => {
|
||||
const coord = makeCoord({
|
||||
listUsers: jest.fn(async () => [
|
||||
{ id: '1001', loginName: 'sami@example.com', displayName: 'Sami', created: '2026-01-01T00:00:00Z' },
|
||||
{ id: '2002', loginName: 'guest-user', created: '2026-02-02T00:00:00Z' },
|
||||
]),
|
||||
});
|
||||
const p = createTailscaleProvider({ coordClient: coord });
|
||||
const people = await p.listPeople();
|
||||
expect(people).toEqual([
|
||||
{ id: '1001', displayName: 'Sami', email: 'sami@example.com', createdAt: '2026-01-01T00:00:00Z' },
|
||||
{ id: '2002', displayName: 'guest-user', email: null, createdAt: '2026-02-02T00:00:00Z' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailscale provider: createInvite()', () => {
|
||||
test('with email: POSTs user-invites including the email field', async () => {
|
||||
const coord = makeCoord({
|
||||
request: jest.fn(async () => ({ id: 'inv1', inviteUrl: 'https://login.tailscale.com/a/abc123' })),
|
||||
});
|
||||
const p = createTailscaleProvider({ coordClient: coord });
|
||||
const invite = await p.createInvite({ role: 'member', email: 'friend@example.com' });
|
||||
expect(invite).toEqual({ inviteUrl: 'https://login.tailscale.com/a/abc123', id: 'inv1' });
|
||||
expect(coord.request).toHaveBeenCalledWith('POST', '/tailnet/-/user-invites', {
|
||||
body: { inviteTailnet: true, email: 'friend@example.com' },
|
||||
});
|
||||
});
|
||||
|
||||
test('without email: body omits the email field entirely (link-first)', async () => {
|
||||
const coord = makeCoord({
|
||||
request: jest.fn(async () => ({ id: 'inv2', inviteUrl: 'https://login.tailscale.com/a/nomail' })),
|
||||
});
|
||||
const p = createTailscaleProvider({ coordClient: coord });
|
||||
const invite = await p.createInvite({ role: 'member' });
|
||||
expect(invite.inviteUrl).toBe('https://login.tailscale.com/a/nomail');
|
||||
expect(coord.request).toHaveBeenCalledWith('POST', '/tailnet/-/user-invites', {
|
||||
body: { inviteTailnet: true },
|
||||
});
|
||||
});
|
||||
|
||||
test('missing inviteUrl in the response maps to a ProviderError', async () => {
|
||||
const coord = makeCoord({ request: jest.fn(async () => ({ id: 'inv3' })) });
|
||||
const p = createTailscaleProvider({ coordClient: coord });
|
||||
await expect(p.createInvite({ role: 'member' })).rejects.toMatchObject({
|
||||
name: 'ProviderError',
|
||||
code: 'provider_error',
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid email input is rejected as bad_input before any API call', async () => {
|
||||
const coord = makeCoord();
|
||||
const p = createTailscaleProvider({ coordClient: coord });
|
||||
await expect(p.createInvite({ role: 'member', email: 'not-an-email' })).rejects.toMatchObject({
|
||||
code: 'bad_input',
|
||||
});
|
||||
expect(coord.request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('missing role is rejected as bad_input', async () => {
|
||||
const p = createTailscaleProvider({ coordClient: makeCoord() });
|
||||
await expect(p.createInvite({})).rejects.toMatchObject({ code: 'bad_input' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailscale provider: revokeInvite()', () => {
|
||||
test('DELETEs the user-invite by id', async () => {
|
||||
const coord = makeCoord({ request: jest.fn(async () => ({})) });
|
||||
const p = createTailscaleProvider({ coordClient: coord });
|
||||
const res = await p.revokeInvite('inv1');
|
||||
expect(res).toEqual({ revoked: true });
|
||||
expect(coord.request).toHaveBeenCalledWith('DELETE', '/tailnet/-/user-invites/inv1');
|
||||
});
|
||||
|
||||
test('requires an id', async () => {
|
||||
const p = createTailscaleProvider({ coordClient: makeCoord() });
|
||||
await expect(p.revokeInvite()).rejects.toMatchObject({ code: 'bad_input' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailscale provider: blockPerson()', () => {
|
||||
test('expires the key of EVERY device owned by the user', async () => {
|
||||
const coord = makeCoord({ listDevices: jest.fn(async () => devicesFixture()) });
|
||||
const p = createTailscaleProvider({ coordClient: coord });
|
||||
const res = await p.blockPerson('1001');
|
||||
expect(res).toEqual({ expired: ['dev1', 'dev2'], count: 2 });
|
||||
const expireCalls = coord.request.mock.calls.filter((c) => c[1].endsWith('/expire'));
|
||||
expect(expireCalls.map((c) => c[1])).toEqual(['/device/dev1/expire', '/device/dev2/expire']);
|
||||
expireCalls.forEach((c) => expect(c[0]).toBe('POST'));
|
||||
// no expiry for other users' devices
|
||||
expect(coord.request.mock.calls.some((c) => c[1] === '/device/dev3/expire')).toBe(false);
|
||||
});
|
||||
|
||||
test('user with zero devices → empty result, no expire calls', async () => {
|
||||
const coord = makeCoord({ listDevices: jest.fn(async () => devicesFixture()) });
|
||||
const p = createTailscaleProvider({ coordClient: coord });
|
||||
const res = await p.blockPerson('9999');
|
||||
expect(res).toEqual({ expired: [], count: 0 });
|
||||
expect(coord.request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('matches userId numerically-or-stringly (API returns numbers)', async () => {
|
||||
const coord = makeCoord({
|
||||
listDevices: jest.fn(async () => devicesFixture().map((d) => ({ ...d, user: Number(d.user) }))),
|
||||
});
|
||||
const p = createTailscaleProvider({ coordClient: coord });
|
||||
const res = await p.blockPerson('1001');
|
||||
expect(res.count).toBe(2);
|
||||
});
|
||||
|
||||
test('requires a userId', async () => {
|
||||
const p = createTailscaleProvider({ coordClient: makeCoord() });
|
||||
await expect(p.blockPerson()).rejects.toMatchObject({ code: 'bad_input' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailscale provider: unblockPerson()', () => {
|
||||
test('is honest (corrected spec): re-allow at ACL level only, devices need re-auth', async () => {
|
||||
const coord = makeCoord();
|
||||
const p = createTailscaleProvider({ coordClient: coord });
|
||||
await expect(p.unblockPerson('1001')).resolves.toEqual({ restored: true, reauthRequired: true });
|
||||
// No device-key restore call exists — nothing may touch the coord API here.
|
||||
expect(coord.request).not.toHaveBeenCalled();
|
||||
expect(coord.listDevices).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailscale provider: removePerson()', () => {
|
||||
test('DELETEs every device owned by the user', async () => {
|
||||
const coord = makeCoord({ listDevices: jest.fn(async () => devicesFixture()) });
|
||||
const p = createTailscaleProvider({ coordClient: coord });
|
||||
const res = await p.removePerson('1001');
|
||||
expect(res).toEqual({ deleted: ['dev1', 'dev2'], count: 2 });
|
||||
expect(coord.deleteDevice).toHaveBeenCalledTimes(2);
|
||||
expect(coord.deleteDevice).toHaveBeenCalledWith('dev1');
|
||||
expect(coord.deleteDevice).toHaveBeenCalledWith('dev2');
|
||||
expect(coord.deleteDevice).not.toHaveBeenCalledWith('dev3');
|
||||
});
|
||||
|
||||
test('requires a userId', async () => {
|
||||
const p = createTailscaleProvider({ coordClient: makeCoord() });
|
||||
await expect(p.removePerson()).rejects.toMatchObject({ code: 'bad_input' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailscale provider: revokeDevice()', () => {
|
||||
test('expires the single device key via POST /device/{id}/expire', async () => {
|
||||
const coord = makeCoord({ request: jest.fn(async () => ({})) });
|
||||
const p = createTailscaleProvider({ coordClient: coord });
|
||||
const res = await p.revokeDevice('dev2');
|
||||
expect(res).toEqual({ expired: 'dev2' });
|
||||
expect(coord.request).toHaveBeenCalledWith('POST', '/device/dev2/expire', { body: {} });
|
||||
});
|
||||
|
||||
test('requires a deviceId', async () => {
|
||||
const p = createTailscaleProvider({ coordClient: makeCoord() });
|
||||
await expect(p.revokeDevice()).rejects.toMatchObject({ code: 'bad_input' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailscale provider: syncAccess() (DC-153 contract tests)', () => {
|
||||
const { LockoutError } = require('../src/network/acl-compiler');
|
||||
const { AclPushError } = require('../src/network/acl-push');
|
||||
|
||||
/** aclSync engine mock factory (src/network/acl-sync.js surface). */
|
||||
function makeAclSync(overrides = {}) {
|
||||
return {
|
||||
compile: jest.fn(async () => ({
|
||||
policy: { acls: [] },
|
||||
hash: 'sha256:abc',
|
||||
usersAffected: 2,
|
||||
})),
|
||||
preview: jest.fn(async () => ({ feasible: true, errors: [] })),
|
||||
push: jest.fn(async () => ({ pushed: true, hash: 'sha256:abc' })),
|
||||
getState: jest.fn(() => null),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const input = () => ({
|
||||
accessMap: { grants: [{ principal: { type: 'person', id: 'p1' }, resource: 'dashcaddy', allowed: true }] },
|
||||
people: [
|
||||
{ id: 'p1', providerUserId: '1001', groups: ['Admin'], status: 'active' },
|
||||
{ id: 'p2', providerUserId: '2002', groups: [], status: 'active' },
|
||||
],
|
||||
resources: [{ name: 'dashcaddy', host: 'dns2.tail.ts.net', ports: ['*'] }],
|
||||
hostDeviceId: 'dev-host',
|
||||
});
|
||||
|
||||
test('happy path: compiles → drift-guarded push (preview happens EXACTLY once, inside push), returns {pushed, hash, usersAffected}', async () => {
|
||||
const aclSync = makeAclSync();
|
||||
const p = createTailscaleProvider({ coordClient: makeCoord(), aclSync });
|
||||
const res = await p.syncAccess(input());
|
||||
expect(res).toEqual({ pushed: true, hash: 'sha256:abc', usersAffected: 2 });
|
||||
// Compile receives the caller shapes + the derived guard inputs.
|
||||
expect(aclSync.compile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accessMap: input().accessMap,
|
||||
people: expect.any(Array),
|
||||
resources: expect.any(Array),
|
||||
hostDeviceId: 'dev-host',
|
||||
adminUserIds: ['1001'], // derived from the active Admin-group person
|
||||
hostResource: { host: 'dns2.tail.ts.net', ports: ['*'] },
|
||||
})
|
||||
);
|
||||
// Preview runs on the compiled policy, BEFORE the push.
|
||||
// syncAccess no longer previews explicitly — engine.push's internal
|
||||
// pushIfNoDrift does drift-check → preview → push (single preview).
|
||||
expect(aclSync.preview).not.toHaveBeenCalled();
|
||||
expect(aclSync.push).toHaveBeenCalledWith({ acls: [] });
|
||||
});
|
||||
|
||||
test('LockoutError from compile → ProviderError lockout_guard, nothing previewed or pushed', async () => {
|
||||
const aclSync = makeAclSync({
|
||||
compile: jest.fn(() => {
|
||||
throw new LockoutError('every active admin (1001) would be denied the host resource');
|
||||
}),
|
||||
});
|
||||
const p = createTailscaleProvider({ coordClient: makeCoord(), aclSync });
|
||||
await expect(p.syncAccess(input())).rejects.toMatchObject({
|
||||
name: 'ProviderError',
|
||||
code: 'lockout_guard',
|
||||
retryable: false,
|
||||
});
|
||||
expect(aclSync.preview).not.toHaveBeenCalled();
|
||||
expect(aclSync.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('preview errors[] (thrown inside drift-guarded push) → ProviderError acl_preview_failed, NOTHING pushed', async () => {
|
||||
// syncAccess no longer previews explicitly; pushIfNoDrift previews
|
||||
// internally, so the internal preview failure surfaces THROUGH push.
|
||||
const aclSync = makeAclSync({
|
||||
push: jest.fn(() => Promise.reject(new AclPushError('ACL preview reported errors', {
|
||||
code: 'acl_preview_failed',
|
||||
cause: ['bad dst'],
|
||||
}))),
|
||||
});
|
||||
const p = createTailscaleProvider({ coordClient: makeCoord(), aclSync });
|
||||
await expect(p.syncAccess(input())).rejects.toMatchObject({
|
||||
name: 'ProviderError',
|
||||
code: 'acl_preview_failed',
|
||||
retryable: false,
|
||||
});
|
||||
expect(aclSync.push).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('push API failure (acl_rejected) → ProviderError bad_input, mapped not leaked', async () => {
|
||||
const aclSync = makeAclSync({
|
||||
push: jest.fn(() => Promise.reject(new AclPushError('POST acl returned 400', {
|
||||
code: 'acl_rejected',
|
||||
}))),
|
||||
});
|
||||
const p = createTailscaleProvider({ coordClient: makeCoord(), aclSync });
|
||||
await expect(p.syncAccess(input())).rejects.toMatchObject({
|
||||
name: 'ProviderError',
|
||||
code: 'bad_input',
|
||||
});
|
||||
});
|
||||
|
||||
test('explicit adminUserIds/hostResource pass through verbatim', async () => {
|
||||
const aclSync = makeAclSync();
|
||||
const p = createTailscaleProvider({ coordClient: makeCoord(), aclSync });
|
||||
await p.syncAccess({
|
||||
...input(),
|
||||
adminUserIds: ['9999'],
|
||||
hostResource: { host: 'other-host', ports: ['22'] },
|
||||
});
|
||||
expect(aclSync.compile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ adminUserIds: ['9999'], hostResource: { host: 'other-host', ports: ['22'] } })
|
||||
);
|
||||
});
|
||||
|
||||
describe('default construction (dep OMITTED — real acl-compiler + acl-push wiring)', () => {
|
||||
const fsReal = require('fs');
|
||||
const os = require('os');
|
||||
const pathMod = require('path');
|
||||
let stateDir;
|
||||
|
||||
beforeAll(() => {
|
||||
stateDir = fsReal.mkdtempSync(pathMod.join(os.tmpdir(), 'dc-ts-syncaccess-'));
|
||||
global.__DC_ACL_STATE_DIR__ = stateDir; // acl-push state file location
|
||||
});
|
||||
afterAll(() => {
|
||||
delete global.__DC_ACL_STATE_DIR__;
|
||||
fsReal.rmSync(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('coord mock: ACL preview + push POST bodies contain per-user srcs', async () => {
|
||||
const request = jest.fn(async (method, apiPath) => {
|
||||
if (apiPath === '/tailnet/-/acl/preview') return { feasible: true, errors: [] };
|
||||
if (apiPath === '/tailnet/-/acl') return {};
|
||||
throw new Error('unexpected ' + method + ' ' + apiPath);
|
||||
});
|
||||
const coord = makeCoord({ request });
|
||||
const p = createTailscaleProvider({ coordClient: coord }); // aclSync OMITTED
|
||||
const res = await p.syncAccess(input());
|
||||
|
||||
expect(res.pushed).toBe(true);
|
||||
expect(res.hash).toMatch(/^sha256:[0-9a-f]{64}$/);
|
||||
expect(res.usersAffected).toBe(1); // user 1001 only (host device excluded)
|
||||
|
||||
const previewCall = request.mock.calls.find((c) => c[1] === '/tailnet/-/acl/preview');
|
||||
const pushCall = request.mock.calls.find((c) => c[1] === '/tailnet/-/acl');
|
||||
expect(previewCall).toBeTruthy();
|
||||
expect(pushCall).toBeTruthy();
|
||||
// preview BEFORE push, both carrying the compiled policy
|
||||
expect(request.mock.calls.indexOf(previewCall)).toBeLessThan(request.mock.calls.indexOf(pushCall));
|
||||
for (const call of [previewCall, pushCall]) {
|
||||
const acls = call[2].body.acls;
|
||||
// per-user src (never a group), dst host:port per resource grant
|
||||
expect(acls).toContainEqual({
|
||||
action: 'accept',
|
||||
src: ['1001'],
|
||||
dst: ['dns2.tail.ts.net:*'],
|
||||
proto: ['tcp', 'udp'],
|
||||
});
|
||||
// host device egress rule
|
||||
expect(acls).toContainEqual({
|
||||
action: 'accept',
|
||||
src: ['dev-host'],
|
||||
dst: ['*:*'],
|
||||
proto: ['tcp', 'udp'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('no coord client attached → not_configured (never builds a dead engine)', async () => {
|
||||
const p = createTailscaleProvider(); // no coordClient, no aclSync
|
||||
await expect(p.syncAccess(input())).rejects.toMatchObject({
|
||||
name: 'ProviderError',
|
||||
code: 'not_configured',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailscale provider: whois()', () => {
|
||||
test('resolves identity via the local tailscale CLI', async () => {
|
||||
const exec = makeExec({
|
||||
'100.91.55.51': 'user: sami@example.com\nnodeKey: nodekey:abc123\nnode: laptop.tail3e209.ts.net\n',
|
||||
});
|
||||
const p = createTailscaleProvider({ coordClient: makeCoord(), execFileImpl: exec });
|
||||
const who = await p.whois('100.91.55.51');
|
||||
expect(exec.calls).toEqual([{ bin: 'tailscale', args: ['whois', '100.91.55.51'] }]);
|
||||
expect(who).toEqual({
|
||||
nodeKey: 'nodekey:abc123',
|
||||
userId: null,
|
||||
loginName: 'sami@example.com',
|
||||
hostname: 'laptop.tail3e209.ts.net',
|
||||
});
|
||||
});
|
||||
|
||||
test('captures userid when present', async () => {
|
||||
const exec = makeExec({ '1.2.3.4': 'userid: 1001\nuser: sami@example.com\n' });
|
||||
const p = createTailscaleProvider({ execFileImpl: exec });
|
||||
await expect(p.whois('1.2.3.4')).resolves.toMatchObject({ userId: 1001 });
|
||||
});
|
||||
|
||||
test('CLI failure resolves to null (unknown identity is not an error)', async () => {
|
||||
const exec = makeExec({}, { failAll: true });
|
||||
const p = createTailscaleProvider({ execFileImpl: exec });
|
||||
await expect(p.whois('100.0.0.1')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
test('empty CLI output resolves to null', async () => {
|
||||
const exec = makeExec({});
|
||||
const p = createTailscaleProvider({ execFileImpl: exec });
|
||||
await expect(p.whois('100.0.0.2')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
test('missing ip is bad_input', async () => {
|
||||
const p = createTailscaleProvider();
|
||||
await expect(p.whois()).rejects.toMatchObject({ code: 'bad_input' });
|
||||
});
|
||||
|
||||
test('never touches the coordination client', async () => {
|
||||
const coord = makeCoord();
|
||||
const exec = makeExec({ '9.9.9.9': 'user: x@example.com\n' });
|
||||
const p = createTailscaleProvider({ coordClient: coord, execFileImpl: exec });
|
||||
await p.whois('9.9.9.9');
|
||||
expect(coord.request).not.toHaveBeenCalled();
|
||||
expect(coord.listDevices).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailscale provider: configureDns()', () => {
|
||||
test('POSTs split-DNS nameservers for the zone (capability-flagged)', async () => {
|
||||
const coord = makeCoord({ request: jest.fn(async () => ({})) });
|
||||
const p = createTailscaleProvider({ coordClient: coord });
|
||||
const res = await p.configureDns({ zone: 'internal', server: '100.100.100.10' });
|
||||
expect(res).toEqual({ configured: true, zone: 'internal', server: '100.100.100.10' });
|
||||
expect(coord.request).toHaveBeenCalledWith('POST', '/tailnet/-/dns/nameservers', {
|
||||
body: { dns: ['100.100.100.10'], domains: ['internal'] },
|
||||
});
|
||||
});
|
||||
|
||||
test('requires both zone and server', async () => {
|
||||
const p = createTailscaleProvider({ coordClient: makeCoord() });
|
||||
await expect(p.configureDns({ zone: 'internal' })).rejects.toMatchObject({ code: 'bad_input' });
|
||||
await expect(p.configureDns({ server: '1.1.1.1' })).rejects.toMatchObject({ code: 'bad_input' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailscale provider: error mapping', () => {
|
||||
test('listPeople surfaces a coord error as ProviderError', async () => {
|
||||
const coord = makeCoord({
|
||||
listUsers: jest.fn(async () => {
|
||||
throw new TailscaleCoordError('Tailscale API 404: no users', { status: 404 });
|
||||
}),
|
||||
});
|
||||
const p = createTailscaleProvider({ coordClient: coord });
|
||||
await expect(p.listPeople()).rejects.toBeInstanceOf(ProviderError);
|
||||
await expect(p.listPeople()).rejects.toMatchObject({ code: 'not_found' });
|
||||
});
|
||||
|
||||
test('unauthorized coord error → ProviderError code unauthorized, not retryable', async () => {
|
||||
const coord = makeCoord({
|
||||
listUsers: jest.fn(async () => {
|
||||
throw new TailscaleCoordError('Tailscale API 401: bad token', { status: 401 });
|
||||
}),
|
||||
});
|
||||
const p = createTailscaleProvider({ coordClient: coord });
|
||||
await expect(p.listPeople()).rejects.toMatchObject({
|
||||
name: 'ProviderError',
|
||||
code: 'unauthorized',
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('rate_limited coord error → ProviderError retryable, retryAfter preserved', async () => {
|
||||
const coord = makeCoord({
|
||||
listUsers: jest.fn(async () => {
|
||||
throw new TailscaleCoordError('Tailscale API 429: slow down', {
|
||||
status: 429,
|
||||
retryAfter: '30',
|
||||
});
|
||||
}),
|
||||
});
|
||||
const p = createTailscaleProvider({ coordClient: coord });
|
||||
await expect(p.listPeople()).rejects.toMatchObject({
|
||||
code: 'rate_limited',
|
||||
retryable: true,
|
||||
retryAfter: '30',
|
||||
});
|
||||
});
|
||||
|
||||
test('server_error coord error → ProviderError retryable', async () => {
|
||||
const coord = makeCoord({
|
||||
listUsers: jest.fn(async () => {
|
||||
throw new TailscaleCoordError('Tailscale API 502: bad gateway', { status: 502 });
|
||||
}),
|
||||
});
|
||||
const p = createTailscaleProvider({ coordClient: coord });
|
||||
await expect(p.listPeople()).rejects.toMatchObject({ code: 'server_error', retryable: true });
|
||||
});
|
||||
|
||||
test('generic thrown errors become retryable provider_error', () => {
|
||||
const mapped = toProviderError(new Error('boom'));
|
||||
expect(mapped).toBeInstanceOf(ProviderError);
|
||||
expect(mapped.code).toBe('provider_error');
|
||||
expect(mapped.retryable).toBe(true);
|
||||
});
|
||||
|
||||
test('ProviderError passes through toProviderError unchanged', () => {
|
||||
const original = new ProviderError('keep me', { code: 'bad_input' });
|
||||
expect(toProviderError(original)).toBe(original);
|
||||
});
|
||||
|
||||
test('coord errors thrown mid-block propagate as ProviderError', async () => {
|
||||
const coord = makeCoord({ listDevices: jest.fn(async () => devicesFixture()) });
|
||||
coord.request = jest.fn(async () => {
|
||||
throw new TailscaleCoordError('Tailscale API 403: forbidden', { status: 403 });
|
||||
});
|
||||
const p = createTailscaleProvider({ coordClient: coord });
|
||||
await expect(p.blockPerson('1001')).rejects.toMatchObject({ code: 'unauthorized' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailscale provider: attachCoordClient()', () => {
|
||||
test('late-binding a client flips not_configured → working', async () => {
|
||||
const p = createTailscaleProvider();
|
||||
await expect(p.listPeople()).rejects.toMatchObject({ code: 'not_configured' });
|
||||
p.attachCoordClient(makeCoord({ listUsers: jest.fn(async () => []) }));
|
||||
await expect(p.listPeople()).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,773 @@
|
||||
/**
|
||||
* Tests for src/network/providers/wireguard.js (DC-149).
|
||||
*
|
||||
* Hermetic: execFile and the ENTIRE fs surface are injected stubs backed by
|
||||
* an in-memory map — no child processes, no real files, no network. Same
|
||||
* rigor as __tests__/network-provider-tailscale.test.js.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { createWireGuardProvider } = require('../src/network/providers/wireguard');
|
||||
const { createTailscaleProvider } = require('../src/network/providers/tailscale');
|
||||
const { CONTRACT_METHODS } = require('../src/network/provider');
|
||||
|
||||
const PRIV = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='; // 44-char b64
|
||||
const PRIV2 = 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=';
|
||||
const PUB = 'a'.repeat(43) + '='; // satisfies the 44-char wg key shape
|
||||
const PUB2 = 'b'.repeat(43) + '=';
|
||||
const PUB3 = 'c'.repeat(43) + '=';
|
||||
|
||||
/**
|
||||
* In-memory fs stub: a Map<path, string> with the sync fs surface the
|
||||
* provider touches. Records nothing else — call assertions go on exec.
|
||||
*/
|
||||
function makeFs(initial = {}) {
|
||||
const files = new Map(Object.entries(initial));
|
||||
return {
|
||||
files,
|
||||
readFileSync(p) {
|
||||
if (!files.has(p)) {
|
||||
const err = new Error("ENOENT: no such file '" + p + "'");
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
}
|
||||
return files.get(p);
|
||||
},
|
||||
writeFileSync(p, contents, opts) {
|
||||
files.set(p, contents);
|
||||
if (opts && opts.mode !== undefined) files.set(p + ':mode', String(opts.mode));
|
||||
},
|
||||
renameSync(from, to) {
|
||||
if (!files.has(from)) {
|
||||
const err = new Error("ENOENT: no such file '" + from + "'");
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
}
|
||||
files.set(to, files.get(from));
|
||||
files.delete(from);
|
||||
},
|
||||
existsSync(p) {
|
||||
return files.has(p);
|
||||
},
|
||||
mkdirSync() {
|
||||
/* in-memory: dirs are implicit */
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* execFile stub with canned per-binary behavior. Records every call.
|
||||
* Defaults: root uid, `wg genkey`→PRIV, `wg pubkey`→PUB derived from input,
|
||||
* `wg show dcnet0 dump`→iface-only table, wg-quick up/down ok.
|
||||
*/
|
||||
function makeExec({
|
||||
genkey = PRIV,
|
||||
dump = null, // string stdout for `wg show dcnet0 dump`
|
||||
dumpFails = false, // `wg show` rejects → interface treated as down
|
||||
missing = [], // binaries that fail with ENOENT
|
||||
failQuickUp = false,
|
||||
} = {}) {
|
||||
const calls = [];
|
||||
const fn = async (bin, args, opts = {}) => {
|
||||
calls.push({ bin, args, opts });
|
||||
if (missing.includes(bin)) {
|
||||
const err = new Error('spawn ' + bin + ' ENOENT');
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
}
|
||||
if (bin === 'wg' && args[0] === 'genkey') return { stdout: genkey + '\n', stderr: '' };
|
||||
if (bin === 'wg' && args[0] === 'pubkey') {
|
||||
// deterministic: derive from the piped private key so hub identity is stable
|
||||
const input = String(opts.input || '');
|
||||
return { stdout: (input.includes('BBBB') ? PUB2 : PUB) + '\n', stderr: '' };
|
||||
}
|
||||
if (bin === 'wg' && args[0] === 'show' && args[2] === 'dump') {
|
||||
if (dumpFails) throw new Error('wg: dcnet0: No such device');
|
||||
return { stdout: dump !== null ? dump : PRIV + '\t' + PUB + '\t51820\toff\n', stderr: '' };
|
||||
}
|
||||
if (bin === 'wg' && args[0] === '--version') return { stdout: 'wg v1.0.20210914\n', stderr: '' };
|
||||
if (bin === 'wg-quick') {
|
||||
if (args[0] === 'up' && failQuickUp) throw new Error('wg-quick: `wg-quick up dcnet0` failed');
|
||||
if (args[0] === 'down') {
|
||||
return { stdout: '', stderr: '' };
|
||||
}
|
||||
return { stdout: '', stderr: '' };
|
||||
}
|
||||
if (bin === 'sysctl') return { stdout: 'net.ipv4.ip_forward = 1\n', stderr: '' };
|
||||
return { stdout: '', stderr: '' };
|
||||
};
|
||||
fn.calls = calls;
|
||||
return fn;
|
||||
}
|
||||
|
||||
/** Build a provider + captures with sensible defaults. */
|
||||
function makeProvider(over = {}) {
|
||||
const fs = over.fs || makeFs(over.files || {});
|
||||
const exec = over.exec || makeExec(over.execOpts || {});
|
||||
const provider = createWireGuardProvider({
|
||||
execFileImpl: exec,
|
||||
fsImpl: fs,
|
||||
dataDir: '/data',
|
||||
confPath: '/wg/dcnet0.conf',
|
||||
unitPath: '/sys/dcnet-hub.service',
|
||||
getuid: over.getuid || (() => 0),
|
||||
now: over.now || (() => 1700000000000),
|
||||
});
|
||||
return { provider, fs, exec };
|
||||
}
|
||||
|
||||
/** Public WG dump line for a peer (epoch handshake optional). */
|
||||
function dumpLine(pubkey, addr, handshakeSec = 0) {
|
||||
return [pubkey, '(none)', addr + '/32', '1.2.3.4:51820', String(handshakeSec), '100', '200', '0'].join('\t');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('wireguard provider: contract + capabilities', () => {
|
||||
test('satisfies every CONTRACT_METHODS entry', () => {
|
||||
const { provider } = makeProvider();
|
||||
for (const m of CONTRACT_METHODS) {
|
||||
expect(typeof provider[m]).toBe('function');
|
||||
}
|
||||
});
|
||||
|
||||
test('id() returns "wireguard"', () => {
|
||||
expect(makeProvider().provider.id()).toBe('wireguard');
|
||||
});
|
||||
|
||||
test('capabilities: invites, perPeerFirewall, selfHosted; NO splitDnsApi', () => {
|
||||
expect(makeProvider().provider.capabilities()).toEqual({
|
||||
invites: true,
|
||||
splitDnsApi: false,
|
||||
perPeerFirewall: true,
|
||||
selfHosted: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('wireguard provider: dependency checks', () => {
|
||||
test('missing `wg` binary → dependency_missing', async () => {
|
||||
const exec = makeExec({ missing: ['wg'] });
|
||||
const { provider } = makeProvider({ exec });
|
||||
await expect(provider.getStatus()).rejects.toMatchObject({
|
||||
name: 'ProviderError',
|
||||
code: 'dependency_missing',
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('missing `wg-quick` binary → dependency_missing', async () => {
|
||||
const exec = makeExec({ missing: ['wg-quick'] });
|
||||
const { provider } = makeProvider({ exec });
|
||||
await expect(provider.ensureHub()).rejects.toMatchObject({ code: 'dependency_missing' });
|
||||
});
|
||||
|
||||
test('non-root uid without CAP_NET_ADMIN → dependency_missing', async () => {
|
||||
const { provider } = makeProvider({ getuid: () => 1000 });
|
||||
await expect(provider.getStatus()).rejects.toMatchObject({
|
||||
code: 'dependency_missing',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('wireguard provider: ensureHub lifecycle', () => {
|
||||
test('first call: generates hub key ONCE, writes conf + unit, ip_forward, brings iface up', async () => {
|
||||
const { provider, fs, exec } = makeProvider();
|
||||
const res = await provider.ensureHub();
|
||||
expect(res).toMatchObject({ interface: 'dcnet0', address: '10.53.0.1/16', listenPort: 51820, up: true });
|
||||
const genkeys = exec.calls.filter((c) => c.bin === 'wg' && c.args[0] === 'genkey');
|
||||
expect(genkeys).toHaveLength(1);
|
||||
// pubkey derived via stdin (private key never hits argv)
|
||||
const pubcalls = exec.calls.filter((c) => c.bin === 'wg' && c.args[0] === 'pubkey');
|
||||
expect(pubcalls).toHaveLength(1);
|
||||
expect(pubcalls[0].opts.input).toContain(PRIV);
|
||||
const conf = fs.files.get('/wg/dcnet0.conf');
|
||||
expect(conf).toContain('[Interface]');
|
||||
expect(conf).toContain('PrivateKey = ' + PRIV);
|
||||
expect(conf).toContain('Address = 10.53.0.1/16');
|
||||
expect(conf).toContain('ListenPort = 51820');
|
||||
expect(fs.files.get('/sys/dcnet-hub.service')).toContain('ExecStart=/usr/bin/wg-quick up dcnet0');
|
||||
expect(exec.calls.some((c) => c.bin === 'sysctl' && c.args[1] === 'net.ipv4.ip_forward=1')).toBe(true);
|
||||
});
|
||||
|
||||
test('idempotent: second call does NOT regenerate the hub key', async () => {
|
||||
const { provider, exec } = makeProvider();
|
||||
await provider.ensureHub();
|
||||
await provider.ensureHub();
|
||||
expect(exec.calls.filter((c) => c.bin === 'wg' && c.args[0] === 'genkey')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('restart: reuses the PrivateKey already in the conf (no genkey)', async () => {
|
||||
const { provider, exec } = makeProvider({
|
||||
files: { '/wg/dcnet0.conf': '[Interface]\nPrivateKey = ' + PRIV2 + '\nAddress = 10.53.0.1/16\nListenPort = 51820\n' },
|
||||
});
|
||||
const res = await provider.ensureHub();
|
||||
expect(res.up).toBe(true);
|
||||
expect(exec.calls.filter((c) => c.bin === 'wg' && c.args[0] === 'genkey')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('missing /etc/wireguard dir is created (no throw)', async () => {
|
||||
const { provider } = makeProvider();
|
||||
await expect(provider.ensureHub()).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
test('startHub / stopHub round-trip, stopHub tolerates "not up"', async () => {
|
||||
const { provider, exec } = makeProvider();
|
||||
await provider.ensureHub();
|
||||
exec.calls.length = 0;
|
||||
await provider.startHub();
|
||||
expect(exec.calls.some((c) => c.bin === 'wg-quick' && c.args[0] === 'up')).toBe(true);
|
||||
await provider.stopHub();
|
||||
expect(exec.calls.some((c) => c.bin === 'wg-quick' && c.args[0] === 'down')).toBe(true);
|
||||
// stop again — down on a non-existent iface must not throw
|
||||
await expect(provider.stopHub()).resolves.toMatchObject({ stopped: 'dcnet0' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('wireguard provider: address pool', () => {
|
||||
test('deterministic lowest-free allocation, hub address excluded', async () => {
|
||||
const { provider, fs } = makeProvider();
|
||||
const a = await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB });
|
||||
const b = await provider.addPeer({ personId: 'p1', deviceId: 'd2', publicKey: PUB2 });
|
||||
expect(a.address).toBe('10.53.0.2/32');
|
||||
expect(b.address).toBe('10.53.0.3/32');
|
||||
const store = JSON.parse(fs.files.get('/data/network-wg-peers.json'));
|
||||
expect(store.peers.map((p) => p.address)).toEqual(['10.53.0.2/32', '10.53.0.3/32']);
|
||||
});
|
||||
|
||||
test('skips taken addresses (gap reuse: lowest-free wins)', async () => {
|
||||
const { provider, fs } = makeProvider();
|
||||
await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB });
|
||||
await provider.addPeer({ personId: 'p1', deviceId: 'd2', publicKey: PUB2 });
|
||||
await provider.removePeer(PUB); // frees 10.53.0.2
|
||||
const c = await provider.addPeer({ personId: 'p2', deviceId: 'd3', publicKey: PUB3 });
|
||||
expect(c.address).toBe('10.53.0.2/32'); // lowest free, reused
|
||||
});
|
||||
|
||||
test('survives restart: pool re-derived from the store, not a counter', async () => {
|
||||
const shared = makeFs({
|
||||
'/data/network-wg-peers.json': JSON.stringify({
|
||||
version: 1,
|
||||
peers: [
|
||||
{ personId: 'p1', deviceId: 'd1', publicKey: PUB, address: '10.53.0.2/32', name: 'laptop', createdAt: '2026-01-01T00:00:00Z', blocked: false },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const exec = makeExec();
|
||||
const first = createWireGuardProvider({
|
||||
execFileImpl: exec, fsImpl: shared, dataDir: '/data',
|
||||
confPath: '/wg/dcnet0.conf', unitPath: '/sys/dcnet-hub.service',
|
||||
getuid: () => 0, now: () => 1700000000000,
|
||||
});
|
||||
const r = await first.addPeer({ personId: 'p2', deviceId: 'd9', publicKey: PUB3 });
|
||||
expect(r.address).toBe('10.53.0.3/32'); // after the surviving 10.53.0.2
|
||||
// brand-new provider instance (fresh memory) over the same store
|
||||
const second = createWireGuardProvider({
|
||||
execFileImpl: makeExec(), fsImpl: shared, dataDir: '/data',
|
||||
confPath: '/wg/dcnet0.conf', unitPath: '/sys/dcnet-hub.service',
|
||||
getuid: () => 0, now: () => 1700000000000,
|
||||
});
|
||||
const devices = await second.listDevices();
|
||||
expect(devices.map((d) => d.ip).sort()).toEqual(['10.53.0.2', '10.53.0.3']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wireguard provider: addPeer', () => {
|
||||
test('writes [Peer] to conf + updates store; never stores a private key', async () => {
|
||||
const { provider, fs } = makeProvider();
|
||||
const res = await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB, name: 'laptop' });
|
||||
expect(res).toMatchObject({ deviceId: 'd1', address: '10.53.0.2/32' });
|
||||
expect(res.hubPublicKey).toBe(PUB); // derived from genkey'd hub private key
|
||||
expect(res.allowedIps).toBe('10.53.0.0/16'); // spec §8.1 split tunnel
|
||||
expect(res.dns).toBe('10.53.0.1'); // spec §8.3
|
||||
const conf = fs.files.get('/wg/dcnet0.conf');
|
||||
expect(conf).toContain('[Peer]');
|
||||
expect(conf).toContain('PublicKey = ' + PUB);
|
||||
expect(conf).toContain('AllowedIPs = 10.53.0.2/32');
|
||||
expect(conf).toContain('# laptop');
|
||||
const store = JSON.parse(fs.files.get('/data/network-wg-peers.json'));
|
||||
expect(store.peers).toHaveLength(1);
|
||||
expect(store.peers[0]).toMatchObject({ personId: 'p1', deviceId: 'd1', publicKey: PUB, address: '10.53.0.2/32', name: 'laptop' });
|
||||
// §8.2: the server stores public keys only
|
||||
expect(conf.includes(PRIV)).toBe(true); // hub key in conf is expected
|
||||
expect(JSON.stringify(store).includes(PRIV)).toBe(false);
|
||||
expect(store.peers[0].privateKey).toBeUndefined();
|
||||
});
|
||||
|
||||
test('apply path: wg-quick down + up (regenerated conf), never syncconf shell form', async () => {
|
||||
const { provider, exec } = makeProvider();
|
||||
await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB });
|
||||
const quick = exec.calls.filter((c) => c.bin === 'wg-quick');
|
||||
expect(quick.some((c) => c.args[0] === 'down')).toBe(true);
|
||||
expect(quick.some((c) => c.args[0] === 'up')).toBe(true);
|
||||
expect(quick.some((c) => c.args[0] === 'syncconf')).toBe(false);
|
||||
const allCmds = exec.calls.map((c) => c.bin + ' ' + c.args.join(' ')).join(';');
|
||||
expect(allCmds.includes('wg-quick strip')).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects invalid public keys (bad_input)', async () => {
|
||||
const { provider } = makeProvider();
|
||||
await expect(provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: 'not-a-key' }))
|
||||
.rejects.toMatchObject({ code: 'bad_input' });
|
||||
await expect(provider.addPeer({ personId: 'p1', deviceId: 'd1' }))
|
||||
.rejects.toMatchObject({ code: 'bad_input' });
|
||||
});
|
||||
|
||||
test('rejects duplicate publicKey (bad_input)', async () => {
|
||||
const { provider } = makeProvider();
|
||||
await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB });
|
||||
await expect(provider.addPeer({ personId: 'p2', deviceId: 'd2', publicKey: PUB }))
|
||||
.rejects.toMatchObject({ code: 'bad_input' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('wireguard provider: removePeer / revokeDevice / removePerson', () => {
|
||||
test('removePeer rewrites conf without the [Peer] and drops the store record', async () => {
|
||||
const { provider, fs } = makeProvider();
|
||||
await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB });
|
||||
const res = await provider.removePeer(PUB);
|
||||
expect(res).toMatchObject({ removed: PUB, address: '10.53.0.2/32' });
|
||||
const conf = fs.files.get('/wg/dcnet0.conf');
|
||||
expect(conf).not.toContain('[Peer]');
|
||||
expect(JSON.parse(fs.files.get('/data/network-wg-peers.json')).peers).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('removePeer unknown key → not_found', async () => {
|
||||
const { provider } = makeProvider();
|
||||
await expect(provider.removePeer(PUB)).rejects.toMatchObject({ code: 'not_found' });
|
||||
});
|
||||
|
||||
test('revokeDevice removes by deviceId', async () => {
|
||||
const { provider, fs } = makeProvider();
|
||||
await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB });
|
||||
const res = await provider.revokeDevice('d1');
|
||||
expect(res).toMatchObject({ revoked: 'd1', publicKey: PUB });
|
||||
expect(fs.files.get('/wg/dcnet0.conf')).not.toContain('PublicKey = ' + PUB);
|
||||
expect(JSON.parse(fs.files.get('/data/network-wg-peers.json')).peers).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('removePerson deletes peers + records entirely', async () => {
|
||||
const { provider, fs } = makeProvider();
|
||||
await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB });
|
||||
await provider.addPeer({ personId: 'p1', deviceId: 'd2', publicKey: PUB2 });
|
||||
await provider.addPeer({ personId: 'p2', deviceId: 'd3', publicKey: PUB3 });
|
||||
const res = await provider.removePerson('p1');
|
||||
expect(res).toEqual({ deleted: 2, count: 2 });
|
||||
const store = JSON.parse(fs.files.get('/data/network-wg-peers.json'));
|
||||
expect(store.peers).toHaveLength(1);
|
||||
expect(store.peers[0].personId).toBe('p2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('wireguard provider: blockPerson / unblockPerson', () => {
|
||||
test('block removes peers from conf but KEEPS records; conf keeps no trace', async () => {
|
||||
const { provider, fs } = makeProvider();
|
||||
await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB });
|
||||
await provider.addPeer({ personId: 'p2', deviceId: 'd2', publicKey: PUB2 });
|
||||
const res = await provider.blockPerson('p1');
|
||||
expect(res).toMatchObject({ blocked: true, count: 1 });
|
||||
const conf = fs.files.get('/wg/dcnet0.conf');
|
||||
expect(conf).not.toContain('PublicKey = ' + PUB);
|
||||
expect(conf).toContain('PublicKey = ' + PUB2); // other person untouched
|
||||
const store = JSON.parse(fs.files.get('/data/network-wg-peers.json'));
|
||||
const blocked = store.peers.find((p) => p.publicKey === PUB);
|
||||
expect(blocked).toBeTruthy(); // record kept for unblock
|
||||
expect(blocked.blocked).toBe(true);
|
||||
expect(blocked.address).toBe('10.53.0.2/32');
|
||||
});
|
||||
|
||||
test('unblock re-adds stored peers from the record, same addresses (WG restores immediately)', async () => {
|
||||
const { provider, fs } = makeProvider();
|
||||
await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB });
|
||||
await provider.blockPerson('p1');
|
||||
const res = await provider.unblockPerson('p1');
|
||||
expect(res).toEqual({ restored: [PUB], count: 1, reauthRequired: false });
|
||||
const conf = fs.files.get('/wg/dcnet0.conf');
|
||||
expect(conf).toContain('PublicKey = ' + PUB);
|
||||
expect(conf).toContain('AllowedIPs = 10.53.0.2/32'); // same /32 restored
|
||||
const store = JSON.parse(fs.files.get('/data/network-wg-peers.json'));
|
||||
expect(store.peers[0].blocked).toBe(false);
|
||||
});
|
||||
|
||||
test('unblockPerson with nothing blocked → empty restore, still reauthRequired:false', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const res = await provider.unblockPerson('nobody');
|
||||
expect(res).toEqual({ restored: [], count: 0, reauthRequired: false });
|
||||
});
|
||||
|
||||
test('tailscale unblockPerson is honest: restored true, reauthRequired true, NO coord call', async () => {
|
||||
const coord = { request: jest.fn(async () => ({})), listDevices: jest.fn(async () => []), listUsers: jest.fn(async () => []), deleteDevice: jest.fn(async () => ({})) };
|
||||
const ts = createTailscaleProvider({ coordClient: coord });
|
||||
const res = await ts.unblockPerson('1001');
|
||||
expect(res).toEqual({ restored: true, reauthRequired: true });
|
||||
expect(coord.request).not.toHaveBeenCalled(); // no un-expire API exists
|
||||
expect(coord.listDevices).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('wireguard provider: whois / listDevices / getStatus', () => {
|
||||
test('whois(ip) → peer-store lookup by address (with and without /32)', async () => {
|
||||
const { provider } = makeProvider();
|
||||
await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB, name: 'laptop' });
|
||||
await expect(provider.whois('10.53.0.2')).resolves.toMatchObject({
|
||||
personId: 'p1', deviceId: 'd1', publicKey: PUB, blocked: false,
|
||||
});
|
||||
await expect(provider.whois('10.53.0.2/32')).resolves.toMatchObject({ personId: 'p1' });
|
||||
await expect(provider.whois('10.53.9.9')).resolves.toBe(null);
|
||||
await expect(provider.whois('')).rejects.toMatchObject({ code: 'bad_input' });
|
||||
});
|
||||
|
||||
test('listDevices merges store with wg dump handshakes (online window)', async () => {
|
||||
const nowMs = 1700000000000;
|
||||
const freshSec = Math.floor((nowMs - 30 * 1000) / 1000); // 30s ago → online
|
||||
const staleSec = Math.floor((nowMs - 10 * 60 * 1000) / 1000); // 10m ago → offline
|
||||
const exec = makeExec({
|
||||
dump: PRIV + '\t' + PUB + '\t51820\toff\n' +
|
||||
dumpLine(PUB, '10.53.0.2', freshSec) + '\n' +
|
||||
dumpLine(PUB2, '10.53.0.3', staleSec) + '\n',
|
||||
});
|
||||
const { provider, fs } = makeProvider({ exec, now: () => nowMs });
|
||||
await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB });
|
||||
await provider.addPeer({ personId: 'p2', deviceId: 'd2', publicKey: PUB2 });
|
||||
const devices = await provider.listDevices();
|
||||
expect(devices).toHaveLength(2);
|
||||
const d1 = devices.find((d) => d.deviceId === 'd1');
|
||||
const d2 = devices.find((d) => d.deviceId === 'd2');
|
||||
expect(d1).toMatchObject({ ip: '10.53.0.2', online: true, userId: 'p1', rx: 100, tx: 200 });
|
||||
expect(d1.lastSeen).toBe(new Date(freshSec * 1000).toISOString());
|
||||
expect(d2.online).toBe(false);
|
||||
expect(fs.files.get('/data/network-wg-peers.json')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('getStatus: hub up with per-peer handshake state', async () => {
|
||||
const nowMs = 1700000000000;
|
||||
const freshSec = Math.floor((nowMs - 60 * 1000) / 1000);
|
||||
const exec = makeExec({ dump: PRIV + '\t' + PUB + '\t51820\toff\n' + dumpLine(PUB, '10.53.0.2', freshSec) + '\n' });
|
||||
const { provider } = makeProvider({ exec, now: () => nowMs });
|
||||
await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB });
|
||||
const st = await provider.getStatus();
|
||||
expect(st).toMatchObject({
|
||||
provider: 'wireguard', installed: true, connected: true, hubUp: true,
|
||||
interface: 'dcnet0', address: '10.53.0.1/16', listenPort: 51820,
|
||||
hubPublicKey: PUB, peerCount: 1,
|
||||
});
|
||||
expect(st.peers[0]).toMatchObject({ publicKey: PUB, address: '10.53.0.2/32', online: true, rx: 100, tx: 200 });
|
||||
});
|
||||
|
||||
test('getStatus: iface down (wg show fails) degrades to connected:false, peers offline', async () => {
|
||||
const exec = makeExec({ dumpFails: true });
|
||||
const { provider } = makeProvider({ exec });
|
||||
await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB });
|
||||
exec.calls.length = 0;
|
||||
const st = await provider.getStatus();
|
||||
expect(st).toMatchObject({ provider: 'wireguard', connected: false, hubUp: false });
|
||||
expect(st.peerCount).toBe(1); // from the store, not wg
|
||||
expect(st.peers[0].online).toBe(false);
|
||||
expect(st.peers[0].latestHandshake).toBe(null);
|
||||
});
|
||||
|
||||
test('listPeople projects unique persons with earliest createdAt', async () => {
|
||||
const { provider } = makeProvider();
|
||||
await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB, name: 'laptop' });
|
||||
await provider.addPeer({ personId: 'p1', deviceId: 'd2', publicKey: PUB2, name: 'phone' });
|
||||
await provider.addPeer({ personId: 'p2', deviceId: 'd3', publicKey: PUB3 });
|
||||
const people = await provider.listPeople();
|
||||
expect(people).toHaveLength(2);
|
||||
expect(people.map((p) => p.id).sort()).toEqual(['p1', 'p2']);
|
||||
expect(people.every((p) => p.email === null)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wireguard provider: invites (spec §8.5)', () => {
|
||||
test('createInvite → { inviteUrl: /network/wg/redeem/<token>, id }, 7d TTL, persisted', async () => {
|
||||
const t0 = 1700000000000;
|
||||
const { provider, fs } = makeProvider({ now: () => t0 });
|
||||
const inv = await provider.createInvite({ role: 'member', email: 'a@b.c' });
|
||||
expect(inv.id).toMatch(/^wgi_/);
|
||||
expect(inv.inviteUrl).toMatch(/^\/network\/wg\/redeem\/[A-Za-z0-9_-]{32}$/);
|
||||
const store = JSON.parse(fs.files.get('/data/network-wg-invites.json'));
|
||||
expect(store.invites).toHaveLength(1);
|
||||
expect(store.invites[0].expiresAt).toBe(t0 + 7 * 24 * 60 * 60 * 1000);
|
||||
expect(store.invites[0].usedAt).toBe(null);
|
||||
});
|
||||
|
||||
test('redeem: single-use — second attempt fails', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const inv = await provider.createInvite({ role: 'member' });
|
||||
const token = inv.inviteUrl.split('/').pop();
|
||||
await expect(provider.redeemInvite(token)).resolves.toMatchObject({ role: 'member' });
|
||||
await expect(provider.redeemInvite(token)).rejects.toMatchObject({
|
||||
code: 'bad_input', // already used
|
||||
});
|
||||
});
|
||||
|
||||
test('redeem: expired token → not_found and pruned from the store', async () => {
|
||||
let t = 1700000000000;
|
||||
const { provider, fs } = makeProvider({ now: () => t });
|
||||
const inv = await provider.createInvite({ role: 'member' });
|
||||
const token = inv.inviteUrl.split('/').pop();
|
||||
t += 7 * 24 * 60 * 60 * 1000 + 1; // past TTL
|
||||
await expect(provider.redeemInvite(token)).rejects.toMatchObject({ code: 'not_found' });
|
||||
const store = JSON.parse(fs.files.get('/data/network-wg-invites.json'));
|
||||
expect(store.invites).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('revokeInvite removes the token; unknown id → not_found', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const inv = await provider.createInvite({ role: 'member' });
|
||||
await expect(provider.revokeInvite(inv.id)).resolves.toEqual({ revoked: true });
|
||||
const token = inv.inviteUrl.split('/').pop();
|
||||
await expect(provider.redeemInvite(token)).rejects.toMatchObject({ code: 'not_found' });
|
||||
await expect(provider.revokeInvite('nope')).rejects.toMatchObject({ code: 'not_found' });
|
||||
});
|
||||
|
||||
test('createInvite validates role/email (bad_input)', async () => {
|
||||
const { provider } = makeProvider();
|
||||
await expect(provider.createInvite({})).rejects.toMatchObject({ code: 'bad_input' });
|
||||
await expect(provider.createInvite({ role: 'member', email: 'nope' })).rejects.toMatchObject({ code: 'bad_input' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('wireguard provider: syncAccess() (DC-153 contract tests)', () => {
|
||||
const {
|
||||
FirewallCheckError,
|
||||
FirewallApplyError,
|
||||
FirewallVerifyError,
|
||||
} = require('../src/network/wg-firewall');
|
||||
|
||||
/** firewall engine mock factory (applier surface). */
|
||||
function makeFirewall(overrides = {}) {
|
||||
return {
|
||||
apply: jest.fn(async () => ({
|
||||
applied: true,
|
||||
verified: true,
|
||||
tempPath: '/tmp/x.nft',
|
||||
rollbackFile: '/tmp/rb.nft',
|
||||
previousExisted: false,
|
||||
})),
|
||||
rollback: jest.fn(async () => ({ applied: true, rollbackFile: '/tmp/rb.nft' })),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const input = () => ({
|
||||
accessMap: { grants: [{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true }] },
|
||||
people: [
|
||||
{ id: 'p1', groups: ['Admin'], status: 'active' },
|
||||
{ id: 'p2', groups: [], status: 'active' },
|
||||
],
|
||||
resources: { plex: { ip: '10.53.0.1', port: 32400 } },
|
||||
peerAddresses: { 'p1/d1': '10.53.0.2', 'p2/d2': '10.53.0.3' },
|
||||
adminPeers: ['p1'],
|
||||
});
|
||||
|
||||
function makeProviderWithFirewall(firewall) {
|
||||
const fs = makeFs();
|
||||
const exec = makeExec();
|
||||
const provider = createWireGuardProvider({
|
||||
execFileImpl: exec,
|
||||
fsImpl: fs,
|
||||
dataDir: '/data',
|
||||
confPath: '/wg/dcnet0.conf',
|
||||
unitPath: '/sys/dcnet-hub.service',
|
||||
getuid: () => 0,
|
||||
now: () => 1700000000000,
|
||||
firewall,
|
||||
});
|
||||
return { provider, fs, exec };
|
||||
}
|
||||
|
||||
test('happy path: applies the compiled ruleset, returns {applied, resourcesAffected}', async () => {
|
||||
const firewall = makeFirewall();
|
||||
const { provider } = makeProviderWithFirewall(firewall);
|
||||
const res = await provider.syncAccess(input());
|
||||
expect(res).toEqual({ applied: true, resourcesAffected: 1 });
|
||||
expect(firewall.apply).toHaveBeenCalledTimes(1);
|
||||
const ruleset = firewall.apply.mock.calls[0][0];
|
||||
// The ruleset is the REAL compiler output for these shapes: admin set
|
||||
// scoped to p1's peer IP, plex resource set with p1 (allowed) but not p2.
|
||||
expect(ruleset).toContain('set net_dc_admins');
|
||||
expect(ruleset).toContain('elements = { 10.53.0.2 }');
|
||||
expect(ruleset).toContain('set net_dc_res_plex');
|
||||
expect(ruleset).toContain('10.53.0.2'); // p1 in the plex resource set
|
||||
expect(ruleset).not.toContain('10.53.0.3'); // p2 (no grant) in NO set
|
||||
});
|
||||
|
||||
test('adminPeers defaults to active Admin-group people when omitted', async () => {
|
||||
const firewall = makeFirewall();
|
||||
const { provider } = makeProviderWithFirewall(firewall);
|
||||
const { adminPeers: _omit, ...withoutAdminPeers } = input();
|
||||
await provider.syncAccess(withoutAdminPeers);
|
||||
const ruleset = firewall.apply.mock.calls[0][0];
|
||||
expect(ruleset).toContain('elements = { 10.53.0.2 }'); // p1 = Admin group
|
||||
});
|
||||
|
||||
test('admin lockout at compile (no active admin peer IPs) → ProviderError lockout_guard, nothing applied', async () => {
|
||||
const firewall = makeFirewall();
|
||||
const { provider } = makeProviderWithFirewall(firewall);
|
||||
await expect(
|
||||
provider.syncAccess({ ...input(), peerAddresses: {} })
|
||||
).rejects.toMatchObject({
|
||||
name: 'ProviderError',
|
||||
code: 'lockout_guard',
|
||||
retryable: false,
|
||||
});
|
||||
expect(firewall.apply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('FirewallCheckError → ProviderError firewall_check_failed, .detail carries stderr + applied:false', async () => {
|
||||
const firewall = makeFirewall({
|
||||
apply: jest.fn(() => Promise.reject(
|
||||
new FirewallCheckError('nft -c rejected ruleset (syntax)', { stderr: 'syntax error' })
|
||||
)),
|
||||
});
|
||||
const { provider } = makeProviderWithFirewall(firewall);
|
||||
const err = await provider.syncAccess(input()).catch((e) => e);
|
||||
expect(err).toMatchObject({
|
||||
name: 'ProviderError',
|
||||
code: 'firewall_check_failed',
|
||||
retryable: false,
|
||||
});
|
||||
expect(err.detail).toEqual({ stderr: 'syntax error', applied: false });
|
||||
});
|
||||
|
||||
test('FirewallApplyError → ProviderError firewall_apply_failed, .detail records NOT partially applied', async () => {
|
||||
const firewall = makeFirewall({
|
||||
apply: jest.fn(() => Promise.reject(
|
||||
new FirewallApplyError('nft -f apply failed (op not permitted)', { stderr: 'op not permitted' })
|
||||
)),
|
||||
});
|
||||
const { provider } = makeProviderWithFirewall(firewall);
|
||||
const err = await provider.syncAccess(input()).catch((e) => e);
|
||||
expect(err).toMatchObject({ name: 'ProviderError', code: 'firewall_apply_failed' });
|
||||
expect(err.detail).toEqual({ stderr: 'op not permitted', applied: false, rolledBack: false });
|
||||
});
|
||||
|
||||
test('FirewallVerifyError → ProviderError firewall_verify_failed, .detail preserves rolledBack info', async () => {
|
||||
const firewall = makeFirewall({
|
||||
apply: jest.fn(() => Promise.reject(
|
||||
new FirewallVerifyError('post-apply verify failed (missing table); rollback succeeded', {
|
||||
stderr: 'missing table',
|
||||
rolledBack: true,
|
||||
})
|
||||
)),
|
||||
});
|
||||
const { provider } = makeProviderWithFirewall(firewall);
|
||||
const err = await provider.syncAccess(input()).catch((e) => e);
|
||||
expect(err).toMatchObject({ name: 'ProviderError', code: 'firewall_verify_failed' });
|
||||
expect(err.detail).toEqual({ stderr: 'missing table', applied: true, rolledBack: true });
|
||||
});
|
||||
|
||||
describe('default construction (dep OMITTED — real wg-firewall applier wiring)', () => {
|
||||
/**
|
||||
* exec stub: default WG behavior PLUS `nft` (check/apply ok, list table
|
||||
* returns the dcnet table text so verify passes). Records every call.
|
||||
*/
|
||||
function makeExecWithNft() {
|
||||
const base = makeExec();
|
||||
const calls = [];
|
||||
const fn = async (bin, args, opts = {}) => {
|
||||
calls.push({ bin, args, opts });
|
||||
if (bin === 'nft') {
|
||||
if (args[0] === '-c') return { stdout: '', stderr: '' };
|
||||
if (args[0] === '-f') return { stdout: '', stderr: '' };
|
||||
if (args[0] === 'list') return { stdout: 'table inet dcnet {\n}\n', stderr: '' };
|
||||
}
|
||||
return base(bin, args, opts);
|
||||
};
|
||||
fn.calls = calls;
|
||||
return fn;
|
||||
}
|
||||
|
||||
test('execFile mock receives nft -c -f THEN nft -f (transactional order)', async () => {
|
||||
const exec = makeExecWithNft();
|
||||
const fs = makeFs();
|
||||
const provider = createWireGuardProvider({
|
||||
execFileImpl: exec,
|
||||
fsImpl: fs,
|
||||
dataDir: '/data',
|
||||
confPath: '/wg/dcnet0.conf',
|
||||
unitPath: '/sys/dcnet-hub.service',
|
||||
getuid: () => 0,
|
||||
now: () => 1700000000000,
|
||||
// firewall dep OMITTED — default createWgFirewallApplier(execFileImpl)
|
||||
});
|
||||
const res = await provider.syncAccess(input());
|
||||
expect(res).toEqual({ applied: true, resourcesAffected: 1 });
|
||||
|
||||
const nftCalls = exec.calls.filter((c) => c.bin === 'nft');
|
||||
expect(nftCalls.length).toBeGreaterThanOrEqual(4);
|
||||
// Sequence: `nft -c -f` (check) → `nft list table` (capture previous)
|
||||
// → `nft -f` (apply) → `nft list table` (verify), strictly in order.
|
||||
const checkCall = nftCalls.find((c) => c.args[0] === '-c');
|
||||
const applyCall = nftCalls.find((c) => c.args[0] === '-f');
|
||||
const listCalls = nftCalls.filter((c) => c.args[0] === 'list');
|
||||
expect(checkCall).toBeTruthy();
|
||||
expect(applyCall).toBeTruthy();
|
||||
expect(listCalls.length).toBeGreaterThanOrEqual(2);
|
||||
expect(nftCalls.indexOf(checkCall)).toBeLessThan(nftCalls.indexOf(applyCall));
|
||||
expect(listCalls[0].args).toEqual(['list', 'table', 'inet', 'dcnet']);
|
||||
// The checked file and the applied file are the same temp ruleset.
|
||||
expect(applyCall.args[1]).toBe(checkCall.args[2]);
|
||||
// The applied ruleset text is the REAL compiler output.
|
||||
const appliedText = fs.readFileSync(applyCall.args[1], 'utf8');
|
||||
expect(appliedText).toContain('set net_dc_admins');
|
||||
expect(appliedText).toContain('elements = { 10.53.0.2 }');
|
||||
});
|
||||
|
||||
test('default applier: nft -c failure → firewall_check_failed with stderr detail, no apply', async () => {
|
||||
const base = makeExec();
|
||||
const calls = [];
|
||||
const exec = async (bin, args, opts = {}) => {
|
||||
calls.push({ bin, args, opts });
|
||||
if (bin === 'nft' && args[0] === '-c') {
|
||||
const e = new Error('nft: syntax error');
|
||||
e.stderr = 'nft: syntax error near line 4';
|
||||
throw e;
|
||||
}
|
||||
return base(bin, args, opts);
|
||||
};
|
||||
const provider = createWireGuardProvider({
|
||||
execFileImpl: exec,
|
||||
fsImpl: makeFs(),
|
||||
dataDir: '/data',
|
||||
confPath: '/wg/dcnet0.conf',
|
||||
unitPath: '/sys/dcnet-hub.service',
|
||||
getuid: () => 0,
|
||||
now: () => 1700000000000,
|
||||
});
|
||||
const err = await provider.syncAccess(input()).catch((e) => e);
|
||||
expect(err).toMatchObject({ name: 'ProviderError', code: 'firewall_check_failed' });
|
||||
expect(err.detail).toMatchObject({ stderr: 'nft: syntax error near line 4', applied: false });
|
||||
// check failed ⇒ `nft -f` apply NEVER ran
|
||||
expect(calls.filter((c) => c.bin === 'nft' && c.args[0] === '-f')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('wireguard provider: unsupported / not-implemented surface', () => {
|
||||
test('configureDns → unsupported (splitDnsApi capability false)', async () => {
|
||||
const { provider } = makeProvider();
|
||||
await expect(provider.configureDns({ zone: 'z', server: 's' })).rejects.toMatchObject({
|
||||
name: 'ProviderError',
|
||||
code: 'unsupported',
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('wireguard provider: registry registration', () => {
|
||||
test('src/network/index registers wireguard alongside tailscale', () => {
|
||||
jest.resetModules();
|
||||
const { registry } = require('../src/network');
|
||||
expect(registry.listProviderIds().sort()).toEqual(['tailscale', 'wireguard']);
|
||||
const p = registry.setActiveProvider('wireguard');
|
||||
expect(p.id()).toBe('wireguard');
|
||||
// contract validation is enforced by setActiveProvider itself
|
||||
for (const m of CONTRACT_METHODS) expect(typeof p[m]).toBe('function');
|
||||
registry.reset();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Tests for src/network/registry.js (DC-140).
|
||||
*
|
||||
* Hermetic: no network, no filesystem. Providers are stub factories whose
|
||||
* instances are plain objects implementing the documented contract surface
|
||||
* (see src/network/provider.js).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { registry } = require('../src/network');
|
||||
const { ProviderNotActiveError, ProviderError } = require('../src/network/errors');
|
||||
const { CONTRACT_METHODS } = require('../src/network/provider');
|
||||
|
||||
/** Build a minimal contract-satisfying stub provider. */
|
||||
function stubProvider(id, caps = {}) {
|
||||
const p = {};
|
||||
for (const m of CONTRACT_METHODS) {
|
||||
p[m] = m === 'id' ? () => id : m === 'capabilities' ? () => ({
|
||||
invites: false,
|
||||
splitDnsApi: false,
|
||||
perPeerFirewall: false,
|
||||
selfHosted: false,
|
||||
...caps,
|
||||
}) : async () => ({});
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
registry.reset();
|
||||
});
|
||||
|
||||
describe('network registry: inactive default', () => {
|
||||
test('defaults to inactive — getActiveProvider() throws ProviderNotActiveError', () => {
|
||||
expect(() => registry.getActiveProvider()).toThrow(ProviderNotActiveError);
|
||||
});
|
||||
|
||||
test('getCapabilities() throws ProviderNotActiveError when unset', () => {
|
||||
expect(() => registry.getCapabilities()).toThrow(ProviderNotActiveError);
|
||||
});
|
||||
|
||||
test('active id is null before activation', () => {
|
||||
expect(registry.getActiveProviderId()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('network registry: registration', () => {
|
||||
test('registerProvider stores a factory under its id', () => {
|
||||
registry.registerProvider('stub-a', () => stubProvider('stub-a'));
|
||||
expect(registry.listProviderIds()).toContain('stub-a');
|
||||
});
|
||||
|
||||
test('registerProvider rejects a non-function factory', () => {
|
||||
expect(() => registry.registerProvider('bad', 'not-a-function')).toThrow(TypeError);
|
||||
});
|
||||
|
||||
test('registerProvider rejects an empty id', () => {
|
||||
expect(() => registry.registerProvider('', () => stubProvider('x'))).toThrow(TypeError);
|
||||
});
|
||||
|
||||
test('registerProvider re-registers over an existing id', () => {
|
||||
registry.registerProvider('stub-a', () => stubProvider('stub-a'));
|
||||
registry.registerProvider('stub-a', () => stubProvider('stub-a'));
|
||||
expect(registry.listProviderIds().filter((i) => i === 'stub-a')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network registry: activation + resolution', () => {
|
||||
test('setActiveProvider(id) resolves the instance via the factory', () => {
|
||||
const sentinel = stubProvider('stub-a');
|
||||
registry.registerProvider('stub-a', () => sentinel);
|
||||
const active = registry.setActiveProvider('stub-a');
|
||||
expect(active).toBe(sentinel);
|
||||
expect(registry.getActiveProvider()).toBe(sentinel);
|
||||
expect(registry.getActiveProviderId()).toBe('stub-a');
|
||||
});
|
||||
|
||||
test('setActiveProvider throws for an unregistered id', () => {
|
||||
expect(() => registry.setActiveProvider('does-not-exist')).toThrow(/Unknown network provider/);
|
||||
});
|
||||
|
||||
test('setActiveProvider rebuilds the instance when the factory is replaced', () => {
|
||||
const first = stubProvider('stub-a');
|
||||
const second = stubProvider('stub-a');
|
||||
registry.registerProvider('stub-a', () => first);
|
||||
registry.setActiveProvider('stub-a');
|
||||
registry.registerProvider('stub-a', () => second);
|
||||
expect(registry.getActiveProvider()).toBe(second);
|
||||
});
|
||||
|
||||
test('setActiveProvider rejects an instance missing contract methods', () => {
|
||||
registry.registerProvider('incomplete', () => ({ id: () => 'incomplete' }));
|
||||
expect(() => registry.setActiveProvider('incomplete')).toThrow(/contract/i);
|
||||
});
|
||||
|
||||
test('setActiveProvider rejects an instance whose id() mismatches', () => {
|
||||
registry.registerProvider('stub-a', () => stubProvider('stub-b'));
|
||||
expect(() => registry.setActiveProvider('stub-a')).toThrow(/different id/i);
|
||||
});
|
||||
|
||||
test('reset() returns the registry to inactive', () => {
|
||||
registry.registerProvider('stub-a', () => stubProvider('stub-a'));
|
||||
registry.setActiveProvider('stub-a');
|
||||
registry.reset();
|
||||
expect(() => registry.getActiveProvider()).toThrow(ProviderNotActiveError);
|
||||
expect(registry.getActiveProviderId()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('network registry: capability flags', () => {
|
||||
test('getCapabilities() returns the active provider capabilities', () => {
|
||||
registry.registerProvider(
|
||||
'stub-ts',
|
||||
() => stubProvider('stub-ts', { invites: true, splitDnsApi: true })
|
||||
);
|
||||
registry.setActiveProvider('stub-ts');
|
||||
expect(registry.getCapabilities()).toEqual({
|
||||
invites: true,
|
||||
splitDnsApi: true,
|
||||
perPeerFirewall: false,
|
||||
selfHosted: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('network errors', () => {
|
||||
test('ProviderNotActiveError is a ProviderError with code provider_not_active', () => {
|
||||
const e = new ProviderNotActiveError();
|
||||
expect(e).toBeInstanceOf(ProviderError);
|
||||
expect(e.code).toBe('provider_not_active');
|
||||
expect(e.retryable).toBe(false);
|
||||
});
|
||||
|
||||
test('ProviderError derives retryable from code when not given', () => {
|
||||
expect(new ProviderError('x', { code: 'rate_limited' }).retryable).toBe(true);
|
||||
expect(new ProviderError('x', { code: 'server_error' }).retryable).toBe(true);
|
||||
expect(new ProviderError('x', { code: 'network_error' }).retryable).toBe(true);
|
||||
expect(new ProviderError('x', { code: 'unauthorized' }).retryable).toBe(false);
|
||||
expect(new ProviderError('x', { code: 'bad_input' }).retryable).toBe(false);
|
||||
});
|
||||
|
||||
test('ProviderError honors an explicit retryable override', () => {
|
||||
expect(new ProviderError('x', { code: 'rate_limited', retryable: false }).retryable).toBe(false);
|
||||
expect(new ProviderError('x', { code: 'unknown', retryable: true }).retryable).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network index: built-in tailscale registration', () => {
|
||||
test('tailscale is registered and activatable via the package entry point', () => {
|
||||
// The package entry registers the real tailscale factory; activation
|
||||
// builds a real provider instance (lazy deps, no network at build time).
|
||||
expect(registry.listProviderIds()).toContain('tailscale');
|
||||
const provider = registry.setActiveProvider('tailscale');
|
||||
expect(provider.id()).toBe('tailscale');
|
||||
expect(provider.capabilities()).toEqual({
|
||||
invites: true,
|
||||
splitDnsApi: true,
|
||||
perPeerFirewall: false,
|
||||
selfHosted: false,
|
||||
});
|
||||
// And it deactivates cleanly again.
|
||||
registry.reset();
|
||||
expect(() => registry.getActiveProvider()).toThrow(ProviderNotActiveError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Route tests for network-resources routes (DC-144).
|
||||
* Coverage:
|
||||
* - CRUD contract (GET list, GET one, POST, PATCH, DELETE)
|
||||
* - validation errors: bad slug name, port out of range / non-integer,
|
||||
* empty host, bad type/protocol/sensitive
|
||||
* - duplicate create → 409; unknown name → 404; name immutable on PATCH
|
||||
* - sensitive flag round-trip (create true → read true → flip → false)
|
||||
* - defaults: displayName falls back to name, type 'app', manual true
|
||||
* - services-registry seeding: skipped silently when the services file
|
||||
* is missing/unreadable; seeded entries are manual:false
|
||||
* Hermetic — supertest against an in-process express app over a real
|
||||
* store in a temp dir, 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 { createResourcesStore } = require('../src/security/network-resources');
|
||||
const networkResourcesRoutes = require('../routes/network-resources');
|
||||
|
||||
function asyncHandler(fn) {
|
||||
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
}
|
||||
|
||||
function createApp({ servicesFile } = {}) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-resourceroutes-'));
|
||||
const resourcesStore = createResourcesStore({ dataDir: dir, servicesFile });
|
||||
const router = networkResourcesRoutes({
|
||||
resourcesStore,
|
||||
asyncHandler,
|
||||
log: { info: () => {}, warn: () => {}, error: () => {} },
|
||||
});
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/network/resources', 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, dir, resourcesStore };
|
||||
}
|
||||
|
||||
function _cleanup(dir) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
|
||||
describe('network-resources routes: contract', () => {
|
||||
let dir, app;
|
||||
beforeEach(() => {
|
||||
({ app, dir } = createApp());
|
||||
});
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('GET / lists resources (empty initially)', async () => {
|
||||
const res = await request(app).get('/network/resources');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.data).toEqual([]);
|
||||
expect(res.body.count).toBe(0);
|
||||
});
|
||||
|
||||
test('POST / creates a resource with defaults', async () => {
|
||||
const res = await request(app).post('/network/resources')
|
||||
.send({ name: 'plex', host: 'plex.internal', port: 32400 });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.resource).toMatchObject({
|
||||
name: 'plex',
|
||||
displayName: 'plex',
|
||||
type: 'app',
|
||||
host: 'plex.internal',
|
||||
port: 32400,
|
||||
protocol: 'http',
|
||||
sensitive: false,
|
||||
manual: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('POST / accepts explicit displayName/type/protocol/sensitive', async () => {
|
||||
const res = await request(app).post('/network/resources')
|
||||
.send({ name: 'nas', displayName: 'NAS Box', type: 'device', host: 'nas.internal', port: 5000, protocol: 'https', sensitive: true });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.resource).toMatchObject({ displayName: 'NAS Box', type: 'device', protocol: 'https', sensitive: true });
|
||||
});
|
||||
|
||||
test('GET /:name returns one resource; 404 unknown', async () => {
|
||||
await request(app).post('/network/resources').send({ name: 'plex', host: 'plex.internal', port: 32400 });
|
||||
const hit = await request(app).get('/network/resources/plex');
|
||||
expect(hit.status).toBe(200);
|
||||
expect(hit.body.resource.name).toBe('plex');
|
||||
const miss = await request(app).get('/network/resources/nope');
|
||||
expect(miss.status).toBe(404);
|
||||
});
|
||||
|
||||
test('POST / duplicate name → 409', async () => {
|
||||
await request(app).post('/network/resources').send({ name: 'plex', host: 'plex.internal', port: 32400 });
|
||||
const res = await request(app).post('/network/resources').send({ name: 'plex', host: 'other', port: 1 });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
test('PATCH /:name updates fields', async () => {
|
||||
await request(app).post('/network/resources').send({ name: 'plex', host: 'plex.internal', port: 32400 });
|
||||
const res = await request(app).patch('/network/resources/plex').send({ port: 32401, displayName: 'Plex!' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.resource).toMatchObject({ port: 32401, displayName: 'Plex!' });
|
||||
});
|
||||
|
||||
test('PATCH /:name: name is immutable; 404 unknown', async () => {
|
||||
await request(app).post('/network/resources').send({ name: 'plex', host: 'plex.internal', port: 32400 });
|
||||
const rename = await request(app).patch('/network/resources/plex').send({ name: 'plex2' });
|
||||
expect(rename.status).toBe(400);
|
||||
const missing = await request(app).patch('/network/resources/nope').send({ port: 80 });
|
||||
expect(missing.status).toBe(404);
|
||||
});
|
||||
|
||||
test('DELETE /:name deletes; second delete → 404', async () => {
|
||||
await request(app).post('/network/resources').send({ name: 'plex', host: 'plex.internal', port: 32400 });
|
||||
const res = await request(app).delete('/network/resources/plex');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.deleted).toBe(true);
|
||||
const again = await request(app).delete('/network/resources/plex');
|
||||
expect(again.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-resources routes: validation', () => {
|
||||
let dir, app;
|
||||
beforeEach(() => {
|
||||
({ app, dir } = createApp());
|
||||
});
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('rejects bad name slugs', async () => {
|
||||
for (const bad of ['Plex', 'has space', 'under_score', '', 'a/b', 'dot.name']) {
|
||||
const res = await request(app).post('/network/resources').send({ name: bad, host: 'h', port: 80 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects ports outside 1-65535 and non-integers', async () => {
|
||||
for (const bad of [0, -1, 65536, 99999, 80.5, '80', null]) {
|
||||
const res = await request(app).post('/network/resources').send({ name: 'plex', host: 'h', port: bad });
|
||||
expect(res.status).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects empty/missing host', async () => {
|
||||
const missing = await request(app).post('/network/resources').send({ name: 'plex', port: 80 });
|
||||
expect(missing.status).toBe(400);
|
||||
const blank = await request(app).post('/network/resources').send({ name: 'plex', host: ' ', port: 80 });
|
||||
expect(blank.status).toBe(400);
|
||||
});
|
||||
|
||||
test('rejects bad type/protocol/sensitive', async () => {
|
||||
expect((await request(app).post('/network/resources').send({ name: 'plex', host: 'h', port: 80, type: 'vm' })).status).toBe(400);
|
||||
expect((await request(app).post('/network/resources').send({ name: 'plex', host: 'h', port: 80, protocol: 'grpc' })).status).toBe(400);
|
||||
expect((await request(app).post('/network/resources').send({ name: 'plex', host: 'h', port: 80, sensitive: 'yes' })).status).toBe(400);
|
||||
});
|
||||
|
||||
test('accepts boundary ports 1 and 65535', async () => {
|
||||
const lo = await request(app).post('/network/resources').send({ name: 'lo', host: 'h', port: 1 });
|
||||
expect(lo.status).toBe(201);
|
||||
const hi = await request(app).post('/network/resources').send({ name: 'hi', host: 'h', port: 65535 });
|
||||
expect(hi.status).toBe(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-resources routes: sensitive round-trip + seeding', () => {
|
||||
let dir, app;
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('sensitive flag round-trips through create, read, update, re-read', async () => {
|
||||
({ app, dir } = createApp());
|
||||
await request(app).post('/network/resources')
|
||||
.send({ name: 'vault', host: 'vault.internal', port: 8200, sensitive: true });
|
||||
let res = await request(app).get('/network/resources/vault');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.resource.sensitive).toBe(true);
|
||||
|
||||
await request(app).patch('/network/resources/vault').send({ sensitive: false });
|
||||
res = await request(app).get('/network/resources/vault');
|
||||
expect(res.body.resource.sensitive).toBe(false);
|
||||
|
||||
// …and persists on disk (round-trip through a fresh store read).
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'network-resources.json'), 'utf8'));
|
||||
expect(raw.resources.find(r => r.name === 'vault').sensitive).toBe(false);
|
||||
});
|
||||
|
||||
test('seed skipped silently when services file is missing (manual entries only)', async () => {
|
||||
const servicesFile = path.join(os.tmpdir(), `no-such-services-${Date.now()}.json`);
|
||||
({ app, dir } = createApp({ servicesFile }));
|
||||
const res = await request(app).get('/network/resources');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data).toEqual([]);
|
||||
expect(res.body.count).toBe(0);
|
||||
// manual creation still works after a skipped seed
|
||||
const create = await request(app).post('/network/resources')
|
||||
.send({ name: 'manual', host: 'm.internal', port: 8080 });
|
||||
expect(create.status).toBe(201);
|
||||
expect(create.body.resource.manual).toBe(true);
|
||||
});
|
||||
|
||||
test('seed skipped when services file is corrupt JSON (no crash)', async () => {
|
||||
const badDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-badseed-'));
|
||||
const servicesFile = path.join(badDir, 'services.json');
|
||||
fs.writeFileSync(servicesFile, '{not json', 'utf8');
|
||||
({ app, dir } = createApp({ servicesFile }));
|
||||
const res = await request(app).get('/network/resources');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data).toEqual([]);
|
||||
_cleanup(badDir);
|
||||
});
|
||||
|
||||
test('seeds from services registry when present (manual:false)', async () => {
|
||||
const seedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-seed-'));
|
||||
const servicesFile = path.join(seedDir, 'services.json');
|
||||
fs.writeFileSync(servicesFile, JSON.stringify([
|
||||
{ id: 'jellyfin', port: 8096 },
|
||||
{ id: 'sonarr', port: 8989, host: 'sonarr.internal' },
|
||||
{ id: 'bad id!', port: 80 }, // non-slug id → skipped
|
||||
{ id: 'noport' }, // no port → skipped
|
||||
]), 'utf8');
|
||||
({ app, dir } = createApp({ servicesFile }));
|
||||
const res = await request(app).get('/network/resources');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.count).toBe(2);
|
||||
const jf = res.body.data.find(r => r.name === 'jellyfin');
|
||||
expect(jf).toMatchObject({ host: 'localhost', port: 8096, manual: false, sensitive: false });
|
||||
const so = res.body.data.find(r => r.name === 'sonarr');
|
||||
expect(so).toMatchObject({ host: 'sonarr.internal', port: 8989, manual: false });
|
||||
_cleanup(seedDir);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* DC-153 syncAccess contract suite — exercises the src/network/acl-sync.js
|
||||
* adapter (the injectable Tailscale enforcement engine) directly, plus the
|
||||
* cross-provider guarantees the judge gate asked for: shapes pass through,
|
||||
* error classes map to ProviderError codes, success shapes hold, and the
|
||||
* DEFAULT construction paths run the real DC-148/DC-151 engines against
|
||||
* injected coord/exec mocks.
|
||||
*
|
||||
* Provider-level behavior tests live in network-provider-{tailscale,wireguard}.test.js.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { createAclSync } = require('../src/network/acl-sync');
|
||||
const { compileAndValidate, LockoutError } = require('../src/network/acl-compiler');
|
||||
const { AclPushError } = require('../src/network/acl-push');
|
||||
|
||||
function tsInput() {
|
||||
return {
|
||||
accessMap: { grants: [{ principal: { type: 'person', id: 'p1' }, resource: 'app', allowed: true }] },
|
||||
people: [{ id: 'p1', providerUserId: '1001', groups: ['Admin'], status: 'active' }],
|
||||
resources: [{ name: 'app', host: 'host.ts.net', ports: ['*'] }],
|
||||
hostDeviceId: 'dev-host',
|
||||
adminUserIds: ['1001'],
|
||||
hostResource: { host: 'host.ts.net', ports: ['*'] },
|
||||
};
|
||||
}
|
||||
|
||||
describe('acl-sync adapter (createAclSync)', () => {
|
||||
test('compile: runs the real compileAndValidate, returns policy + hash + usersAffected', async () => {
|
||||
const sync = createAclSync({ pusher: {} }); // compile path needs no pusher
|
||||
const out = await sync.compile(tsInput());
|
||||
expect(out.hash).toMatch(/^sha256:[0-9a-f]{64}$/);
|
||||
expect(out.usersAffected).toBe(1); // 1001 only; host device excluded
|
||||
// same policy the raw engine produces
|
||||
expect(out.policy).toEqual(compileAndValidate(tsInput()).policy);
|
||||
});
|
||||
|
||||
test('compile: LockoutError propagates untouched (no admin ids)', async () => {
|
||||
const sync = createAclSync({ pusher: {} });
|
||||
const bad = { ...tsInput(), adminUserIds: [] };
|
||||
await expect(sync.compile(bad)).rejects.toBeInstanceOf(LockoutError);
|
||||
});
|
||||
|
||||
test('preview delegates to the pusher; push goes through the drift guard; getState to getLastKnownGood', async () => {
|
||||
const pusher = {
|
||||
previewPolicy: jest.fn(async () => ({ feasible: true, errors: [] })),
|
||||
pushIfNoDrift: jest.fn(async (policy) => ({ drifted: false, ...(await pusher.pushPolicy(policy)) })),
|
||||
pushPolicy: jest.fn(async () => ({ pushed: true, hash: 'sha256:x' })),
|
||||
getLastKnownGood: jest.fn(() => ({ hash: 'sha256:x', policy: { acls: [] } })),
|
||||
};
|
||||
const sync = createAclSync({ pusher });
|
||||
await expect(sync.preview({ acls: [] })).resolves.toEqual({ feasible: true, errors: [] });
|
||||
await expect(sync.push({ acls: [] })).resolves.toEqual({ drifted: false, pushed: true, hash: 'sha256:x' });
|
||||
expect(sync.getState()).toMatchObject({ hash: 'sha256:x' });
|
||||
expect(pusher.previewPolicy).toHaveBeenCalledWith({ acls: [] });
|
||||
// push now flows through the drift guard, not bare pushPolicy
|
||||
expect(pusher.pushIfNoDrift).toHaveBeenCalledWith({ acls: [] });
|
||||
expect(pusher.pushPolicy).toHaveBeenCalledWith({ acls: [] });
|
||||
});
|
||||
|
||||
test('built from a coordClient: preview POSTs to /tailnet/{t}/acl/preview', async () => {
|
||||
const coord = {
|
||||
request: jest.fn(async (m, p) => {
|
||||
if (p === '/tailnet/-/acl/preview') return { feasible: true, errors: [] };
|
||||
return {};
|
||||
}),
|
||||
};
|
||||
const sync = createAclSync({ coordClient: coord, tailnet: '-' });
|
||||
await sync.preview({ acls: [] });
|
||||
expect(coord.request).toHaveBeenCalledWith('POST', '/tailnet/-/acl/preview', {
|
||||
body: { acls: [] },
|
||||
});
|
||||
});
|
||||
|
||||
test('pusher errors[] surface as AclPushError (caller must refuse to push)', async () => {
|
||||
const coord = {
|
||||
request: jest.fn(async () => ({ feasible: false, errors: ['dst unreachable'] })),
|
||||
};
|
||||
const sync = createAclSync({ coordClient: coord });
|
||||
await expect(sync.preview({ acls: [] })).rejects.toBeInstanceOf(AclPushError);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ── Drift-guard regression (judge r2 blocker 1) ──────────────────────────
|
||||
describe('acl-sync drift guard (pushIfNoDrift wiring)', () => {
|
||||
test('push: drifted remote policy → acl_drift_detected, pushPolicy NEVER called', async () => {
|
||||
const policy = { acls: [] };
|
||||
const pusher = {
|
||||
previewPolicy: jest.fn(async () => ({ feasible: true, errors: [] })),
|
||||
pushIfNoDrift: jest.fn(async () => ({ drifted: true, pushed: false })),
|
||||
pushPolicy: jest.fn(async () => ({ pushed: true, hash: 'h' })),
|
||||
getLastKnownGood: jest.fn(() => null),
|
||||
};
|
||||
const sync = createAclSync({ pusher });
|
||||
await expect(sync.push(policy)).rejects.toMatchObject({
|
||||
code: 'acl_drift_detected',
|
||||
});
|
||||
expect(pusher.pushIfNoDrift).toHaveBeenCalledTimes(1);
|
||||
expect(pusher.pushPolicy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('push: no drift → delegates to success shape', async () => {
|
||||
const pusher = {
|
||||
pushIfNoDrift: jest.fn(async () => ({ drifted: false, pushed: true, hash: 'abc' })),
|
||||
pushPolicy: jest.fn(),
|
||||
getLastKnownGood: jest.fn(() => null),
|
||||
};
|
||||
const sync = createAclSync({ pusher });
|
||||
const result = await sync.push({ acls: [] });
|
||||
expect(result).toEqual({ drifted: false, pushed: true, hash: 'abc' });
|
||||
});
|
||||
|
||||
test('provider: tailscale syncAccess surfaces drifted remote as ProviderError acl_drift_detected (nothing pushed)', async () => {
|
||||
const { createTailscaleProvider } = require('../src/network/providers/tailscale');
|
||||
const { ProviderError } = require('../src/network/errors');
|
||||
const engine = {
|
||||
compile: jest.fn(async () => ({ policy: { acls: [] }, hash: 'h', usersAffected: 1 })),
|
||||
preview: jest.fn(async () => ({ feasible: true, errors: [] })),
|
||||
push: jest.fn(async () => { const e = new Error('drift'); e.code = 'acl_drift_detected'; e.drift = { drifted: true }; throw e; }),
|
||||
getState: jest.fn(() => null),
|
||||
};
|
||||
const provider = createTailscaleProvider({ coordClient: { request: jest.fn() }, aclSync: engine });
|
||||
await expect(provider.syncAccess(tsInput())).rejects.toMatchObject({
|
||||
code: 'acl_drift_detected',
|
||||
retryable: false,
|
||||
});
|
||||
await expect(provider.syncAccess(tsInput())).rejects.toBeInstanceOf(ProviderError);
|
||||
expect(engine.push).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* Tests for src/network/technitium.js (DC-146).
|
||||
*
|
||||
* Hermetic: fetchImpl is a jest.fn() stub, credentialManager and the
|
||||
* network registry are stub objects (same rigor as
|
||||
* __tests__/network-provider-tailscale.test.js). No real Technitium,
|
||||
* no real sockets.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
createTechnitiumClient,
|
||||
createTechnitiumService,
|
||||
} = require('../src/network/technitium');
|
||||
const { ProviderError } = require('../src/network/errors');
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* HTTP stub helpers
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const _JSON_HEADERS = { 'Content-Type': 'application/json' };
|
||||
|
||||
function jsonResponse(status, body) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: 'stub',
|
||||
json: async () => Promise.resolve(body),
|
||||
};
|
||||
}
|
||||
|
||||
function textResponse(status, text) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: text,
|
||||
json: async () => Promise.reject(new Error('not json')),
|
||||
};
|
||||
}
|
||||
|
||||
/** fetch stub that routes by URL path; handlers appended as [matcher, fn]. */
|
||||
function makeFetch(routes = [], { defaultResponse } = {}) {
|
||||
const calls = [];
|
||||
const fn = async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
const u = new URL(String(url));
|
||||
for (const [match, handler] of routes) {
|
||||
if (u.pathname.endsWith(match)) return handler(u, init, calls.length - 1);
|
||||
}
|
||||
if (defaultResponse) return defaultResponse(u, init);
|
||||
return jsonResponse(404, { status: 'error', errorMessage: 'no route' });
|
||||
};
|
||||
fn.mock = {}; // jest.fn()-shape compatibility if inspected
|
||||
fn.calls = calls;
|
||||
return fn;
|
||||
}
|
||||
|
||||
function credMgr(token) {
|
||||
const retrieve = async () => (token === undefined ? null : token);
|
||||
return { retrieve: jest.fn(retrieve) };
|
||||
}
|
||||
|
||||
const noopLogger = { info() {}, warn: jest.fn(), error: jest.fn(), debug() {} };
|
||||
|
||||
function makeClient(routes, { token = 'tok123', host = 'dns.local', ...rest } = {}) {
|
||||
const fetchImpl = typeof routes === 'function' ? routes : makeFetch(routes);
|
||||
const getToken = async () => token;
|
||||
const client = createTechnitiumClient({
|
||||
host,
|
||||
getToken,
|
||||
fetchImpl,
|
||||
logger: noopLogger,
|
||||
...rest,
|
||||
});
|
||||
return { client, fetchImpl };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Client: zones
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
describe('technitium client: listZones + ensureZone idempotency', () => {
|
||||
test('listZones normalizes the response array', async () => {
|
||||
const { client } = makeClient([
|
||||
['/zones/list', () => jsonResponse(200, { status: 'ok', response: [{ name: 'corp', type: 'Primary' }] })],
|
||||
]);
|
||||
const zones = await client.listZones();
|
||||
expect(zones).toEqual([{ name: 'corp', type: 'Primary' }]);
|
||||
});
|
||||
|
||||
test('ensureZone creates a Primary zone only when absent', async () => {
|
||||
const zones = [];
|
||||
const fetchImpl = makeFetch([
|
||||
['/zones/list', () => jsonResponse(200, { status: 'ok', response: zones })],
|
||||
['/zones/create', () => { zones.push({ name: 'corp', type: 'Primary' }); return jsonResponse(200, { status: 'ok' }); }],
|
||||
]);
|
||||
const client = createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl });
|
||||
|
||||
const first = await client.ensureZone('corp');
|
||||
expect(first).toEqual({ created: true, unchanged: false });
|
||||
expect(fetchImpl.calls.filter((c) => c.url.includes('/zones/create'))).toHaveLength(1);
|
||||
|
||||
const second = await client.ensureZone('corp'); // now present
|
||||
expect(second).toEqual({ created: false, unchanged: true });
|
||||
expect(fetchImpl.calls.filter((c) => c.url.includes('/zones/create'))).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Client: upsertRecord three paths
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
describe('technitium client: upsertRecord', () => {
|
||||
function recordApi(existing, handlers = {}) {
|
||||
let rec = existing;
|
||||
const routes = [
|
||||
['/zones/records/get', (u) => {
|
||||
if (handlers.get) return handlers.get(u);
|
||||
if (!rec) return jsonResponse(200, { status: 'error', errorMessage: 'Record not found.' });
|
||||
return jsonResponse(200, { status: 'ok', response: rec });
|
||||
}],
|
||||
['/zones/records/delete', (u, init) => {
|
||||
if (handlers.del) return handlers.del(u, init);
|
||||
rec = null;
|
||||
return jsonResponse(200, { status: 'ok' });
|
||||
}],
|
||||
['/zones/records/add', (u, init) => {
|
||||
if (handlers.add) return handlers.add(u, init);
|
||||
const body = JSON.parse(init.body);
|
||||
rec = { name: body.name, type: body.type, rData: { ipAddress: body.ipAddress } };
|
||||
return jsonResponse(200, { status: 'ok' });
|
||||
}],
|
||||
];
|
||||
return makeFetch(routes);
|
||||
}
|
||||
|
||||
test('absent record → created (single add, no delete)', async () => {
|
||||
const fetchImpl = recordApi(null);
|
||||
const client = createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl });
|
||||
const out = await client.upsertRecord({ zone: 'corp', name: 'app', type: 'A', rData: { ipAddress: '10.53.0.5' } });
|
||||
expect(out).toEqual({ created: true });
|
||||
expect(fetchImpl.calls.filter((c) => c.url.includes('/add'))).toHaveLength(1);
|
||||
expect(fetchImpl.calls.filter((c) => c.url.includes('/delete'))).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('present record with same rData → unchanged no-op (no add, no delete)', async () => {
|
||||
const fetchImpl = recordApi({ name: 'app', type: 'A', rData: { ipAddress: '10.53.0.5' } });
|
||||
const client = createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl });
|
||||
const out = await client.upsertRecord({ zone: 'corp', name: 'app', type: 'A', rData: { ipAddress: '10.53.0.5' } });
|
||||
expect(out).toEqual({ unchanged: true });
|
||||
expect(fetchImpl.calls).toHaveLength(1); // only the get
|
||||
});
|
||||
|
||||
test('present record with different rData → delete + add → updated', async () => {
|
||||
const fetchImpl = recordApi({ name: 'app', type: 'A', rData: { ipAddress: '10.53.0.9' } });
|
||||
const client = createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl });
|
||||
const out = await client.upsertRecord({ zone: 'corp', name: 'app', type: 'A', rData: { ipAddress: '10.53.0.5' } });
|
||||
expect(out).toEqual({ updated: true });
|
||||
const add = fetchImpl.calls.find((c) => c.url.includes('/add'));
|
||||
expect(JSON.parse(add.init.body)).toMatchObject({ zone: 'corp', name: 'app', type: 'A', ipAddress: '10.53.0.5' });
|
||||
expect(fetchImpl.calls.some((c) => c.url.includes('/delete'))).toBe(true);
|
||||
});
|
||||
|
||||
test('auth token rides on every request (query param on GET, body on POST)', async () => {
|
||||
const fetchImpl = recordApi(null);
|
||||
const client = createTechnitiumClient({ host: 'h', getToken: async () => 'sekrit', fetchImpl });
|
||||
await client.upsertRecord({ zone: 'corp', name: 'app', type: 'A', rData: { ipAddress: '10.53.0.5' } });
|
||||
const get = fetchImpl.calls[0];
|
||||
expect(new URL(get.url).searchParams.get('token')).toBe('sekrit');
|
||||
const add = fetchImpl.calls.find((c) => c.url.includes('/add'));
|
||||
expect(JSON.parse(add.init.body).token).toBe('sekrit');
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Client: error mapping + timeout
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
describe('technitium client: error mapping', () => {
|
||||
test('HTTP 401/403 → ProviderError auth_failed', async () => {
|
||||
const { client } = makeClient([
|
||||
['/zones/list', () => jsonResponse(401, { status: 'error', errorMessage: 'Unauthorized' })],
|
||||
]);
|
||||
await expect(client.listZones()).rejects.toMatchObject({ code: 'auth_failed', name: 'ProviderError' });
|
||||
});
|
||||
|
||||
test('envelope status:"error" with "not found" → not_found (getRecord → null)', async () => {
|
||||
const { client } = makeClient([
|
||||
['/zones/records/get', () => jsonResponse(200, { status: 'error', errorMessage: 'Record not found.' })],
|
||||
]);
|
||||
await expect(client.getRecord('corp', 'app', 'A')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
test('envelope token error → auth_failed', async () => {
|
||||
const { client } = makeClient([
|
||||
['/zones/list', () => jsonResponse(200, { status: 'error', errorMessage: 'Invalid token' })],
|
||||
]);
|
||||
await expect(client.listZones()).rejects.toMatchObject({ code: 'auth_failed' });
|
||||
});
|
||||
|
||||
test('HTTP 500 → api_error; transport throw → network_error', async () => {
|
||||
const { client: c1 } = makeClient([
|
||||
['/zones/list', () => textResponse(500, 'boom')],
|
||||
]);
|
||||
await expect(c1.listZones()).rejects.toMatchObject({ code: 'api_error' });
|
||||
|
||||
const fetchThrows = jest.fn(async () => { const e = new Error('ECONNREFUSED'); e.name = 'FetchError'; throw e; });
|
||||
const c2 = createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl: fetchThrows });
|
||||
await expect(c2.listZones()).rejects.toMatchObject({ code: 'network_error' });
|
||||
});
|
||||
|
||||
test('timeout: fetch that never settles → network_error within the budget', async () => {
|
||||
const fetchImpl = jest.fn(() => new Promise(() => {})); // hangs forever
|
||||
const client = createTechnitiumClient({
|
||||
host: 'h',
|
||||
getToken: async () => 't',
|
||||
fetchImpl,
|
||||
timeoutMs: 50,
|
||||
});
|
||||
const t0 = Date.now();
|
||||
await expect(client.listZones()).rejects.toMatchObject({ code: 'network_error' });
|
||||
expect(Date.now() - t0).toBeLessThan(2000);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Service: syncRecords
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
describe('technitium service: syncRecords', () => {
|
||||
function serviceWith(existingByFqdn) {
|
||||
const state = { ...existingByFqdn };
|
||||
const fetchImpl = makeFetch([
|
||||
['/zones/records/get', (u) => {
|
||||
const name = u.searchParams.get('name');
|
||||
const rec = state[name];
|
||||
if (!rec) return jsonResponse(200, { status: 'error', errorMessage: 'Record not found.' });
|
||||
return jsonResponse(200, { status: 'ok', response: rec });
|
||||
}],
|
||||
['/zones/records/delete', (u, init) => {
|
||||
const body = JSON.parse(init.body);
|
||||
delete state[body.name];
|
||||
return jsonResponse(200, { status: 'ok' });
|
||||
}],
|
||||
['/zones/records/add', (u, init) => {
|
||||
const body = JSON.parse(init.body);
|
||||
state[body.name] = { name: body.name, type: body.type, rData: { ipAddress: body.ipAddress } };
|
||||
return jsonResponse(200, { status: 'ok' });
|
||||
}],
|
||||
]);
|
||||
const svc = createTechnitiumService({
|
||||
client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl }),
|
||||
logger: noopLogger,
|
||||
});
|
||||
return { svc, state };
|
||||
}
|
||||
|
||||
test('resources + devices → name.zone records; three upsert paths in one batch', async () => {
|
||||
const { svc } = serviceWith({ 'app': { name: 'app', type: 'A', rData: { ipAddress: '10.53.0.5' } } });
|
||||
const out = await svc.syncRecords({
|
||||
zone: 'corp',
|
||||
providerMode: 'wireguard',
|
||||
resources: [
|
||||
{ name: 'app', ip: '10.53.0.5' }, // unchanged
|
||||
{ name: 'git', ip: '10.53.1.7' }, // created
|
||||
],
|
||||
devices: [
|
||||
{ slug: 'laptop', ip: '10.53.2.9' }, // created
|
||||
],
|
||||
});
|
||||
expect(out.zone).toBe('corp');
|
||||
expect(out.count).toBe(3);
|
||||
expect(Object.fromEntries(out.results.map((r) => [r.name, r.action]))).toEqual({
|
||||
'app.corp': 'unchanged',
|
||||
'git.corp': 'created',
|
||||
'laptop.corp': 'created',
|
||||
});
|
||||
});
|
||||
|
||||
test('tailscale mode prefers tailnetIp; wireguard mode prefers ip', async () => {
|
||||
const seen = {};
|
||||
const fetchImpl = makeFetch([
|
||||
['/zones/records/get', () => jsonResponse(200, { status: 'error', errorMessage: 'Record not found.' })],
|
||||
['/zones/records/add', (u, init) => {
|
||||
const b = JSON.parse(init.body);
|
||||
seen[b.name] = b.ipAddress;
|
||||
return jsonResponse(200, { status: 'ok' });
|
||||
}],
|
||||
]);
|
||||
const svc = createTechnitiumService({
|
||||
client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl }),
|
||||
logger: noopLogger,
|
||||
});
|
||||
const entry = { name: 'nas', tailnetIp: '100.91.55.51', ip: '10.53.0.9' };
|
||||
await svc.syncRecords({ zone: 'corp', providerMode: 'tailscale', resources: [entry] });
|
||||
expect(seen.nas).toBe('100.91.55.51');
|
||||
await svc.syncRecords({ zone: 'corp', providerMode: 'wireguard', resources: [entry] });
|
||||
expect(seen.nas).toBe('10.53.0.9');
|
||||
});
|
||||
|
||||
test('per-record failure isolation: one bad record does not block the rest', async () => {
|
||||
let n = 0;
|
||||
const fetchImpl = makeFetch([
|
||||
['/zones/records/get', () => {
|
||||
n += 1;
|
||||
if (n === 1) return Promise.reject(new Error('ECONNRESET')); // first record fails at transport
|
||||
return jsonResponse(200, { status: 'error', errorMessage: 'Record not found.' });
|
||||
}],
|
||||
['/zones/records/add', () => jsonResponse(200, { status: 'ok' })],
|
||||
]);
|
||||
const logger = { ...noopLogger, warn: jest.fn() };
|
||||
const svc = createTechnitiumService({
|
||||
client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl }),
|
||||
logger,
|
||||
});
|
||||
const out = await svc.syncRecords({
|
||||
zone: 'corp',
|
||||
resources: [{ name: 'bad', ip: '10.53.0.1' }, { name: 'ok', ip: '10.53.0.2' }],
|
||||
});
|
||||
expect(out.count).toBe(2);
|
||||
expect(out.results[0]).toMatchObject({ name: 'bad.corp', action: 'failed' });
|
||||
expect(typeof out.results[0].error).toBe('string');
|
||||
expect(out.results[0].error).toContain('network_error');
|
||||
expect(out.results[1]).toEqual({ name: 'ok.corp', action: 'created' });
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('missing zone → bad_input ProviderError', async () => {
|
||||
const svc = createTechnitiumService({
|
||||
client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl: makeFetch([]) }),
|
||||
logger: noopLogger,
|
||||
});
|
||||
await expect(svc.syncRecords({ resources: [] })).rejects.toMatchObject({ code: 'bad_input' });
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Service: verifyResolution
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
describe('technitium service: verifyResolution', () => {
|
||||
test('falls back to API read-back when no dnsServer (method noted)', async () => {
|
||||
const fetchImpl = makeFetch([
|
||||
['/zones/records/get', () => jsonResponse(200, { status: 'ok', response: { name: 'app', type: 'A', rData: { ipAddress: '10.53.0.5' } } })],
|
||||
]);
|
||||
const svc = createTechnitiumService({
|
||||
client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl }),
|
||||
logger: noopLogger,
|
||||
});
|
||||
const out = await svc.verifyResolution({ name: 'app.corp', type: 'A', expectedIp: '10.53.0.5' });
|
||||
expect(out.method).toBe('api-readback');
|
||||
expect(out.verified).toBe(true);
|
||||
});
|
||||
|
||||
test('read-back mismatch → verified:false', async () => {
|
||||
const fetchImpl = makeFetch([
|
||||
['/zones/records/get', () => jsonResponse(200, { status: 'ok', response: { rData: { ipAddress: '10.53.0.99' } } })],
|
||||
]);
|
||||
const svc = createTechnitiumService({
|
||||
client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl }),
|
||||
logger: noopLogger,
|
||||
});
|
||||
const out = await svc.verifyResolution({ name: 'app.corp', expectedIp: '10.53.0.5' });
|
||||
expect(out.verified).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Service: configureSplitDns capability gating
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
describe('technitium service: configureSplitDns capability gate', () => {
|
||||
function providerStub({ splitDnsApi, id = 'stub' }) {
|
||||
return {
|
||||
id: () => id,
|
||||
capabilities: () => ({ invites: true, splitDnsApi, perPeerFirewall: false, selfHosted: true }),
|
||||
configureDns: jest.fn(async ({ zone, server }) => ({ delegated: true, zone, server })),
|
||||
};
|
||||
}
|
||||
|
||||
test('tailscale (splitDnsApi:true) → delegates to provider.configureDns', async () => {
|
||||
const ts = providerStub({ splitDnsApi: true, id: 'tailscale' });
|
||||
const svc = createTechnitiumService({
|
||||
client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl: makeFetch([]) }),
|
||||
getActiveProvider: () => ts,
|
||||
logger: noopLogger,
|
||||
});
|
||||
const out = await svc.configureSplitDns({ zone: 'corp', dnsServerIp: '10.53.0.1' });
|
||||
expect(out).toMatchObject({ configured: true, provider: 'tailscale' });
|
||||
expect(ts.configureDns).toHaveBeenCalledWith({ zone: 'corp', server: '10.53.0.1' });
|
||||
});
|
||||
|
||||
test('wireguard (splitDnsApi:false) → {configured:false, reason} per spec', async () => {
|
||||
const wg = providerStub({ splitDnsApi: false, id: 'wireguard' });
|
||||
const svc = createTechnitiumService({
|
||||
client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl: makeFetch([]) }),
|
||||
getActiveProvider: () => wg,
|
||||
logger: noopLogger,
|
||||
});
|
||||
const out = await svc.configureSplitDns({ zone: 'corp', dnsServerIp: '10.53.0.1' });
|
||||
expect(out).toEqual({
|
||||
configured: false,
|
||||
reason: 'splitDnsApi unsupported — set DNS in client config',
|
||||
});
|
||||
});
|
||||
|
||||
test('no active provider → same unsupported result, no throw', async () => {
|
||||
const svc = createTechnitiumService({
|
||||
client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl: makeFetch([]) }),
|
||||
getActiveProvider: () => { throw new ProviderError('none', { code: 'provider_not_active' }); },
|
||||
logger: noopLogger,
|
||||
});
|
||||
await expect(svc.configureSplitDns({ zone: 'corp', dnsServerIp: '10.53.0.1' })).resolves.toMatchObject({
|
||||
configured: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Service: checkSetup (wizard, DC-152)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
describe('technitium service: checkSetup red/green', () => {
|
||||
test('green: reachable + valid token + zone present', async () => {
|
||||
const fetchImpl = makeFetch([
|
||||
['/zones/list', () => jsonResponse(200, { status: 'ok', response: [{ name: 'corp', type: 'Primary' }] })],
|
||||
]);
|
||||
const svc = createTechnitiumService({
|
||||
client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl }),
|
||||
logger: noopLogger,
|
||||
});
|
||||
await expect(svc.checkSetup({ zone: 'corp' })).resolves.toEqual({
|
||||
apiReachable: true,
|
||||
tokenValid: true,
|
||||
zoneExists: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('red: unreachable → apiReachable:false, tokenValid:false', async () => {
|
||||
const fetchImpl = jest.fn(async () => { const e = new Error('connect ECONNREFUSED'); e.name = 'FetchError'; throw e; });
|
||||
const svc = createTechnitiumService({
|
||||
client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl }),
|
||||
logger: noopLogger,
|
||||
});
|
||||
const out = await svc.checkSetup({ zone: 'corp' });
|
||||
expect(out).toMatchObject({ apiReachable: false, tokenValid: false, zoneExists: false });
|
||||
expect(out.error).toContain('network_error');
|
||||
});
|
||||
|
||||
test('yellow: reachable but bad token → apiReachable:true, tokenValid:false', async () => {
|
||||
const fetchImpl = makeFetch([
|
||||
['/zones/list', () => jsonResponse(401, { status: 'error', errorMessage: 'Unauthorized' })],
|
||||
]);
|
||||
const svc = createTechnitiumService({
|
||||
client: createTechnitiumClient({ host: 'h', getToken: async () => 'bad', fetchImpl }),
|
||||
logger: noopLogger,
|
||||
});
|
||||
const out = await svc.checkSetup({ zone: 'corp' });
|
||||
expect(out).toMatchObject({ apiReachable: true, tokenValid: false, zoneExists: false });
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Service: credential-manager wiring
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
describe('technitium service: credential wiring', () => {
|
||||
test('token resolved from credentialManager.retrieve("technitium"); {token} object supported', async () => {
|
||||
const cm = credMgr({ token: 'obj-tok' });
|
||||
const fetchImpl = makeFetch([
|
||||
['/zones/list', () => jsonResponse(200, { status: 'ok', response: [] })],
|
||||
]);
|
||||
const svc = createTechnitiumService({
|
||||
credentialManager: cm,
|
||||
config: { technitium: { host: 'dns.internal' } },
|
||||
fetchImpl,
|
||||
logger: noopLogger,
|
||||
});
|
||||
await svc.listZones();
|
||||
expect(cm.retrieve).toHaveBeenCalledWith('technitium');
|
||||
expect(new URL(fetchImpl.calls[0].url).searchParams.get('token')).toBe('obj-tok');
|
||||
});
|
||||
|
||||
test('client construction requires host or baseUrl (never hardcoded)', () => {
|
||||
expect(() => createTechnitiumClient({ getToken: async () => 't', fetchImpl: makeFetch([]) }))
|
||||
.toThrow(ProviderError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* Tests for src/network/wg-firewall.js (DC-151).
|
||||
* Coverage:
|
||||
* - golden ruleset text: single resource single grant, group grant
|
||||
* expansion, blocked person absent from ALL sets, default-deny tails,
|
||||
* admin ports only from admin set
|
||||
* - corrected baseline: WG UDP handshake rule NOT bound to dcnet0
|
||||
* (externalInterface scoping + null → unscoped), peer-to-peer forward
|
||||
* default-deny (oifname dcnet0 drop)
|
||||
* - name sanitization: 'plex-2!' → net_dc_res_plex_2, collision guard
|
||||
* - admin lockout guard; assertAdminIsolation (positive + adversarial)
|
||||
* - transactional apply (mocked execFile + fs): check-failure → no apply
|
||||
* + temp deleted; apply-failure → {applied:false} + rollback file
|
||||
* unchanged; verify-mismatch → rollback attempted; rollback() re-applies
|
||||
* previous; unique temp paths across applies
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
compileRuleset,
|
||||
createWgFirewallApplier,
|
||||
assertAdminIsolation,
|
||||
resourceSetName,
|
||||
FirewallCheckError,
|
||||
FirewallApplyError,
|
||||
} = require('../src/network/wg-firewall');
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const PEOPLE = [
|
||||
{
|
||||
id: 'admin1',
|
||||
displayName: 'Admin',
|
||||
groups: ['family'],
|
||||
status: 'active',
|
||||
devices: [{ keyId: 'k1', name: 'laptop', ip: '10.53.0.10' }],
|
||||
},
|
||||
{
|
||||
id: 'alice',
|
||||
displayName: 'Alice',
|
||||
groups: ['family'],
|
||||
status: 'active',
|
||||
devices: [{ keyId: 'k2', name: 'phone', ip: '10.53.1.20' }],
|
||||
},
|
||||
{
|
||||
id: 'bob',
|
||||
displayName: 'Bob',
|
||||
groups: [],
|
||||
status: 'active',
|
||||
devices: [{ keyId: 'k3', name: 'pc', ip: '10.53.2.30' }],
|
||||
},
|
||||
{
|
||||
id: 'mallory',
|
||||
displayName: 'Mallory',
|
||||
groups: ['family'],
|
||||
status: 'blocked',
|
||||
devices: [{ keyId: 'k4', name: 'tab', ip: '10.53.3.40' }],
|
||||
},
|
||||
];
|
||||
|
||||
const PEERS = {
|
||||
'admin1/dev1': '10.53.0.10',
|
||||
'alice/dev1': '10.53.1.20',
|
||||
'bob/dev1': '10.53.2.30',
|
||||
'mallory/dev1': '10.53.3.40',
|
||||
};
|
||||
|
||||
const RESOURCES = {
|
||||
plex: { ip: '10.53.0.1', port: 32400, protocol: 'tcp' },
|
||||
nas: { ip: '10.53.9.9', port: 445, protocol: 'tcp' },
|
||||
};
|
||||
|
||||
const BASE = {
|
||||
accessMap: { grants: [] },
|
||||
people: PEOPLE,
|
||||
peerAddresses: PEERS,
|
||||
resources: RESOURCES,
|
||||
adminPeers: ['admin1'],
|
||||
};
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Compiler: golden ruleset text
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('wg-firewall: compileRuleset golden text', () => {
|
||||
test('single resource single grant — exact set element + accept rule', () => {
|
||||
const text = compileRuleset({
|
||||
...BASE,
|
||||
accessMap: {
|
||||
grants: [{ principal: { type: 'person', id: 'alice' }, resource: 'plex', allowed: true }],
|
||||
},
|
||||
resources: { plex: RESOURCES.plex },
|
||||
});
|
||||
expect(text).toContain('set net_dc_res_plex {');
|
||||
expect(text).toContain('elements = { 10.53.1.20 }');
|
||||
expect(text).toContain(
|
||||
'ip saddr @net_dc_res_plex ip daddr 10.53.0.1 tcp dport 32400 accept comment "resource plex"'
|
||||
);
|
||||
// blocked + ungranted peers are NOT in the plex resource set
|
||||
const plexSet = text.split('set net_dc_res_plex {')[1].split('}')[0];
|
||||
expect(plexSet).not.toContain('10.53.3.40'); // mallory
|
||||
expect(plexSet).not.toContain('10.53.2.30'); // bob
|
||||
expect(plexSet).not.toContain('10.53.0.10'); // admin not granted plex → not in res set
|
||||
expect(plexSet).toContain('elements = { 10.53.1.20');
|
||||
});
|
||||
|
||||
test('group grant expands to every active member (person-override honored)', () => {
|
||||
const text = compileRuleset({
|
||||
...BASE,
|
||||
accessMap: {
|
||||
grants: [
|
||||
{ principal: { type: 'group', id: 'family' }, resource: 'nas', allowed: true },
|
||||
{ principal: { type: 'person', id: 'alice' }, resource: 'nas', allowed: false },
|
||||
],
|
||||
},
|
||||
resources: { nas: RESOURCES.nas },
|
||||
});
|
||||
// family = admin1, alice, mallory(blocked); alice overridden to false
|
||||
expect(text).toMatch(/set net_dc_res_nas \{\s*\n\s*type ipv4_addr\s*\n\s*elements = \{ 10\.53\.0\.10 \}/);
|
||||
expect(text).toContain('ip saddr @net_dc_res_nas ip daddr 10.53.9.9 tcp dport 445 accept');
|
||||
});
|
||||
|
||||
test('blocked person absent from ALL sets (resource sets and admin set)', () => {
|
||||
const text = compileRuleset({
|
||||
...BASE,
|
||||
accessMap: {
|
||||
grants: [{ principal: { type: 'group', id: 'family' }, resource: 'plex', allowed: true }],
|
||||
},
|
||||
adminPeers: ['admin1', 'mallory'], // mallory blocked → must NOT get admin
|
||||
});
|
||||
const adminSetMatch = text.match(/set net_dc_admins \{[^}]*elements = \{ ([^}]*) \}/);
|
||||
expect(adminSetMatch).toBeTruthy();
|
||||
const adminEls = adminSetMatch[1].split(', ');
|
||||
expect(adminEls).toEqual(['10.53.0.10']); // mallory's 10.53.3.40 excluded
|
||||
expect(adminEls).not.toContain('10.53.3.40');
|
||||
});
|
||||
|
||||
test('default-deny tails present: hub input, peer egress, peer-to-peer', () => {
|
||||
const text = compileRuleset(BASE);
|
||||
expect(text).toContain('ct state new drop comment "default-deny: hub input"');
|
||||
expect(text).toContain('drop comment "default-deny: peer egress without grant"');
|
||||
expect(text).toContain('drop comment "default-deny: peer-to-peer / new hub-to-peer"');
|
||||
});
|
||||
|
||||
test('admin ports only from admin set (3001 + 22)', () => {
|
||||
const text = compileRuleset(BASE);
|
||||
expect(text).toContain(
|
||||
'iifname "dcnet0" ip saddr @net_dc_admins tcp dport { 22, 3001 } accept comment "admin ports: admin peers only"'
|
||||
);
|
||||
});
|
||||
|
||||
test('table declared as inet dcnet with destroy-first', () => {
|
||||
const text = compileRuleset(BASE);
|
||||
expect(text).toContain('destroy table inet dcnet');
|
||||
expect(text).toContain('table inet dcnet {');
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Compiler: corrected baseline (judge gate)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('wg-firewall: WG UDP handshake + peer-to-peer baseline (corrected)', () => {
|
||||
test('UDP allow rule is NOT bound to dcnet0 — scoped to external interface', () => {
|
||||
const text = compileRuleset({ ...BASE, externalInterface: 'eth0' });
|
||||
const udpLines = text.split('\n').filter((l) => l.includes('udp dport 51820'));
|
||||
expect(udpLines).toHaveLength(1);
|
||||
expect(udpLines[0]).toContain('iifname "eth0"');
|
||||
expect(udpLines[0]).not.toContain('dcnet0');
|
||||
});
|
||||
|
||||
test('externalInterface null → unscoped UDP rule with comment', () => {
|
||||
const text = compileRuleset(BASE);
|
||||
const udpLines = text.split('\n').filter((l) => l.includes('udp dport 51820'));
|
||||
expect(udpLines).toHaveLength(1);
|
||||
expect(udpLines[0]).not.toContain('iifname');
|
||||
expect(udpLines[0]).toContain('external iface unspecified');
|
||||
});
|
||||
|
||||
test('custom listen port honored', () => {
|
||||
const text = compileRuleset({ ...BASE, wgListenPort: 51821, externalInterface: 'ens3' });
|
||||
expect(text).toContain('iifname "ens3" udp dport 51821 accept');
|
||||
});
|
||||
|
||||
test('peer-to-peer forwarding default-deny: oifname dcnet0 drop tail', () => {
|
||||
const text = compileRuleset(BASE);
|
||||
expect(text).toContain('oifname "dcnet0" drop');
|
||||
// and no accept rule that would let peer→peer through ahead of it
|
||||
const forward = text.split('chain forward {')[1].split('}')[0];
|
||||
expect(forward).not.toMatch(/oifname "dcnet0".*accept/);
|
||||
});
|
||||
|
||||
test('hub-local resource lands in input chain; peer resource in forward chain', () => {
|
||||
const text = compileRuleset({
|
||||
...BASE,
|
||||
accessMap: {
|
||||
grants: [{ principal: { type: 'person', id: 'alice' }, resource: 'nas', allowed: true }],
|
||||
},
|
||||
});
|
||||
const input = text.split('chain input {')[1].split('chain forward')[0];
|
||||
const forward = text.split('chain forward {')[1];
|
||||
expect(input).toContain('@net_dc_res_plex');
|
||||
expect(input).not.toContain('@net_dc_res_nas');
|
||||
expect(forward).toContain('@net_dc_res_nas');
|
||||
expect(forward).not.toContain('@net_dc_res_plex');
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Compiler: sanitization + guards
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('wg-firewall: sanitization and guards', () => {
|
||||
test("resource 'plex-2!' sanitizes to a safe set name", () => {
|
||||
expect(resourceSetName('plex-2!')).toBe('net_dc_res_plex_2');
|
||||
const text = compileRuleset({
|
||||
...BASE,
|
||||
resources: { 'plex-2!': { ip: '10.53.0.1', port: 32400 } },
|
||||
accessMap: {
|
||||
grants: [{ principal: { type: 'person', id: 'alice' }, resource: 'plex-2!', allowed: true }],
|
||||
},
|
||||
});
|
||||
expect(text).toContain('set net_dc_res_plex_2 {');
|
||||
expect(text).toContain('@net_dc_res_plex_2');
|
||||
expect(text).toContain('ip saddr @net_dc_res_plex_2 ip daddr 10.53.0.1 tcp dport 32400 accept');
|
||||
});
|
||||
|
||||
test('set-name collision between distinct resources throws', () => {
|
||||
expect(() =>
|
||||
compileRuleset({ ...BASE, resources: { 'plex 1': { ip: '10.53.0.1', port: 1 }, 'plex!1': { ip: '10.53.0.2', port: 2 } } })
|
||||
).toThrow(/collision/);
|
||||
});
|
||||
|
||||
test('admin lockout guard: zero resolvable admin peers throws', () => {
|
||||
expect(() => compileRuleset({ ...BASE, adminPeers: ['ghost'] })).toThrow(/lockout/);
|
||||
});
|
||||
|
||||
test('invalid resource (missing ip / bad port) throws', () => {
|
||||
expect(() => compileRuleset({ ...BASE, resources: { x: { port: 80 } } })).toThrow(/ip required/);
|
||||
expect(() => compileRuleset({ ...BASE, resources: { x: { ip: '10.53.0.1', port: 0 } } })).toThrow(/port/);
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// assertAdminIsolation
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('wg-firewall: assertAdminIsolation', () => {
|
||||
test('passes on a correctly compiled ruleset', () => {
|
||||
const text = compileRuleset(BASE);
|
||||
expect(assertAdminIsolation(text, { adminPeers: ['admin1'] })).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects an admin port allowed without the admin set', () => {
|
||||
const bad = 'iifname "dcnet0" tcp dport 22 accept';
|
||||
expect(() => assertAdminIsolation(bad)).toThrow(/without @net_dc_admins/);
|
||||
});
|
||||
|
||||
test('rejects an admin port allowed from a resource set', () => {
|
||||
const bad = 'iifname "dcnet0" ip saddr @net_dc_res_plex tcp dport 3001 accept';
|
||||
expect(() => assertAdminIsolation(bad)).toThrow(/resource set/);
|
||||
});
|
||||
|
||||
test('rejects an admin port allowed from a literal peer IP', () => {
|
||||
const bad = 'iifname "dcnet0" ip saddr 10.53.4.4 tcp dport 22 accept';
|
||||
expect(() => assertAdminIsolation(bad)).toThrow(/literal peer saddr/);
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Transactional applier (hermetic: mocked execFile + fs)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
function mockFs() {
|
||||
const files = new Map();
|
||||
return {
|
||||
files,
|
||||
writeFileSync: jest.fn((p, c) => files.set(p, c)),
|
||||
readFileSync: jest.fn((p) => {
|
||||
if (!files.has(p)) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; }
|
||||
return files.get(p);
|
||||
}),
|
||||
existsSync: jest.fn((p) => files.has(p)),
|
||||
unlinkSync: jest.fn((p) => files.delete(p)),
|
||||
};
|
||||
}
|
||||
|
||||
/** execFile mock with a per-call script of behaviors keyed by args. */
|
||||
function mockExec({ current = 'table inet dcnet { }', checkErr, applyErr, verifyOut } = {}) {
|
||||
const calls = [];
|
||||
const impl = async (cmd, args) => { // eslint-disable-line require-await -- scripted mock, no awaits by design
|
||||
calls.push({ cmd, args: [...args] });
|
||||
const joined = args.join(' ');
|
||||
if (joined.startsWith('-c -f')) {
|
||||
if (checkErr) {
|
||||
const e = new Error('check failed');
|
||||
e.stderr = 'syntax error near unexpected token';
|
||||
throw e;
|
||||
}
|
||||
return { stdout: '' };
|
||||
}
|
||||
if (joined.startsWith('-f')) {
|
||||
if (applyErr) {
|
||||
const e = new Error('apply failed');
|
||||
e.stderr = 'operation failed: Device or resource busy';
|
||||
throw e;
|
||||
}
|
||||
return { stdout: '' };
|
||||
}
|
||||
if (joined.startsWith('list table')) {
|
||||
if (verifyOut !== undefined) return { stdout: verifyOut };
|
||||
return { stdout: current };
|
||||
}
|
||||
return { stdout: '' };
|
||||
};
|
||||
return { impl, calls };
|
||||
}
|
||||
|
||||
const RULESET = compileRuleset(BASE);
|
||||
const RULESET2 = compileRuleset({
|
||||
...BASE,
|
||||
accessMap: { grants: [{ principal: { type: 'person', id: 'bob' }, resource: 'plex', allowed: true }] },
|
||||
});
|
||||
|
||||
describe('wg-firewall: transactional apply', () => {
|
||||
test('happy path: check → rollback write → apply → verify; temp cleaned up', async () => {
|
||||
const fs = mockFs();
|
||||
const { impl, calls } = mockExec();
|
||||
const applier = createWgFirewallApplier({
|
||||
execFileImpl: impl,
|
||||
fsImpl: fs,
|
||||
tempDir: '/tmp/x',
|
||||
rollbackPath: '/tmp/x/rollback.nft',
|
||||
});
|
||||
const res = await applier.apply(RULESET);
|
||||
expect(res.applied).toBe(true);
|
||||
expect(res.verified).toBe(true);
|
||||
// order: check(-c -f) before rollback write before apply(-f) before verify
|
||||
const checkIdx = calls.findIndex((c) => c.args[0] === '-c');
|
||||
const applyIdx = calls.findIndex((c) => c.args[0] === '-f');
|
||||
const listIdxs = calls.map((c, i) => (c.args[0] === 'list' ? i : -1)).filter((i) => i >= 0);
|
||||
expect(checkIdx).toBe(0);
|
||||
expect(applyIdx).toBeGreaterThan(checkIdx);
|
||||
// one `list` before apply (capture previous) + one after (verify)
|
||||
expect(listIdxs).toHaveLength(2);
|
||||
expect(listIdxs[0]).toBeLessThan(applyIdx);
|
||||
expect(listIdxs[1]).toBeGreaterThan(applyIdx);
|
||||
const writePaths = fs.writeFileSync.mock.calls.map((c) => c[0]);
|
||||
const tempWrites = writePaths.filter((p) => p !== '/tmp/x/rollback.nft');
|
||||
expect(tempWrites).toHaveLength(1);
|
||||
const tempPath = tempWrites[0];
|
||||
// write order: temp (for check) then rollback BEFORE the apply exec
|
||||
expect(writePaths).toEqual([tempPath, '/tmp/x/rollback.nft']);
|
||||
const applyCall = calls[applyIdx];
|
||||
expect(applyCall.args).toEqual(['-f', tempPath]);
|
||||
expect(applyCall.args[1]).not.toBe('/tmp/x/rollback.nft');
|
||||
// rollback file holds the PREVIOUS table dump
|
||||
expect(fs.readFileSync('/tmp/x/rollback.nft')).toBe('table inet dcnet { }');
|
||||
// temp file deleted at the end
|
||||
expect(fs.existsSync(tempPath)).toBe(false);
|
||||
expect(res.previousExisted).toBe(true);
|
||||
});
|
||||
|
||||
test('check failure → no apply, temp deleted, FirewallCheckError with stderr', async () => {
|
||||
const fs = mockFs();
|
||||
const { impl, calls } = mockExec({ checkErr: true });
|
||||
const applier = createWgFirewallApplier({
|
||||
execFileImpl: impl, fsImpl: fs, tempDir: '/tmp/x', rollbackPath: '/tmp/x/rollback.nft',
|
||||
});
|
||||
await expect(applier.apply(RULESET)).rejects.toMatchObject({
|
||||
name: 'FirewallCheckError',
|
||||
code: 'check_failed',
|
||||
applied: false,
|
||||
stderr: expect.stringContaining('syntax error'),
|
||||
});
|
||||
// NOTHING applied: no bare `-f` run, no `nft list` verify, no rollback write
|
||||
expect(calls.filter((c) => c.args[0] === '-f')).toHaveLength(0);
|
||||
expect(calls.filter((c) => c.args[0] === 'list')).toHaveLength(0);
|
||||
expect(fs.writeFileSync).toHaveBeenCalledTimes(1); // only the temp file
|
||||
expect(fs.files.size).toBe(0); // temp deleted
|
||||
});
|
||||
|
||||
test('apply failure → {applied:false} thrown, rollback file unchanged, old rules intact', async () => {
|
||||
const fs = mockFs();
|
||||
fs.files.set('/tmp/x/rollback.nft', 'PREVIOUS-ROLLBACK-CONTENT');
|
||||
const { impl, calls } = mockExec({ applyErr: true });
|
||||
const applier = createWgFirewallApplier({
|
||||
execFileImpl: impl, fsImpl: fs, tempDir: '/tmp/x', rollbackPath: '/tmp/x/rollback.nft',
|
||||
});
|
||||
await expect(applier.apply(RULESET)).rejects.toMatchObject({
|
||||
name: 'FirewallApplyError',
|
||||
applied: false,
|
||||
});
|
||||
// rollback file NOT overwritten by the failed apply (old rules intact)
|
||||
expect(fs.readFileSync('/tmp/x/rollback.nft')).toBe('PREVIOUS-ROLLBACK-CONTENT');
|
||||
// temp cleaned, only the rollback file remains
|
||||
expect(fs.files.size).toBe(1);
|
||||
// verify never reached: the only `nft -f` run is the failed apply —
|
||||
// no rollback re-apply, and exactly one `list` (the pre-apply capture)
|
||||
expect(calls.filter((c) => c.args[0] === '-f')).toHaveLength(1);
|
||||
expect(calls.filter((c) => c.args[0] === 'list')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('verify mismatch → rollback attempted, verify error carries rolledBack', async () => {
|
||||
const fs = mockFs();
|
||||
const { impl, calls } = mockExec({ verifyOut: '' }); // list returns no table
|
||||
const applier = createWgFirewallApplier({
|
||||
execFileImpl: impl, fsImpl: fs, tempDir: '/tmp/x', rollbackPath: '/tmp/x/rollback.nft',
|
||||
});
|
||||
await expect(applier.apply(RULESET)).rejects.toMatchObject({
|
||||
name: 'FirewallVerifyError',
|
||||
code: 'verify_failed',
|
||||
rolledBack: true,
|
||||
});
|
||||
// last exec is the rollback `nft -f <rollbackFile>`
|
||||
const last = calls[calls.length - 1];
|
||||
expect(last.args).toEqual(['-f', '/tmp/x/rollback.nft']);
|
||||
});
|
||||
|
||||
test('rollback() re-applies the previous ruleset file', async () => {
|
||||
const fs = mockFs();
|
||||
fs.files.set('/tmp/x/rollback.nft', 'table inet dcnet { OLD }');
|
||||
const { impl, calls } = mockExec();
|
||||
const applier = createWgFirewallApplier({
|
||||
execFileImpl: impl, fsImpl: fs, tempDir: '/tmp/x', rollbackPath: '/tmp/x/rollback.nft',
|
||||
});
|
||||
const res = await applier.rollback();
|
||||
expect(res.applied).toBe(true);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].args).toEqual(['-f', '/tmp/x/rollback.nft']);
|
||||
});
|
||||
|
||||
test('rollback() without a rollback file throws', async () => {
|
||||
const fs = mockFs();
|
||||
const { impl } = mockExec();
|
||||
const applier = createWgFirewallApplier({
|
||||
execFileImpl: impl, fsImpl: fs, tempDir: '/tmp/x', rollbackPath: '/tmp/x/rollback.nft',
|
||||
});
|
||||
await expect(applier.rollback()).rejects.toThrow(/no rollback file/);
|
||||
});
|
||||
|
||||
test('two applies never share a temp path', async () => {
|
||||
const fs = mockFs();
|
||||
const { impl } = mockExec();
|
||||
const applier = createWgFirewallApplier({
|
||||
execFileImpl: impl, fsImpl: fs, tempDir: '/tmp/x', rollbackPath: '/tmp/x/rollback.nft',
|
||||
});
|
||||
const r1 = await applier.apply(RULESET);
|
||||
const r2 = await applier.apply(RULESET2);
|
||||
expect(r1.tempPath).not.toBe(r2.tempPath);
|
||||
expect(r1.tempPath).toMatch(/dcnet-nft-/);
|
||||
expect(r2.tempPath).toMatch(/dcnet-nft-/);
|
||||
});
|
||||
|
||||
test('apply rejects empty/non-string ruleset before any I/O', async () => {
|
||||
const fs = mockFs();
|
||||
const { impl, calls } = mockExec();
|
||||
const applier = createWgFirewallApplier({ execFileImpl: impl, fsImpl: fs });
|
||||
await expect(applier.apply('')).rejects.toThrow(/non-empty/);
|
||||
expect(calls).toHaveLength(0);
|
||||
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('FirewallCheckError is exported and distinguishable', () => {
|
||||
const e = new FirewallCheckError('x', { stderr: 's' });
|
||||
expect(e instanceof Error).toBe(true);
|
||||
expect(e.applied).toBe(false);
|
||||
expect(new FirewallApplyError('y').applied).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* Route tests for WireGuard invite redemption (DC-150).
|
||||
*
|
||||
* Coverage:
|
||||
* - GET serves the redemption HTML (keygen script, X25519, pkcs8 references)
|
||||
* - POST happy path: peer added, config returned, NO privateKey anywhere
|
||||
* - used / expired / unknown token → 410 Gone
|
||||
* - invalid publicKey (length, charset) → 400
|
||||
* - single-use enforced: second POST → 410
|
||||
* - deviceName validation (missing, blank, too long) → 400
|
||||
* - bad inputs never burn the token (validation precedes redemption)
|
||||
* - token value is NEVER logged (log mock assertions)
|
||||
* Hermetic — supertest against an in-process express app, mocked provider.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const crypto = require('crypto');
|
||||
const redeemRoutes = require('../routes/network-wg-redeem');
|
||||
|
||||
const TOKEN = 'tok_' + 'a'.repeat(20);
|
||||
const PUB_KEY = Buffer.alloc(32, 7).toString('base64'); // 44 chars, '=' pad
|
||||
|
||||
function makeProvider({ failRedeem, failAddPeerCode } = {}) {
|
||||
const calls = { redeemInvite: [], addPeer: [] };
|
||||
let used = false;
|
||||
return {
|
||||
calls,
|
||||
// eslint-disable-next-line require-await
|
||||
async redeemInvite(token) {
|
||||
calls.redeemInvite.push(token);
|
||||
if (failRedeem) {
|
||||
const err = new Error(typeof failRedeem === 'string' ? failRedeem : 'redeemInvite: token expired');
|
||||
err.code = 'not_found';
|
||||
throw err;
|
||||
}
|
||||
if (used) {
|
||||
const err = new Error('redeemInvite: token already used');
|
||||
err.code = 'bad_input';
|
||||
throw err;
|
||||
}
|
||||
used = true;
|
||||
return { role: 'member', email: 'newbie@example.com' };
|
||||
},
|
||||
// eslint-disable-next-line require-await
|
||||
async addPeer(args) {
|
||||
calls.addPeer.push(args);
|
||||
if (failAddPeerCode) {
|
||||
const err = new Error('addPeer: rejected');
|
||||
err.code = failAddPeerCode;
|
||||
throw err;
|
||||
}
|
||||
return {
|
||||
deviceId: args.deviceId,
|
||||
publicKey: args.publicKey,
|
||||
address: '10.53.0.2/32',
|
||||
allowedIps: '10.53.0.0/16',
|
||||
dns: '10.53.0.1',
|
||||
hubPublicKey: 'HUB' + 'P'.repeat(39) + '=',
|
||||
hubAddress: '10.53.0.1/16',
|
||||
listenPort: 51820,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function asyncHandler(fn) {
|
||||
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
}
|
||||
|
||||
function createApp(opts = {}) {
|
||||
const endpoint = 'endpoint' in opts ? opts.endpoint : 'hub.example.com:51820';
|
||||
const provider = opts.provider || makeProvider();
|
||||
const logCalls = [];
|
||||
const logger = opts.log || {
|
||||
info: (...a) => logCalls.push(['info', a]),
|
||||
warn: (...a) => logCalls.push(['warn', a]),
|
||||
error: (...a) => logCalls.push(['error', a]),
|
||||
};
|
||||
const router = redeemRoutes({
|
||||
provider,
|
||||
endpoint,
|
||||
asyncHandler,
|
||||
log: logger,
|
||||
});
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/network/wg/redeem', 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, logCalls };
|
||||
}
|
||||
|
||||
afterEach(() => { jest.restoreAllMocks(); });
|
||||
|
||||
describe('DC-150 WireGuard invite redemption routes', () => {
|
||||
describe('GET /network/wg/redeem/:token (redemption page)', () => {
|
||||
it('serves HTML containing the browser-keygen script with X25519 + pkcs8 references', async () => {
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/network/wg/redeem/' + TOKEN);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toMatch(/text\/html/);
|
||||
expect(res.headers['cache-control']).toBe('no-store');
|
||||
expect(res.text).toContain('crypto.subtle.generateKey');
|
||||
expect(res.text).toContain('X25519');
|
||||
expect(res.text).toContain('pkcs8');
|
||||
expect(res.text).toContain('exportKey');
|
||||
// fixed PKCS#8 prefix bytes (30 2e 02 01 00 30 05 06 03 2b 65 6e 04 22 04 20)
|
||||
expect(res.text).toContain('0x30,0x2e,0x02,0x01,0x00,0x30,0x05,0x06,0x03,0x2b,0x65,0x6e,0x04,0x22,0x04,0x20');
|
||||
// private key warning + client-side QR
|
||||
expect(res.text).toMatch(/save this config now/i);
|
||||
expect(res.text).toContain('QRCode');
|
||||
});
|
||||
|
||||
it('serves the same page for any token (validation happens at POST)', async () => {
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/network/wg/redeem/anything-at-all');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toContain('X25519');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /network/wg/redeem/:token (happy path)', () => {
|
||||
it('adds a peer and returns the client config with NO private key anywhere', async () => {
|
||||
const provider = makeProvider();
|
||||
const { app, logCalls } = createApp({ provider });
|
||||
const res = await request(app)
|
||||
.post('/network/wg/redeem/' + TOKEN)
|
||||
.send({ deviceName: 'sam-laptop', publicKey: PUB_KEY });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.deviceName).toBe('sam-laptop');
|
||||
expect(res.body.config).toEqual({
|
||||
address: '10.53.0.2/32',
|
||||
dns: '10.53.0.1',
|
||||
hubPublicKey: 'HUB' + 'P'.repeat(39) + '=',
|
||||
allowedIps: '10.53.0.0/16',
|
||||
endpoint: 'hub.example.com:51820',
|
||||
});
|
||||
// NO privateKey field at any depth; no /private/i anywhere in the body.
|
||||
expect(JSON.stringify(res.body)).not.toMatch(/private/i);
|
||||
// provider got ONLY the public key
|
||||
expect(provider.calls.addPeer).toHaveLength(1);
|
||||
const addArgs = provider.calls.addPeer[0];
|
||||
expect(addArgs.publicKey).toBe(PUB_KEY);
|
||||
expect(Object.keys(addArgs)).not.toContain('privateKey');
|
||||
expect(addArgs.personId).toBeTruthy();
|
||||
expect(addArgs.deviceId).toMatch(/^wgd_[0-9a-f]{16}$/);
|
||||
expect(addArgs.name).toBe('sam-laptop');
|
||||
// log mock NEVER receives the token value
|
||||
const flat = JSON.stringify(logCalls);
|
||||
expect(flat).not.toContain(TOKEN);
|
||||
});
|
||||
|
||||
it('falls back to request host + listenPort when no endpoint injected', async () => {
|
||||
const { app } = createApp({ endpoint: undefined });
|
||||
const res = await request(app)
|
||||
.post('/network/wg/redeem/' + TOKEN)
|
||||
.set('Host', 'box.internal:8443')
|
||||
.send({ deviceName: 'phone', publicKey: PUB_KEY });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.config.endpoint).toBe('box.internal:51820');
|
||||
});
|
||||
|
||||
it('defaults dns and allowedIps per spec when provider omits them', async () => {
|
||||
const provider = {
|
||||
// eslint-disable-next-line require-await
|
||||
redeemInvite: async () => ({ role: 'member', email: null }),
|
||||
// eslint-disable-next-line require-await
|
||||
addPeer: async () => ({ address: '10.53.0.9/32', hubPublicKey: 'H', listenPort: 51821 }),
|
||||
};
|
||||
const { app } = createApp({ provider });
|
||||
const res = await request(app)
|
||||
.post('/network/wg/redeem/' + TOKEN)
|
||||
.send({ deviceName: 'x', publicKey: PUB_KEY });
|
||||
expect(res.body.config.dns).toBe('10.53.0.1');
|
||||
expect(res.body.config.allowedIps).toBe('10.53.0.0/16');
|
||||
});
|
||||
});
|
||||
|
||||
describe('token lifecycle (410 Gone)', () => {
|
||||
it('unknown / expired token → 410 with a clear message', async () => {
|
||||
const { app, logCalls } = createApp({ provider: makeProvider({ failRedeem: true }) });
|
||||
const res = await request(app)
|
||||
.post('/network/wg/redeem/' + TOKEN)
|
||||
.send({ deviceName: 'sam-laptop', publicKey: PUB_KEY });
|
||||
expect(res.status).toBe(410);
|
||||
expect(res.body.error).toMatch(/expired|already used/i);
|
||||
// never addPeer on a dead token
|
||||
expect(JSON.stringify(logCalls)).not.toContain(TOKEN);
|
||||
});
|
||||
|
||||
it('single-use enforced: second POST with the same token → 410', async () => {
|
||||
const provider = makeProvider();
|
||||
const { app } = createApp({ provider });
|
||||
const key2 = crypto.randomBytes(32).toString('base64');
|
||||
const first = await request(app)
|
||||
.post('/network/wg/redeem/' + TOKEN)
|
||||
.send({ deviceName: 'dev1', publicKey: PUB_KEY });
|
||||
expect(first.status).toBe(200);
|
||||
const second = await request(app)
|
||||
.post('/network/wg/redeem/' + TOKEN)
|
||||
.send({ deviceName: 'dev2', publicKey: key2 });
|
||||
expect(second.status).toBe(410);
|
||||
expect(provider.calls.addPeer).toHaveLength(1); // dev2 never registered
|
||||
});
|
||||
});
|
||||
|
||||
describe('publicKey validation (400)', () => {
|
||||
const badKeys = [
|
||||
['', 'missing'],
|
||||
[Buffer.alloc(31).toString('base64'), 'wrong decoded length (31 bytes)'],
|
||||
[Buffer.alloc(33).toString('base64'), 'wrong decoded length (33 bytes)'],
|
||||
['A'.repeat(43) + '!', 'bad charset (punctuation)'],
|
||||
['A'.repeat(43) + '_', 'bad charset (urlsafe char)'],
|
||||
[Buffer.alloc(32).toString('hex'), 'hex, not base64'],
|
||||
[12345, 'not a string'],
|
||||
];
|
||||
for (const [key, label] of badKeys) {
|
||||
it('rejects ' + label + ' → 400', async () => {
|
||||
const provider = makeProvider();
|
||||
const { app } = createApp({ provider });
|
||||
const res = await request(app)
|
||||
.post('/network/wg/redeem/' + TOKEN)
|
||||
.send({ deviceName: 'sam-laptop', publicKey: key });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/publicKey/i);
|
||||
// bad input must NOT burn the one-time token
|
||||
expect(provider.calls.redeemInvite).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
|
||||
it('accepts a real 44-char base64 key decoding to 32 bytes', async () => {
|
||||
const realKey = crypto.randomBytes(32).toString('base64'); // 44 chars incl '='
|
||||
expect(realKey).toHaveLength(44);
|
||||
const { app } = createApp({});
|
||||
const res = await request(app)
|
||||
.post('/network/wg/redeem/' + TOKEN)
|
||||
.send({ deviceName: 'ok', publicKey: realKey });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deviceName validation (400)', () => {
|
||||
const badNames = [
|
||||
[undefined, 'missing'],
|
||||
[null, 'null'],
|
||||
['', 'empty'],
|
||||
[' ', 'whitespace-only'],
|
||||
['x'.repeat(101), 'over 100 chars'],
|
||||
[42, 'not a string'],
|
||||
];
|
||||
for (const [name, label] of badNames) {
|
||||
it('rejects ' + label + ' → 400', async () => {
|
||||
const provider = makeProvider();
|
||||
const { app } = createApp({ provider });
|
||||
const res = await request(app)
|
||||
.post('/network/wg/redeem/' + TOKEN)
|
||||
.send({ deviceName: name, publicKey: PUB_KEY });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/deviceName/i);
|
||||
expect(provider.calls.redeemInvite).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
|
||||
it('trims a valid name before storing', async () => {
|
||||
const provider = makeProvider();
|
||||
const { app } = createApp({ provider });
|
||||
const res = await request(app)
|
||||
.post('/network/wg/redeem/' + TOKEN)
|
||||
.send({ deviceName: ' pad-book ', publicKey: PUB_KEY });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.deviceName).toBe('pad-book');
|
||||
expect(provider.calls.addPeer[0].name).toBe('pad-book');
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider failure mapping', () => {
|
||||
it('addPeer bad_input → 400 (token already burned — retry impossible, surface clearly)', async () => {
|
||||
const { app } = createApp({ provider: makeProvider({ failAddPeerCode: 'bad_input' }) });
|
||||
const res = await request(app)
|
||||
.post('/network/wg/redeem/' + TOKEN)
|
||||
.send({ deviceName: 'dup', publicKey: PUB_KEY });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('addPeer infrastructure failure → 500 via error handler (no config leak)', async () => {
|
||||
const { app } = createApp({ provider: makeProvider({ failAddPeerCode: 'provider_error' }) });
|
||||
const res = await request(app)
|
||||
.post('/network/wg/redeem/' + TOKEN)
|
||||
.send({ deviceName: 'dup', publicKey: PUB_KEY });
|
||||
expect(res.status).toBe(500);
|
||||
expect(JSON.stringify(res.body)).not.toMatch(/private/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('module surface', () => {
|
||||
it('exports the public mount prefix constant for middleware registration', () => {
|
||||
expect(redeemRoutes.PUBLIC_REDEEM_PATH_PREFIX).toBe('/network/wg/redeem/');
|
||||
});
|
||||
|
||||
it('derivePersonId prefers invite personId, then email, then token hash', () => {
|
||||
expect(redeemRoutes.derivePersonId({ personId: 'p1' }, 't')).toBe('p1');
|
||||
expect(redeemRoutes.derivePersonId({ email: 'a@b.c' }, 't')).toBe('wge:a@b.c');
|
||||
expect(redeemRoutes.derivePersonId({ role: 'member' }, 'tok')).toMatch(/^wgi:[0-9a-f]{24}$/);
|
||||
});
|
||||
|
||||
it('factory throws without a provider (redeemInvite/addPeer required)', () => {
|
||||
expect(() => redeemRoutes({})).toThrow(/provider/);
|
||||
expect(() => redeemRoutes({ provider: { redeemInvite: async () => {} } })).toThrow(/provider/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,647 @@
|
||||
/**
|
||||
* Route tests for the Private Network setup wizard + status — DC-152
|
||||
* (specs/PRIVATE-NETWORK-SPEC.md §9).
|
||||
*
|
||||
* Hermetic: supertest against an in-process express app with fully mocked
|
||||
* deps (registry, providers, peopleStore, credentialStore, technitium,
|
||||
* caSetup). No network, no real filesystem state (tmp state file per app).
|
||||
*
|
||||
* Coverage required by the ticket:
|
||||
* - mode validation for BOTH providers, incl. credential failure →
|
||||
* step does NOT advance
|
||||
* - apiKey expiry warning present for apiKey, absent for oauth
|
||||
* - domain default + custom-TLD warning (accepted, never blocked)
|
||||
* - connect runs the first sync (people + devices)
|
||||
* - dns capability gating in both modes
|
||||
* - ca locked-in warning + skip-when-dep-absent
|
||||
* - admin bootstrap (first person = admin)
|
||||
* - complete gate refuses when checks are missing
|
||||
* - status aggregation shape
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const wizardRoutes = require('../routes/network-wizard');
|
||||
const { createPeopleStore, ADMIN_GROUP } = require('../src/security/network-people-store');
|
||||
const { ProviderError } = require('../src/network/errors');
|
||||
|
||||
/* ── fixtures ─────────────────────────────────────────────────────── */
|
||||
|
||||
function makeCredentialStore() {
|
||||
const map = new Map();
|
||||
return {
|
||||
map,
|
||||
async store(key, value) { map.set(key, value); return true; },
|
||||
async retrieve(key) { return map.get(key) || null; },
|
||||
async delete(key) { map.delete(key); return true; },
|
||||
};
|
||||
}
|
||||
|
||||
function makeProvider(overrides = {}) {
|
||||
const base = {
|
||||
id: () => 'mock',
|
||||
capabilities: () => ({ invites: true, splitDnsApi: true, perPeerFirewall: false, selfHosted: false }),
|
||||
getStatus: async () => ({ connected: true, installed: true }),
|
||||
listDevices: async () => [
|
||||
{ keyId: 'dev-1', name: 'laptop', ip: '100.64.0.2', online: true },
|
||||
{ keyId: 'dev-2', name: 'phone', ip: '100.64.0.3', online: false },
|
||||
],
|
||||
listPeople: async () => [
|
||||
{ providerUserId: 'user-1', displayName: 'Sami' },
|
||||
],
|
||||
};
|
||||
return Object.assign(base, overrides);
|
||||
}
|
||||
|
||||
function makeRegistry({ activeId = null } = {}) {
|
||||
const reg = {
|
||||
_activeId: activeId,
|
||||
setActiveProvider: jest.fn(function (id) { this._activeId = id; }),
|
||||
getActiveProvider: jest.fn(function () { return this._activeId; }),
|
||||
};
|
||||
return reg;
|
||||
}
|
||||
|
||||
let _seq = 0;
|
||||
function createApp(overrides = {}) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc152-wizard-'));
|
||||
const stateFile = path.join(dir, 'network-wizard-state.json');
|
||||
const credentialStore = overrides.credentialStore || makeCredentialStore();
|
||||
const peopleStore = overrides.peopleStore
|
||||
|| createPeopleStore({ dataDir: dir, log: { info() {}, warn() {}, error() {} } });
|
||||
const tailscale = overrides.tailscale || makeProvider({ id: () => 'tailscale' });
|
||||
const wireguard = overrides.wireguard || makeProvider({
|
||||
id: () => 'wireguard',
|
||||
capabilities: () => ({ invites: false, splitDnsApi: false, perPeerFirewall: true, selfHosted: true }),
|
||||
});
|
||||
const registry = overrides.registry || makeRegistry();
|
||||
const router = wizardRoutes({
|
||||
registry,
|
||||
providers: { tailscale, wireguard },
|
||||
peopleStore,
|
||||
technitium: overrides.technitium !== undefined ? overrides.technitium : null,
|
||||
caSetup: overrides.caSetup !== undefined ? overrides.caSetup : null,
|
||||
credentialStore,
|
||||
stateFile,
|
||||
connectionLogReader: overrides.connectionLogReader || null,
|
||||
aclStateReader: overrides.aclStateReader || null,
|
||||
firewallStateReader: overrides.firewallStateReader || null,
|
||||
asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
|
||||
log: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/network/wizard', 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 });
|
||||
});
|
||||
_seq += 1;
|
||||
return { app, dir, stateFile, credentialStore, peopleStore, registry, tailscale, wireguard, seq: _seq };
|
||||
}
|
||||
|
||||
function cleanup(dir) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ok */ }
|
||||
}
|
||||
|
||||
/* ── state ────────────────────────────────────────────────────────── */
|
||||
|
||||
describe('GET /network/wizard/state', () => {
|
||||
let ctx;
|
||||
afterEach(() => cleanup(ctx.dir));
|
||||
|
||||
test('initial state: step mode, no checks passing', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app).get('/network/wizard/state');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.step).toBe('mode');
|
||||
expect(res.body.mode).toBeNull();
|
||||
expect(res.body.startedAt).toBeTruthy();
|
||||
expect(res.body.completedAt).toBeNull();
|
||||
expect(res.body.checks).toMatchObject({
|
||||
modeSelected: false,
|
||||
domainConfigured: false,
|
||||
providerConnected: false,
|
||||
dnsConfigured: false,
|
||||
caConfigured: false,
|
||||
adminCreated: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('state persists to network-wizard-state.json', async () => {
|
||||
ctx = createApp();
|
||||
await request(ctx.app).post('/network/wizard/mode').send({
|
||||
mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-xyz' },
|
||||
});
|
||||
const raw = JSON.parse(fs.readFileSync(ctx.stateFile, 'utf8'));
|
||||
expect(raw.mode).toBe('tailscale');
|
||||
expect(raw.step).toBe('domain');
|
||||
});
|
||||
});
|
||||
|
||||
/* ── step 1: mode ─────────────────────────────────────────────────── */
|
||||
|
||||
describe('POST /network/wizard/mode', () => {
|
||||
let ctx;
|
||||
afterEach(() => cleanup(ctx.dir));
|
||||
|
||||
test('tailscale with oauthClientSecret: accepted, no expiry warning', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app).post('/network/wizard/mode').send({
|
||||
mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-abc123' },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.mode).toBe('tailscale');
|
||||
expect(res.body.apiKeyExpiryWarning).toBeUndefined();
|
||||
expect(res.body.step).toBe('domain');
|
||||
expect(ctx.registry.setActiveProvider).toHaveBeenCalledWith('tailscale');
|
||||
});
|
||||
|
||||
test('tailscale with apiKey: accepted WITH expiry warning + oauth recommendation', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app).post('/network/wizard/mode').send({
|
||||
mode: 'tailscale', credentials: { apiKey: 'tskey-api-k1234567890abcdef' },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.apiKeyExpiryWarning).toBe(true);
|
||||
expect(res.body.warning).toMatch(/90 days/);
|
||||
expect(res.body.warning).toMatch(/tskey-client-/);
|
||||
expect(res.body.step).toBe('domain');
|
||||
});
|
||||
|
||||
test('tailscale: credential stored under REUSED keys (tailscale.coord.apiToken / oauth)', async () => {
|
||||
ctx = createApp();
|
||||
await request(ctx.app).post('/network/wizard/mode').send({
|
||||
mode: 'tailscale', credentials: { apiKey: 'tskey-api-k1234567890abcdef' },
|
||||
});
|
||||
expect(ctx.credentialStore.map.get('tailscale.coord.apiToken')).toBe('tskey-api-k1234567890abcdef');
|
||||
await request(ctx.app).post('/network/wizard/mode').send({
|
||||
mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-aaa' },
|
||||
});
|
||||
expect(ctx.credentialStore.map.get('tailscale.oauth.client_secret')).toBe('tskey-client-aaa');
|
||||
});
|
||||
|
||||
test('tailscale credential failure (unauthorized): 4xx, step does NOT advance, credential rolled back', async () => {
|
||||
const tailscale = makeProvider({
|
||||
id: () => 'tailscale',
|
||||
listDevices: async () => { throw new ProviderError('token rejected', { code: 'unauthorized' }); },
|
||||
});
|
||||
ctx = createApp({ tailscale });
|
||||
const res = await request(ctx.app).post('/network/wizard/mode').send({
|
||||
mode: 'tailscale', credentials: { apiKey: 'tskey-api-k1234567890abcdef' },
|
||||
});
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(res.status).toBeLessThan(500);
|
||||
expect(res.body.success).toBe(false);
|
||||
const state = await request(ctx.app).get('/network/wizard/state');
|
||||
expect(state.body.step).toBe('mode'); // NOT advanced
|
||||
expect(state.body.checks.modeSelected).toBe(false);
|
||||
expect(ctx.credentialStore.map.get('tailscale.coord.apiToken')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('tailscale without any credentials: 400', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app).post('/network/wizard/mode').send({ mode: 'tailscale' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
test('wireguard happy path: endpoint validated via getStatus, step advances', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app).post('/network/wizard/mode').send({
|
||||
mode: 'wireguard',
|
||||
credentials: { endpoint: 'vpn.example.com', listenPort: 51820, externalInterface: 'eth0' },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.mode).toBe('wireguard');
|
||||
expect(res.body.step).toBe('domain');
|
||||
expect(ctx.registry.setActiveProvider).toHaveBeenCalledWith('wireguard');
|
||||
});
|
||||
|
||||
test('wireguard dependency_missing: 400 with the missing binary list, step does NOT advance', async () => {
|
||||
const wireguard = makeProvider({
|
||||
id: () => 'wireguard',
|
||||
getStatus: async () => {
|
||||
throw new ProviderError('wireguard: `wg` binary not installed; wireguard: `wg-quick` binary not installed', {
|
||||
code: 'dependency_missing',
|
||||
});
|
||||
},
|
||||
});
|
||||
ctx = createApp({ wireguard });
|
||||
const res = await request(ctx.app).post('/network/wizard/mode').send({
|
||||
mode: 'wireguard', credentials: { endpoint: 'vpn.example.com' },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
expect(res.body.details.missingBinaries).toEqual(expect.arrayContaining(['wg', 'wg-quick']));
|
||||
const state = await request(ctx.app).get('/network/wizard/state');
|
||||
expect(state.body.step).toBe('mode');
|
||||
});
|
||||
|
||||
test('wireguard without endpoint: 400', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app).post('/network/wizard/mode').send({ mode: 'wireguard' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('unknown mode: 400', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app).post('/network/wizard/mode').send({ mode: 'openvpn' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('bad_mode');
|
||||
});
|
||||
});
|
||||
|
||||
/* ── step 2: domain ───────────────────────────────────────────────── */
|
||||
|
||||
describe('POST /network/wizard/domain', () => {
|
||||
let ctx;
|
||||
afterEach(() => cleanup(ctx.dir));
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = createApp();
|
||||
await request(ctx.app).post('/network/wizard/mode').send({
|
||||
mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-x' },
|
||||
});
|
||||
});
|
||||
|
||||
test('default .internal: accepted silently, no warning', async () => {
|
||||
const res = await request(ctx.app).post('/network/wizard/domain').send({ tld: '.internal' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tld).toBe('.internal');
|
||||
expect(res.body.customTldWarning).toBeUndefined();
|
||||
expect(res.body.step).toBe('connect');
|
||||
});
|
||||
|
||||
test('home.arpa: accepted silently', async () => {
|
||||
const res = await request(ctx.app).post('/network/wizard/domain').send({ tld: 'home.arpa' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tld).toBe('.home.arpa');
|
||||
expect(res.body.customTldWarning).toBeUndefined();
|
||||
});
|
||||
|
||||
test('empty body → default .internal', async () => {
|
||||
const res = await request(ctx.app).post('/network/wizard/domain').send({});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tld).toBe('.internal');
|
||||
expect(res.body.customTldWarning).toBeUndefined();
|
||||
});
|
||||
|
||||
test('custom TLD: accepted WITH "could become real later" warning (never blocked)', async () => {
|
||||
const res = await request(ctx.app).post('/network/wizard/domain').send({ tld: '.mynet' });
|
||||
expect(res.status).toBe(200); // accepted, not blocked
|
||||
expect(res.body.tld).toBe('.mynet');
|
||||
expect(res.body.customTldWarning).toBe(true);
|
||||
expect(res.body.warning).toMatch(/could become real later/);
|
||||
});
|
||||
|
||||
test('garbage tld: 400', async () => {
|
||||
const res = await request(ctx.app).post('/network/wizard/domain').send({ tld: '.not valid!' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('domain before mode: 409, step does not advance', async () => {
|
||||
const fresh = createApp();
|
||||
const res = await request(fresh.app).post('/network/wizard/domain').send({ tld: '.internal' });
|
||||
expect(res.status).toBe(409);
|
||||
cleanup(fresh.dir);
|
||||
});
|
||||
});
|
||||
|
||||
/* ── step 3: connect ─────────────────────────────────────────────── */
|
||||
|
||||
describe('POST /network/wizard/connect', () => {
|
||||
let ctx;
|
||||
afterEach(() => cleanup(ctx.dir));
|
||||
|
||||
async function ready(mode = 'tailscale') {
|
||||
ctx = createApp();
|
||||
const creds = mode === 'tailscale'
|
||||
? { mode, credentials: { oauthClientSecret: 'tskey-client-x' } }
|
||||
: { mode, credentials: { endpoint: 'vpn.example.com' } };
|
||||
await request(ctx.app).post('/network/wizard/mode').send(creds);
|
||||
await request(ctx.app).post('/network/wizard/domain').send({ tld: '.internal' });
|
||||
}
|
||||
|
||||
test('runs health check + first sync (people into store, devices listed)', async () => {
|
||||
await ready('tailscale');
|
||||
const res = await request(ctx.app).post('/network/wizard/connect');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.connected).toBe(true);
|
||||
expect(res.body.peopleSynced).toBe(1);
|
||||
expect(Array.isArray(res.body.devices)).toBe(true);
|
||||
expect(res.body.devices).toHaveLength(2);
|
||||
expect(res.body.step).toBe('dns');
|
||||
const people = await ctx.peopleStore.listPeople();
|
||||
expect(people).toHaveLength(1);
|
||||
expect(people[0].displayName).toBe('Sami');
|
||||
});
|
||||
|
||||
test('connect is idempotent: re-running does not duplicate people', async () => {
|
||||
await ready('tailscale');
|
||||
await request(ctx.app).post('/network/wizard/connect');
|
||||
const res = await request(ctx.app).post('/network/wizard/connect');
|
||||
expect(res.status).toBe(200);
|
||||
const people = await ctx.peopleStore.listPeople();
|
||||
expect(people).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('health check failure → 502, providerConnected stays false', async () => {
|
||||
await ready('wireguard');
|
||||
ctx.wireguard.getStatus = async () => ({ connected: false, installed: true });
|
||||
const res = await request(ctx.app).post('/network/wizard/connect');
|
||||
expect(res.status).toBe(502);
|
||||
const state = await request(ctx.app).get('/network/wizard/state');
|
||||
expect(state.body.checks.providerConnected).toBe(false);
|
||||
});
|
||||
|
||||
test('connect before mode: 409', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app).post('/network/wizard/connect');
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
/* ── step 4: dns ─────────────────────────────────────────────────── */
|
||||
|
||||
describe('POST /network/wizard/dns (capability gating)', () => {
|
||||
let ctx;
|
||||
afterEach(() => cleanup(ctx.dir));
|
||||
|
||||
function makeTechnitium() {
|
||||
return {
|
||||
checkSetup: jest.fn(async () => ({ ok: true, installed: true, configured: true, server: '127.0.0.1:5353' })),
|
||||
configureSplitDns: jest.fn(async ({ zone, server }) => ({ ok: true, zone, server })),
|
||||
};
|
||||
}
|
||||
|
||||
async function readyToDns(mode, technitium) {
|
||||
ctx = createApp({ technitium });
|
||||
const creds = mode === 'tailscale'
|
||||
? { mode, credentials: { oauthClientSecret: 'tskey-client-x' } }
|
||||
: { mode, credentials: { endpoint: 'vpn.example.com' } };
|
||||
await request(ctx.app).post('/network/wizard/mode').send(creds);
|
||||
await request(ctx.app).post('/network/wizard/domain').send({ tld: '.internal' });
|
||||
await request(ctx.app).post('/network/wizard/connect');
|
||||
}
|
||||
|
||||
test('tailscale mode: split-DNS delegated via provider-capable path, per-check results', async () => {
|
||||
const technitium = makeTechnitium();
|
||||
await readyToDns('tailscale', technitium);
|
||||
const res = await request(ctx.app).post('/network/wizard/dns');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.configured).toBe(true);
|
||||
expect(res.body.checks).toHaveLength(2);
|
||||
expect(technitium.checkSetup).toHaveBeenCalledTimes(1);
|
||||
expect(technitium.configureSplitDns).toHaveBeenCalledTimes(1);
|
||||
expect(technitium.configureSplitDns.mock.calls[0][0].zone).toBe('internal');
|
||||
expect(technitium.configureSplitDns.mock.calls[0][0].viaProviderApi).toBe(true); // splitDnsApi capability
|
||||
expect(res.body.step).toBe('ca');
|
||||
});
|
||||
|
||||
test('wireguard mode: splitDnsApi capability false → direct Technitium write path', async () => {
|
||||
const technitium = makeTechnitium();
|
||||
await readyToDns('wireguard', technitium);
|
||||
const res = await request(ctx.app).post('/network/wizard/dns');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.configured).toBe(true);
|
||||
expect(technitium.configureSplitDns.mock.calls[0][0].viaProviderApi).toBe(false);
|
||||
});
|
||||
|
||||
test('technitium dep absent: skipped honestly, dns check counts as done', async () => {
|
||||
await readyToDns('tailscale', null);
|
||||
const res = await request(ctx.app).post('/network/wizard/dns');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.skipped).toBe(true);
|
||||
expect(res.body.reason).toBeTruthy();
|
||||
const state = await request(ctx.app).get('/network/wizard/state');
|
||||
expect(state.body.checks.dnsConfigured).toBe(true); // skipped counts
|
||||
expect(state.body.step).toBe('ca');
|
||||
});
|
||||
|
||||
test('technitium setup not ok: configured=false, per-check fail surfaced', async () => {
|
||||
const technitium = {
|
||||
checkSetup: async () => ({ ok: false, installed: false }),
|
||||
configureSplitDns: jest.fn(async () => ({ ok: true })),
|
||||
};
|
||||
await readyToDns('tailscale', technitium);
|
||||
const res = await request(ctx.app).post('/network/wizard/dns');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.configured).toBe(false);
|
||||
expect(res.body.checks[0].status).toBe('fail');
|
||||
expect(technitium.configureSplitDns).not.toHaveBeenCalled();
|
||||
const state = await request(ctx.app).get('/network/wizard/state');
|
||||
expect(state.body.checks.dnsConfigured).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/* ── step 5: ca ──────────────────────────────────────────────────── */
|
||||
|
||||
describe('POST /network/wizard/ca', () => {
|
||||
let ctx;
|
||||
afterEach(() => cleanup(ctx.dir));
|
||||
|
||||
async function readyToCa(caSetup) {
|
||||
ctx = createApp({ caSetup });
|
||||
await request(ctx.app).post('/network/wizard/mode').send({
|
||||
mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-x' },
|
||||
});
|
||||
await request(ctx.app).post('/network/wizard/domain').send({ tld: '.internal' });
|
||||
await request(ctx.app).post('/network/wizard/connect');
|
||||
await request(ctx.app).post('/network/wizard/dns');
|
||||
}
|
||||
|
||||
test('dep present: setupNamedRootCA called with commonName + privateTld, locked-in warning returned', async () => {
|
||||
const caSetup = { setupNamedRootCA: jest.fn(async ({ commonName, privateTld }) => ({
|
||||
ok: true, commonName, privateTld, fingerprint: 'AA:BB',
|
||||
})) };
|
||||
await readyToCa(caSetup);
|
||||
const res = await request(ctx.app).post('/network/wizard/ca').send({ commonName: 'DashCaddy Home CA' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.configured).toBe(true);
|
||||
expect(res.body.lockedInWarning).toMatch(/new root/i);
|
||||
expect(res.body.lockedInWarning).toMatch(/re-trust/i);
|
||||
expect(caSetup.setupNamedRootCA).toHaveBeenCalledWith({
|
||||
commonName: 'DashCaddy Home CA',
|
||||
privateTld: '.internal',
|
||||
});
|
||||
expect(res.body.step).toBe('admin');
|
||||
});
|
||||
|
||||
test('dep absent: skipped with reason (wizard not wedged), still carries warning', async () => {
|
||||
await readyToCa(null);
|
||||
const res = await request(ctx.app).post('/network/wizard/ca').send({ commonName: 'X' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.skipped).toBe(true);
|
||||
expect(res.body.reason).toBeTruthy();
|
||||
expect(res.body.lockedInWarning).toBeTruthy();
|
||||
expect(res.body.step).toBe('admin');
|
||||
});
|
||||
|
||||
test('missing commonName with dep present: 400', async () => {
|
||||
await readyToCa({ setupNamedRootCA: async () => ({ ok: true }) });
|
||||
const res = await request(ctx.app).post('/network/wizard/ca').send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('bad_common_name');
|
||||
});
|
||||
});
|
||||
|
||||
/* ── step 6: admin ───────────────────────────────────────────────── */
|
||||
|
||||
describe('POST /network/wizard/admin (bootstrap admin)', () => {
|
||||
let ctx;
|
||||
afterEach(() => cleanup(ctx.dir));
|
||||
|
||||
async function readyToAdmin() {
|
||||
// No provider people: the store must still be EMPTY at /admin time so
|
||||
// the bootstrap-admin rule (first person ⇒ Admin group) applies to the
|
||||
// wizard-created person, not to a connect-synced one.
|
||||
const tailscale = makeProvider({ id: () => 'tailscale', listPeople: async () => [] });
|
||||
ctx = createApp({ tailscale });
|
||||
await request(ctx.app).post('/network/wizard/mode').send({
|
||||
mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-x' },
|
||||
});
|
||||
await request(ctx.app).post('/network/wizard/domain').send({ tld: '.internal' });
|
||||
await request(ctx.app).post('/network/wizard/connect');
|
||||
await request(ctx.app).post('/network/wizard/dns');
|
||||
await request(ctx.app).post('/network/wizard/ca').send({ commonName: 'CA' });
|
||||
}
|
||||
|
||||
test('creates first person via peopleStore with bootstrap admin rule', async () => {
|
||||
await readyToAdmin();
|
||||
const res = await request(ctx.app).post('/network/wizard/admin').send({
|
||||
displayName: 'Sami', providerUserId: 'ts-user-1',
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.adminCreated).toBe(true);
|
||||
expect(res.body.person.displayName).toBe('Sami');
|
||||
expect(res.body.person.groups).toContain(ADMIN_GROUP); // bootstrap rule
|
||||
expect(res.body.person.protectedAt).toBeTruthy();
|
||||
expect(res.body.step).toBe('complete');
|
||||
});
|
||||
|
||||
test('invalid displayName: 400', async () => {
|
||||
await readyToAdmin();
|
||||
const res = await request(ctx.app).post('/network/wizard/admin').send({ displayName: '' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
/* ── completion gate ─────────────────────────────────────────────── */
|
||||
|
||||
describe('POST /network/wizard/complete', () => {
|
||||
let ctx;
|
||||
afterEach(() => cleanup(ctx.dir));
|
||||
|
||||
test('refuses (409) when checks missing — names them', async () => {
|
||||
ctx = createApp();
|
||||
await request(ctx.app).post('/network/wizard/mode').send({
|
||||
mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-x' },
|
||||
});
|
||||
const res = await request(ctx.app).post('/network/wizard/complete');
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.code).toBe('incomplete');
|
||||
expect(res.body.details.missing).toEqual(expect.arrayContaining([
|
||||
'providerConnected', 'dnsConfigured', 'caConfigured', 'adminCreated',
|
||||
]));
|
||||
const state = await request(ctx.app).get('/network/wizard/state');
|
||||
expect(state.body.completedAt).toBeNull();
|
||||
});
|
||||
|
||||
test('all green → completedAt set', async () => {
|
||||
ctx = createApp();
|
||||
await request(ctx.app).post('/network/wizard/mode').send({
|
||||
mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-x' },
|
||||
});
|
||||
await request(ctx.app).post('/network/wizard/domain').send({ tld: '.internal' });
|
||||
await request(ctx.app).post('/network/wizard/connect');
|
||||
await request(ctx.app).post('/network/wizard/dns'); // skipped path (no technitium)
|
||||
await request(ctx.app).post('/network/wizard/ca').send({ commonName: 'CA' }); // skipped path
|
||||
await request(ctx.app).post('/network/wizard/admin').send({ displayName: 'Sami' });
|
||||
const res = await request(ctx.app).post('/network/wizard/complete');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.completed).toBe(true);
|
||||
expect(res.body.completedAt).toBeTruthy();
|
||||
const state = await request(ctx.app).get('/network/wizard/state');
|
||||
expect(state.body.completedAt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
/* ── status aggregation ──────────────────────────────────────────── */
|
||||
|
||||
describe('GET /network/wizard/status', () => {
|
||||
let ctx;
|
||||
afterEach(() => cleanup(ctx.dir));
|
||||
|
||||
test('aggregation shape: wizard, online, recentConnections, sync, driftBanner', async () => {
|
||||
ctx = createApp({
|
||||
connectionLogReader: async () => ({ events: [{ at: 't', ip: '100.64.0.2' }], note: 'event-store' }),
|
||||
aclStateReader: async () => ({ hash: 'abc123', lastPushAt: 't', drifted: false }),
|
||||
firewallStateReader: async () => ({ applied: true, lastApplyAt: 't', drifted: false }),
|
||||
});
|
||||
await request(ctx.app).post('/network/wizard/mode').send({
|
||||
mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-x' },
|
||||
});
|
||||
const res = await request(ctx.app).get('/network/wizard/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.wizard).toMatchObject({ mode: 'tailscale', privateTld: null });
|
||||
expect(res.body.online).toMatchObject({ provider: 'tailscale', devicesTotal: 2, devicesOnline: 1 });
|
||||
expect(Array.isArray(res.body.online.devices)).toBe(true);
|
||||
expect(res.body.recentConnections.events).toHaveLength(1);
|
||||
expect(res.body.sync.acl.hash).toBe('abc123');
|
||||
expect(res.body.sync.firewall.applied).toBe(true);
|
||||
expect(res.body.driftBanner).toBe(false);
|
||||
});
|
||||
|
||||
test('no connectionLogReader → empty list with a note (not an error)', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app).get('/network/wizard/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.recentConnections.events).toEqual([]);
|
||||
expect(res.body.recentConnections.note).toBeTruthy();
|
||||
expect(res.body.sync).toEqual({ acl: null, firewall: null });
|
||||
});
|
||||
|
||||
test('drift in either reader flips driftBanner', async () => {
|
||||
ctx = createApp({
|
||||
aclStateReader: async () => ({ hash: 'x', drifted: true }),
|
||||
});
|
||||
await request(ctx.app).post('/network/wizard/mode').send({
|
||||
mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-x' },
|
||||
});
|
||||
const res = await request(ctx.app).get('/network/wizard/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.driftBanner).toBe(true);
|
||||
});
|
||||
|
||||
test('provider status failure surfaces as online.error, still 200', async () => {
|
||||
// listDevices works (mode validation passes); getStatus fails → the
|
||||
// status aggregation surfaces the error instead of 500ing.
|
||||
const tailscale = makeProvider({
|
||||
id: () => 'tailscale',
|
||||
getStatus: async () => { throw new Error('daemon down'); },
|
||||
});
|
||||
ctx = createApp({ tailscale });
|
||||
await request(ctx.app).post('/network/wizard/mode').send({
|
||||
mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-x' },
|
||||
});
|
||||
const res = await request(ctx.app).get('/network/wizard/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.online.error).toBe('daemon down');
|
||||
});
|
||||
|
||||
test('before any mode: honest empty shape', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app).get('/network/wizard/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.wizard.mode).toBeNull();
|
||||
expect(res.body.online.devices).toEqual([]);
|
||||
expect(res.body.driftBanner).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user