DC-149: WireGuard hub provider (peer store, pool allocator, hub lifecycle) + tests
This commit is contained in:
5 files changed
+1517
-8
No files matched your search
@@ -242,9 +242,13 @@ describe('tailscale provider: blockPerson()', () => {
|
||||
});
|
||||
|
||||
describe('tailscale provider: unblockPerson()', () => {
|
||||
test('is a no-op at the Tailscale level and returns { restored: true }', async () => {
|
||||
const p = createTailscaleProvider({ coordClient: makeCoord() });
|
||||
await expect(p.unblockPerson('1001')).resolves.toEqual({ restored: true });
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
/**
|
||||
* 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: unsupported / not-implemented surface', () => {
|
||||
test('syncAccess → not_implemented (DC-151 injects the firewall compiler)', async () => {
|
||||
const { provider } = makeProvider();
|
||||
await expect(provider.syncAccess()).rejects.toMatchObject({
|
||||
name: 'ProviderError',
|
||||
code: 'not_implemented',
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user