/** * Tests for src/managers/tailscale-coord.js * * Strategy: inject a fake `fetchImpl` into the client so we can simulate * every Tailscale API response shape without making real HTTP calls. Each * test sets up a mock that responds to the URL path with a fixture body * and the expected status code, then asserts the client's behavior. * * The mock is intentionally simple: a function (method, path, opts) → Promise<{ * status, body, headers }>. We don't try to be exhaustive about request * shape matching — just enough to verify the client's status handling, * caching, error mapping, and JSON parsing. */ 'use strict'; /* eslint-disable require-await, no-unused-vars */ // require-await: many helper functions in this file are `async () => ...` to // match the shape of the real function signatures — they don't need to await. // no-unused-vars: some tests destructure fields they don't exercise. const { TailscaleCoordClient, TailscaleCoordError } = require('../src/managers/tailscale-coord'); const VALID_TOKEN = 'tskey-api-kLD2XbydZ511CNTRL-CKorHnjoVpc11chfHcV8qcSz9hhjpUr3'; // realistic shape /** * Build a fake fetchImpl from a route map. * * { * 'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [...] } }, * 'POST /api/v2/tailnet/-/keys': { status: 200, body: { id: 'k1', key: 'tskey-auth-abc' } }, * 'DELETE /api/v2/device/d1': { status: 200, body: '' }, * } * * Unmatched routes return 404 by default (the client will then throw * TailscaleCoordError with code='not_found'). */ function makeFetch(routes, { defaultStatus = 404, defaultBody = { message: 'no route' } } = {}) { const calls = []; const fn = jest.fn(async (method, path, opts) => { calls.push({ method, path, opts }); const key = method + ' ' + path; const match = routes[key]; if (match) { return { status: match.status, body: typeof match.body === 'string' ? match.body : JSON.stringify(match.body), headers: match.headers || { 'content-type': 'application/json' }, }; } return { status: defaultStatus, body: JSON.stringify(defaultBody), headers: { 'content-type': 'application/json' }, }; }); fn.calls = calls; return fn; } describe('tailscale-coord: configuration', () => { test('isConfigured() returns false when no token set', () => { const c = new TailscaleCoordClient(); expect(c.isConfigured()).toBe(false); }); test('isConfigured() returns true after setApiToken()', () => { const c = new TailscaleCoordClient(); c.setApiToken('tskey-api-foo'); expect(c.isConfigured()).toBe(true); }); test('setApiToken(null) clears the token', () => { const c = new TailscaleCoordClient({ apiToken: 'foo' }); c.setApiToken(null); expect(c.isConfigured()).toBe(false); }); test('constructor accepts apiToken in opts', () => { const c = new TailscaleCoordClient({ apiToken: 'x' }); expect(c.isConfigured()).toBe(true); }); }); describe('tailscale-coord: not configured errors', () => { test('listDevices throws not_configured when no token', async () => { const c = new TailscaleCoordClient(); await expect(c.listDevices()).rejects.toMatchObject({ code: 'not_configured' }); }); test('ping throws not_configured when no token', async () => { const c = new TailscaleCoordClient(); await expect(c.ping()).rejects.toMatchObject({ code: 'not_configured' }); }); test('createAuthKey throws not_configured when no token', async () => { const c = new TailscaleCoordClient(); await expect(c.createAuthKey({})).rejects.toMatchObject({ code: 'not_configured' }); }); }); describe('tailscale-coord: ping()', () => { // ping() now hits /devices and derives tailnet name from magicDNSSuffix // on the first device. (Tailscale retired /preferences in 2026.) test('returns { domain, deviceCount } derived from /devices response', async () => { const fetchImpl = makeFetch({ 'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [ { id: '1', hostname: 'dns2', name: 'dns2-sami.tail3e209.ts.net', addresses: ['100.121.150.22'] }, { id: '2', hostname: 'laptop', name: 'laptop.tail3e209.ts.net', addresses: ['100.91.55.51'] }, ], }, }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); const result = await c.ping(); expect(result.domain).toBe('tail3e209.ts.net'); expect(result.deviceCount).toBe(2); expect(fetchImpl).toHaveBeenCalledTimes(1); // Second call hits cache, no new HTTP request const result2 = await c.ping(); expect(result2.domain).toBe('tail3e209.ts.net'); expect(fetchImpl).toHaveBeenCalledTimes(1); }); test('returns null domain when no .ts.net suffix is in name', async () => { const fetchImpl = makeFetch({ 'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [ { id: '1', name: 'some-other-host.example.com', addresses: ['100.121.150.22'] }, ], }, }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); const result = await c.ping(); expect(result.domain).toBeNull(); }); test('returns null domain when no useful name data is available', async () => { const fetchImpl = makeFetch({ 'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [] }, }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); const result = await c.ping(); expect(result.domain).toBeNull(); expect(result.deviceCount).toBe(0); }); test('skipCache forces a fresh request', async () => { const fetchImpl = makeFetch({ 'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [{ id: '1', name: 'foo.ts.net' }] }, }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); await c.ping(); await c.ping({ skipCache: true }); expect(fetchImpl).toHaveBeenCalledTimes(2); }); test('401 surfaces as TailscaleCoordError code=unauthorized', async () => { const fetchImpl = makeFetch({ 'GET /api/v2/tailnet/-/devices': { status: 401, body: { message: 'unauthorized' } }, }); const c = new TailscaleCoordClient({ apiToken: 'bad-token', fetchImpl }); await expect(c.ping()).rejects.toBeInstanceOf(TailscaleCoordError); await expect(c.ping()).rejects.toMatchObject({ status: 401, code: 'unauthorized' }); }); }); describe('tailscale-coord: listDevices()', () => { const fixtureDevices = [ { id: 'nodekey:1', hostname: 'dns2', addresses: ['100.121.150.22'], os: 'linux', online: true }, { id: 'nodekey:2', hostname: 'laptop', addresses: ['100.91.55.51'], os: 'windows', online: true }, { id: 'nodekey:3', hostname: 'phone', addresses: ['100.106.44.35'], os: 'android', online: false }, ]; test('returns devices array on 200', async () => { const fetchImpl = makeFetch({ 'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: fixtureDevices } }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); const devices = await c.listDevices(); expect(devices).toHaveLength(3); expect(devices[0].hostname).toBe('dns2'); expect(devices[2].online).toBe(false); }); test('empty devices array on 200 with no devices', async () => { const fetchImpl = makeFetch({ 'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [] } }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); const devices = await c.listDevices(); expect(devices).toEqual([]); }); test('missing devices field returns []', async () => { const fetchImpl = makeFetch({ 'GET /api/v2/tailnet/-/devices': { status: 200, body: {} }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); const devices = await c.listDevices(); expect(devices).toEqual([]); }); test('caches list for TTL_DEVICES_MS (60s)', async () => { const fetchImpl = makeFetch({ 'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: fixtureDevices } }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); await c.listDevices(); await c.listDevices(); await c.listDevices(); expect(fetchImpl).toHaveBeenCalledTimes(1); }); test('5xx surfaces as server_error', async () => { const fetchImpl = makeFetch({ 'GET /api/v2/tailnet/-/devices': { status: 503, body: { message: 'unavailable' } }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); await expect(c.listDevices()).rejects.toMatchObject({ status: 503, code: 'server_error' }); }); test('429 surfaces as rate_limited with retryAfter', async () => { const fetchImpl = makeFetch({ 'GET /api/v2/tailnet/-/devices': { status: 429, body: { message: 'too many requests' }, headers: { 'content-type': 'application/json', 'retry-after': '30' }, }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); await expect(c.listDevices()).rejects.toMatchObject({ status: 429, code: 'rate_limited', retryAfter: '30', }); }); test('404 surfaces as not_found', async () => { const fetchImpl = makeFetch({ 'GET /api/v2/tailnet/-/devices': { status: 404, body: { message: 'tailnet not found' } }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); await expect(c.listDevices()).rejects.toMatchObject({ status: 404, code: 'not_found' }); }); }); describe('tailscale-coord: getDevice()', () => { test('returns single device on 200', async () => { const dev = { id: 'nodekey:1', hostname: 'dns2', addresses: ['100.121.150.22'] }; const fetchImpl = makeFetch({ // client URL-encodes the deviceId, so route key uses %3A 'GET /api/v2/device/nodekey%3A1': { status: 200, body: dev }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); const got = await c.getDevice('nodekey:1'); expect(got.id).toBe('nodekey:1'); }); test('encodes deviceId in URL', async () => { const fetchImpl = makeFetch({ 'GET /api/v2/device/nodekey%3A1': { status: 200, body: { id: 'nodekey:1' } }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); await c.getDevice('nodekey:1'); expect(fetchImpl.calls[0].path).toBe('/api/v2/device/nodekey%3A1'); }); test('throws bad_input when deviceId missing', async () => { const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl: makeFetch({}) }); await expect(c.getDevice('')).rejects.toMatchObject({ code: 'bad_input' }); await expect(c.getDevice(null)).rejects.toMatchObject({ code: 'bad_input' }); }); }); describe('tailscale-coord: deleteDevice()', () => { test('returns success on 200 and invalidates device caches', async () => { const fetchImpl = makeFetch({ 'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [{ id: 'd1' }] } }, 'DELETE /api/v2/device/d1': { status: 200, body: {} }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); await c.listDevices(); // populates cache expect(fetchImpl).toHaveBeenCalledTimes(1); await c.deleteDevice('d1'); expect(fetchImpl).toHaveBeenCalledTimes(2); // Next listDevices should re-fetch because cache was invalidated await c.listDevices(); expect(fetchImpl).toHaveBeenCalledTimes(3); }); test('throws bad_input when deviceId missing', async () => { const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl: makeFetch({}) }); await expect(c.deleteDevice('')).rejects.toMatchObject({ code: 'bad_input' }); }); }); describe('tailscale-coord: createAuthKey()', () => { test('sends correct body and returns key on 200', async () => { const fetchImpl = makeFetch({ 'POST /api/v2/tailnet/-/keys': { status: 200, body: { id: 'k1', key: 'tskey-auth-abc123', created: '2026-07-07T00:00:00Z' }, }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); const result = await c.createAuthKey({ reusable: false, ephemeral: true, preauthorized: true, tags: ['tag:guest-plex'], description: 'Plex invite for friend', expirySeconds: 86400, }); expect(result.key).toBe('tskey-auth-abc123'); expect(result.id).toBe('k1'); const sent = JSON.parse(fetchImpl.calls[0].opts.body); expect(sent.reusable).toBe(false); expect(sent.ephemeral).toBe(true); expect(sent.preauthorized).toBe(true); expect(sent.tags).toEqual(['tag:guest-plex']); expect(sent.description).toBe('Plex invite for friend'); expect(sent.expirySeconds).toBe(86400); }); test('omits optional fields when not provided', async () => { const fetchImpl = makeFetch({ 'POST /api/v2/tailnet/-/keys': { status: 200, body: { id: 'k2', key: 'k' } }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); await c.createAuthKey({}); const sent = JSON.parse(fetchImpl.calls[0].opts.body); expect(sent.tags).toBeUndefined(); expect(sent.description).toBeUndefined(); expect(sent.expirySeconds).toBeUndefined(); expect(sent.reusable).toBe(false); // default expect(sent.ephemeral).toBe(false); // default expect(sent.preauthorized).toBe(true); // default }); test('caps expirySeconds at 7776000 (90 days)', async () => { const fetchImpl = makeFetch({ 'POST /api/v2/tailnet/-/keys': { status: 200, body: { id: 'k3' } }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); await c.createAuthKey({ expirySeconds: 99999999 }); const sent = JSON.parse(fetchImpl.calls[0].opts.body); expect(sent.expirySeconds).toBe(7776000); }); test('ignores non-positive expirySeconds', async () => { const fetchImpl = makeFetch({ 'POST /api/v2/tailnet/-/keys': { status: 200, body: { id: 'k4' } }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); await c.createAuthKey({ expirySeconds: 0 }); const sent = JSON.parse(fetchImpl.calls[0].opts.body); expect(sent.expirySeconds).toBeUndefined(); }); test('ignores non-array tags', async () => { const fetchImpl = makeFetch({ 'POST /api/v2/tailnet/-/keys': { status: 200, body: { id: 'k5' } }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); await c.createAuthKey({ tags: 'tag:foo' }); const sent = JSON.parse(fetchImpl.calls[0].opts.body); expect(sent.tags).toBeUndefined(); }); }); describe('tailscale-coord: listAuthKeys()', () => { test('returns keys array and caches for TTL_LIST_MS', async () => { const fetchImpl = makeFetch({ 'GET /api/v2/tailnet/-/keys': { status: 200, body: { keys: [{ id: 'k1' }, { id: 'k2' }] }, }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); const k1 = await c.listAuthKeys(); const k2 = await c.listAuthKeys(); expect(k1).toHaveLength(2); expect(k2).toBe(k1); // cached expect(fetchImpl).toHaveBeenCalledTimes(1); }); test('missing keys field returns []', async () => { const fetchImpl = makeFetch({ 'GET /api/v2/tailnet/-/keys': { status: 200, body: {} }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); const keys = await c.listAuthKeys(); expect(keys).toEqual([]); }); }); describe('tailscale-coord: deleteAuthKey()', () => { test('invalidates keys:list cache', async () => { const fetchImpl = makeFetch({ 'GET /api/v2/tailnet/-/keys': { status: 200, body: { keys: [{ id: 'k1' }] } }, 'DELETE /api/v2/keys/k1': { status: 200, body: {} }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); await c.listAuthKeys(); await c.deleteAuthKey('k1'); await c.listAuthKeys(); expect(fetchImpl).toHaveBeenCalledTimes(3); }); test('throws bad_input when keyId missing', async () => { const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl: makeFetch({}) }); await expect(c.deleteAuthKey('')).rejects.toMatchObject({ code: 'bad_input' }); }); }); describe('tailscale-coord: listUsers()', () => { test('returns users array and caches', async () => { const fetchImpl = makeFetch({ 'GET /api/v2/tailnet/-/users': { status: 200, body: { users: [ { id: 'u1', displayName: 'Sami', loginName: 'sami@github', role: 'admin' }, { id: 'u2', displayName: 'Friend', loginName: 'friend@gmail.com', role: 'member' }, ], }, }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); const u = await c.listUsers(); expect(u).toHaveLength(2); expect(u[0].role).toBe('admin'); await c.listUsers(); expect(fetchImpl).toHaveBeenCalledTimes(1); }); }); describe('tailscale-coord: ACL', () => { const aclFixture = { acls: [{ action: 'accept', src: ['autogroup:member'], dst: ['*:*'] }], ssh: [{ action: 'accept', src: ['autogroup:member'], dst: ['autogroup:self'], users: ['root', 'autogroup:nonroot'] }], }; test('getAcl returns parsed body (not cached)', async () => { const fetchImpl = makeFetch({ 'GET /api/v2/tailnet/-/acl': { status: 200, body: aclFixture }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); const a1 = await c.getAcl(); const a2 = await c.getAcl(); expect(a1.acls[0].action).toBe('accept'); expect(fetchImpl).toHaveBeenCalledTimes(2); // explicitly not cached }); test('updateAcl sends the object as JSON body', async () => { const fetchImpl = makeFetch({ 'POST /api/v2/tailnet/-/acl': { status: 200, body: {} }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); await c.updateAcl(aclFixture); const sent = JSON.parse(fetchImpl.calls[0].opts.body); expect(sent.acls[0].src).toContain('autogroup:member'); }); test('updateAcl throws bad_input on non-object', async () => { const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl: makeFetch({}) }); await expect(c.updateAcl(null)).rejects.toMatchObject({ code: 'bad_input' }); await expect(c.updateAcl('a string')).rejects.toMatchObject({ code: 'bad_input' }); await expect(c.updateAcl([])).rejects.toMatchObject({ code: 'bad_input' }); }); }); describe('tailscale-coord: HTTP shape', () => { test('sends Authorization: Bearer header', async () => { const fetchImpl = makeFetch({ 'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [{ id: '1', name: 'foo.ts.net' }] } }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); await c.ping(); expect(fetchImpl.calls[0].opts.headers.Authorization).toBe('Bearer ' + VALID_TOKEN); }); test('sends Content-Type: application/json on POST with body', async () => { const fetchImpl = makeFetch({ 'POST /api/v2/tailnet/-/keys': { status: 200, body: { id: 'k1' } }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); await c.createAuthKey({ tags: ['tag:x'] }); expect(fetchImpl.calls[0].opts.headers['Content-Type']).toBe('application/json'); }); test('does not send Content-Type when no body', async () => { const fetchImpl = makeFetch({ 'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [{ id: '1', name: 'foo.ts.net' }] } }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); await c.ping(); expect(fetchImpl.calls[0].opts.headers['Content-Type']).toBeUndefined(); }); test('parses string JSON body correctly', async () => { const fetchImpl = makeFetch({ 'GET /api/v2/tailnet/-/devices': { status: 200, body: JSON.stringify({ devices: [{ id: '1', name: 'foo.ts.net' }] }), }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); const result = await c.ping(); expect(result.domain).toBe('foo.ts.net'); expect(result.deviceCount).toBe(1); }); test('non-JSON 200 body returned as string', async () => { const fetchImpl = makeFetch({ 'GET /api/v2/tailnet/-/acl': { status: 200, body: 'not-json', headers: { 'content-type': 'text/plain' } }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); const result = await c.getAcl(); expect(result).toBe('not-json'); }); test('extracts retryAfter from response headers', async () => { const fetchImpl = makeFetch({ 'GET /api/v2/tailnet/-/devices': { status: 429, body: { message: 'slow down' }, headers: { 'content-type': 'application/json', 'retry-after': '60' }, }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); try { await c.listDevices(); throw new Error('expected throw'); } catch (e) { expect(e).toBeInstanceOf(TailscaleCoordError); expect(e.retryAfter).toBe('60'); } }); }); describe('tailscale-coord: cache lifecycle', () => { test('setApiToken clears all caches', async () => { const fetchImpl = makeFetch({ 'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [{ id: '1', name: 'foo.ts.net' }] } }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); await c.ping(); expect(fetchImpl).toHaveBeenCalledTimes(1); c.setApiToken('tskey-api-other'); await c.ping(); expect(fetchImpl).toHaveBeenCalledTimes(2); }); test('expired cache entries re-fetch', async () => { const fetchImpl = makeFetch({ 'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [{ id: '1', name: 'a.ts.net' }] }, }, }); const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl }); await c.ping(); expect(fetchImpl).toHaveBeenCalledTimes(1); // Manually expire the cache entry c._cache.set('ping', { expiresAt: Date.now() - 1000, value: { stale: true } }); const fresh = await c.ping(); expect(fresh.domain).toBe('a.ts.net'); expect(fetchImpl).toHaveBeenCalledTimes(2); }); }); describe('tailscale-coord: error class', () => { test('TailscaleCoordError carries status, code, body, retryAfter', () => { const e = new TailscaleCoordError('test', { status: 429, body: { x: 1 }, retryAfter: '60', code: 'rate_limited' }); expect(e.message).toBe('test'); expect(e.status).toBe(429); expect(e.code).toBe('rate_limited'); expect(e.body).toEqual({ x: 1 }); expect(e.retryAfter).toBe('60'); expect(e).toBeInstanceOf(Error); expect(e).toBeInstanceOf(TailscaleCoordError); }); test('default code derives from status', () => { expect(new TailscaleCoordError('x', { status: 401 }).code).toBe('unauthorized'); expect(new TailscaleCoordError('x', { status: 403 }).code).toBe('unauthorized'); expect(new TailscaleCoordError('x', { status: 404 }).code).toBe('not_found'); expect(new TailscaleCoordError('x', { status: 429 }).code).toBe('rate_limited'); expect(new TailscaleCoordError('x', { status: 500 }).code).toBe('server_error'); expect(new TailscaleCoordError('x', { status: 502 }).code).toBe('server_error'); expect(new TailscaleCoordError('x', {}).code).toBe('unknown'); }); });