/** * 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()', () => { 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([]); }); });