Merge branch 'dc/DC-140' into dc/DC-148

This commit is contained in:
Hermes committed 2026-10-03 21:20:01 -07:00
commit b7df8f5fe0
7 files changed
+1308

No files matched your search

@@ -0,0 +1,444 @@
/**
* 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 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 });
});
});
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()', () => {
test('throws ProviderError code=not_implemented_in_provider (DC-148 lands later)', async () => {
const p = createTailscaleProvider({ coordClient: makeCoord() });
await expect(p.syncAccess({}, [])).rejects.toMatchObject({
name: 'ProviderError',
code: 'not_implemented_in_provider',
retryable: false,
});
});
});
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,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);
});
});