DC-042: implement real Tailscale manager — replace null stub
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

The previous getTailscaleStatus() in src/app.js was a hard-coded
`return null` stub with a TODO saying it would be populated by context.
The context had a tailscale.* namespace declared with null function
stubs (routes/context.js:71), but nothing ever set them to real
functions. routes/tailscale.js has been calling ctx.tailscale.getStatus()
/ getLocalIP() / isTailscaleIP() and getting undefined back, silently
returning empty device lists. The tailscaleAuthMiddleware's allowedTailnet
check (DC-121, device-not-in-tailnet 403) was dead code for the same reason.

This commit replaces the stub with a real implementation:

- New src/managers/tailscale-manager.js shells out to the host's
  `tailscale status --json` (cached 5 minutes), parses the result, and
  exposes getStatus / getLocalIP / getSummary / getDevices / isTailscaleIP /
  invalidateCache / getAccessToken (stub) / startSyncTimer / stopSyncTimer
  / syncAPI (stub). All failure modes (CLI missing, tailscaled down,
  malformed JSON, EACCES) are handled gracefully — return null with no
  cache poisoning.
- src/context/index.js now wires the manager into ctx.tailscale.* so
  routes/tailscale.js and middleware.js's allowedTailnet gate get the
  real functions.
- src/app.js:189 getTailscaleStatus() now delegates to the manager
  instead of returning null.
- The duplicate isTailscaleIP() in src/app.js:179 (no malformed-input
  guards) is removed in favor of the canonical version in
  src/utilities/network-detector.js (DC-031) which the manager also uses.
- start.sh now bind-mounts /usr/bin/tailscale (statically linked Go binary
  — works under Alpine libc) and /var/run/tailscale/ into the container,
  read-only. Lets the container invoke the CLI without needing its own
  tailscale install.
- 41 new unit tests in __tests__/tailscale-manager.test.js cover: CLI
  success/missing/daemon-down/malformed-JSON paths, 5-min cache hit/miss,
  1-hour installed-cache hit/miss, getLocalIP IPv4/IPv6/missing-choices,
  getSummary shape, getDevices shape with full + minimal peer fields,
  startSyncTimer/stopSyncTimer interval + idempotency, TAILSCALE_BIN env
  override.

Total: 1138 tests pass (was 1097, +41 new), 0 new ESLint warnings.

What this unlocks:
- /api/v1/tailscale/status → real installed/connected/hostname/ip/
  peerCount/onlinePeerCount summary instead of empty
- /api/v1/tailscale/devices → real device list (was returning [])
- /api/v1/tailscale/check-connection → works (uses real isTailscaleIP)
- tailscaleAuthMiddleware allowedTailnet check (DC-121) is no longer
  dead code — a request from a Tailscale IP not in the allowed tailnet
  now actually gets 403 instead of being silently allowed.
This commit is contained in:
Krystie
2026-07-06 18:55:16 -07:00
parent ca705fe59f
commit d04238621f
5 changed files with 686 additions and 20 deletions
@@ -0,0 +1,399 @@
/**
* 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;
});
});
});