/** * Tests for src/managers/tailscale-manager.js * * Strategy: stub `child_process.execFile` so the manager calls a fake `tailscale` * CLI we control in-memory. This lets us exercise every code path — success, * CLI missing, tailscaled down, malformed JSON, cache hit/miss, IPv4 vs IPv6 * selection, device-list shape — without depending on a real Tailscale install. * * The mock is a single function that inspects the args and resolves accordingly, * so we don't have to count invocations. */ 'use strict'; jest.mock('child_process', () => ({ execFile: jest.fn(), })); const { execFile } = require('child_process'); const tm = require('../src/managers/tailscale-manager'); /** * Configure the mock to behave like a specific tailscale binary. * * mode: 'ok-version' → `tailscale version` returns 1.98.4; everything else fails * mode: 'ok-status' → `tailscale version` AND `tailscale status --json` return ok * with the given status fixture * mode: 'no-cli' → everything rejects with ENOENT * mode: 'no-daemon' → version ok, status rejects with code 1 * mode: 'malformed-status' → version ok, status returns malformed JSON */ function configureMock(mode, opts = {}) { execFile.mockImplementation((cmd, args, optsArg, cb) => { // Handle both 3-arg and 4-arg call shapes (promisify passes 3, manual passes 4) if (typeof optsArg === 'function') { cb = optsArg; } const isVersion = Array.isArray(args) && args[0] === 'version'; const isStatus = Array.isArray(args) && args[0] === 'status'; // Match the real `execFile` callback signature: cb(err, {stdout, stderr}) // (modern util.promisify(execFile) resolves with {stdout, stderr}) const ok = (out, err = '') => process.nextTick(() => cb(null, { stdout: out, stderr: err })); const fail = (err) => process.nextTick(() => cb(err)); if (mode === 'no-cli') { const err = new Error('spawn tailscale ENOENT'); err.code = 'ENOENT'; return fail(err); } if (isVersion) { if (mode === 'ok-version' || mode === 'ok-status' || mode === 'no-daemon' || mode === 'malformed-status') { return ok('1.98.4\n'); } } if (isStatus) { if (mode === 'no-daemon') { const err = new Error('tailscaled not running'); err.code = 1; return fail(err); } if (mode === 'malformed-status') { return ok('not json{{{'); } if (mode === 'ok-status') { return ok(JSON.stringify(opts.status || {})); } } // Default: fail const err = new Error(`unhandled mock invocation: ${cmd} ${(args||[]).join(' ')}`); err.code = 1; fail(err); }); } const RUNNING_STATUS = { Version: '1.98.4', BackendState: 'Running', Self: { HostName: 'vmi3080415', TailscaleIPs: ['100.121.150.22', 'fd7a:115c:a1e0::1539:9616'], }, Peer: { p1: { ID: 'p1', HostName: 'peer1', DNSName: 'peer1.tail.ts.net', TailscaleIPs: ['100.100.100.1', 'fd7a::5'], OS: 'linux', Online: true, LastSeen: '2026-07-06T10:00:00Z', UserID: 'u1', KeyExpiry: '2026-08-01T00:00:00Z', Tags: ['tag:server'], ExitNode: false, RxBytes: 100, TxBytes: 200, }, p2: { ID: 'p2', HostName: 'peer2', TailscaleIPs: ['100.100.100.2'], OS: 'iOS', Online: false, }, }, }; beforeEach(() => { tm.invalidateCache(); execFile.mockReset(); }); describe('tailscale-manager', () => { describe('isTailscaleIP() — re-exported from network-detector (DC-031)', () => { test.each([ ['100.64.0.1', true], ['100.121.150.22', true], ['100.127.255.255', true], ['100.63.255.255', false], ['100.128.0.0', false], ['192.168.1.5', false], ['172.17.0.6', false], ['', false], [null, false], [undefined, false], ['not.an.ip', false], ['100.x.y.z', false], ['100.999.0.0', false], ])('isTailscaleIP(%p) === %p', (input, expected) => { expect(tm.isTailscaleIP(input)).toBe(expected); }); }); describe('getStatus()', () => { test('returns parsed JSON on success', async () => { configureMock('ok-status', { status: RUNNING_STATUS }); const s = await tm.getStatus(); expect(s.BackendState).toBe('Running'); expect(s.Self.HostName).toBe('vmi3080415'); expect(s.Peer.p1.HostName).toBe('peer1'); expect(s.Peer.p2.Online).toBe(false); }); test('returns null when CLI is missing', async () => { configureMock('no-cli'); expect(await tm.getStatus()).toBeNull(); }); test('returns null when tailscaled is down', async () => { configureMock('no-daemon'); expect(await tm.getStatus()).toBeNull(); }); test('returns null when stdout is malformed JSON', async () => { configureMock('malformed-status'); expect(await tm.getStatus()).toBeNull(); }); test('caches successful results within CACHE_TTL_MS', async () => { configureMock('ok-status', { status: RUNNING_STATUS }); const a = await tm.getStatus(); const b = await tm.getStatus(); expect(a).toBe(b); // same reference }); test('does NOT cache failed status fetches (so we retry on next call)', async () => { // version succeeds (CLI present) but status returns malformed JSON. // _isInstalled caches the positive result for 1 hour (correctly — // we don't want to re-probe for the CLI on every request). However, // a failed status fetch returns null without being cached, so the // next getStatus() must retry the status command. configureMock('malformed-status'); await tm.getStatus(); const before = execFile.mock.calls.length; await tm.getStatus(); const after = execFile.mock.calls.length; // Second getStatus: _isInstalled hits cache (no call); status re-exec'd (1 call). // So we expect exactly 1 additional execFile call from the second getStatus. expect(after - before).toBe(1); }); }); describe('getLocalIP()', () => { test('returns the first IPv4 TailscaleIP from Self', async () => { configureMock('ok-status', { status: RUNNING_STATUS }); expect(await tm.getLocalIP()).toBe('100.121.150.22'); }); test('returns null when status is null (CLI missing)', async () => { configureMock('no-cli'); expect(await tm.getLocalIP()).toBeNull(); }); test('returns null when Self has no TailscaleIPs', async () => { configureMock('ok-status', { status: { BackendState: 'Running', Self: {}, Peer: {} } }); expect(await tm.getLocalIP()).toBeNull(); }); test('returns null when only IPv6 is assigned', async () => { configureMock('ok-status', { status: { BackendState: 'Running', Self: { TailscaleIPs: ['fd7a:115c::1'] }, Peer: {} }, }); expect(await tm.getLocalIP()).toBeNull(); }); test('returns null when Self is missing entirely', async () => { configureMock('ok-status', { status: { BackendState: 'Running', Peer: {} } }); expect(await tm.getLocalIP()).toBeNull(); }); }); describe('getSummary()', () => { test('returns installed:false when CLI missing', async () => { configureMock('no-cli'); const s = await tm.getSummary(); expect(s.installed).toBe(false); expect(s.connected).toBe(false); expect(s.message).toMatch(/not found/i); }); test('returns installed:true, connected:false when tailscaled down', async () => { configureMock('no-daemon'); const s = await tm.getSummary(); expect(s.installed).toBe(true); expect(s.connected).toBe(false); expect(s.message).toMatch(/not reachable/i); }); test('returns full summary on success', async () => { configureMock('ok-status', { status: RUNNING_STATUS }); const s = await tm.getSummary(); expect(s.installed).toBe(true); expect(s.connected).toBe(true); expect(s.backendState).toBe('Running'); expect(s.hostname).toBe('vmi3080415'); expect(s.ip).toBe('100.121.150.22'); expect(s.ipv6).toBe('fd7a:115c:a1e0::1539:9616'); expect(s.peerCount).toBe(2); expect(s.onlinePeerCount).toBe(1); }); test('handles missing Peer field', async () => { configureMock('ok-status', { status: { BackendState: 'Running', Self: RUNNING_STATUS.Self }, }); const s = await tm.getSummary(); expect(s.peerCount).toBe(0); expect(s.onlinePeerCount).toBe(0); }); }); describe('getDevices()', () => { test('returns empty array when CLI missing', async () => { configureMock('no-cli'); expect(await tm.getDevices()).toEqual([]); }); test('returns empty array when Peer is missing', async () => { configureMock('ok-status', { status: { BackendState: 'Running', Self: RUNNING_STATUS.Self } }); expect(await tm.getDevices()).toEqual([]); }); test('shapes each peer into dashboard-friendly form', async () => { configureMock('ok-status', { status: RUNNING_STATUS }); const devices = await tm.getDevices(); expect(devices).toHaveLength(2); const d1 = devices.find(d => d.id === 'p1'); expect(d1.hostname).toBe('peer1'); expect(d1.dnsName).toBe('peer1.tail.ts.net'); expect(d1.ip).toBe('100.100.100.1'); expect(d1.ips).toEqual(['100.100.100.1', 'fd7a::5']); expect(d1.os).toBe('linux'); expect(d1.online).toBe(true); expect(d1.user).toBe('u1'); expect(d1.tags).toEqual(['tag:server']); expect(d1.isExitNode).toBe(false); expect(d1.rxBytes).toBe(100); expect(d1.txBytes).toBe(200); expect(d1.keyExpiry).toBe('2026-08-01T00:00:00Z'); }); test('handles missing optional peer fields gracefully', async () => { configureMock('ok-status', { status: { BackendState: 'Running', Peer: { minimal: { HostName: 'min' } } }, }); const devices = await tm.getDevices(); expect(devices).toHaveLength(1); expect(devices[0].hostname).toBe('min'); expect(devices[0].ip).toBeNull(); expect(devices[0].ips).toEqual([]); expect(devices[0].tags).toEqual([]); expect(devices[0].online).toBe(false); expect(devices[0].isExitNode).toBe(false); expect(devices[0].rxBytes).toBe(0); expect(devices[0].txBytes).toBe(0); }); }); describe('getAccessToken()', () => { test('returns null (placeholder for OAuth-cached token)', async () => { expect(await tm.getAccessToken()).toBeNull(); }); }); describe('syncAPI()', () => { test('returns a synced result with ISO timestamp', async () => { configureMock('ok-status', { status: RUNNING_STATUS }); const r = await tm.syncAPI(); expect(r.synced).toBe(true); expect(r.at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); }); test('invalidates the cache so next getStatus re-execs', async () => { configureMock('ok-status', { status: RUNNING_STATUS }); // Prime cache await tm.getStatus(); const callsBeforeSync = execFile.mock.calls.length; await tm.syncAPI(); await tm.getStatus(); const callsAfterSync = execFile.mock.calls.length; // After sync, getStatus should re-exec (version + status = 2 calls) expect(callsAfterSync).toBeGreaterThan(callsBeforeSync); }); }); describe('startSyncTimer / stopSyncTimer', () => { afterEach(() => { tm.stopSyncTimer(); jest.useRealTimers(); }); test('fires the callback on interval and stops cleanly', () => { jest.useFakeTimers(); const cb = jest.fn(); tm.startSyncTimer(1000, cb); jest.advanceTimersByTime(3500); expect(cb).toHaveBeenCalledTimes(3); tm.stopSyncTimer(); jest.advanceTimersByTime(5000); expect(cb).toHaveBeenCalledTimes(3); }); test('second startSyncTimer call is a no-op while one is running', () => { jest.useFakeTimers(); const cb1 = jest.fn(); const cb2 = jest.fn(); tm.startSyncTimer(1000, cb1); tm.startSyncTimer(1000, cb2); jest.advanceTimersByTime(2500); // Only the first callback should fire expect(cb1).toHaveBeenCalled(); expect(cb2).not.toHaveBeenCalled(); }); }); describe('invalidateCache()', () => { test('forces a re-fetch on next getStatus', async () => { configureMock('ok-status', { status: RUNNING_STATUS }); await tm.getStatus(); const callsBefore = execFile.mock.calls.length; tm.invalidateCache(); await tm.getStatus(); const callsAfter = execFile.mock.calls.length; expect(callsAfter).toBeGreaterThan(callsBefore); }); }); describe('module API surface (regression guard)', () => { test('exports the documented functions', () => { const expected = [ 'getStatus', 'getLocalIP', 'getSummary', 'getDevices', 'isTailscaleIP', 'invalidateCache', 'getAccessToken', 'startSyncTimer', 'stopSyncTimer', 'syncAPI', ]; for (const fn of expected) { expect(typeof tm[fn]).toBe('function'); } }); }); describe('CLI binary path', () => { test('default is /usr/bin/tailscale', () => { expect(tm._CLI_BIN).toBe('/usr/bin/tailscale'); }); test('respects TAILSCALE_BIN env var', () => { jest.resetModules(); process.env.TAILSCALE_BIN = '/custom/path/tailscale'; const tm2 = require('../src/managers/tailscale-manager'); expect(tm2._CLI_BIN).toBe('/custom/path/tailscale'); delete process.env.TAILSCALE_BIN; }); }); });