DC-043: tailscale coordination API client + admin/settings routes
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

Companion to DC-042 (tailscale-manager). Adds the write-side of Tailscale
integration — REST client for api.tailscale.com that lets DashCaddy
manage its own tailnet (devices, pre-auth keys, users, ACL).

src/managers/tailscale-coord.js — new module:
  * Ping, list/get/delete devices, create/list/delete pre-auth keys,
    list users, get/update ACL
  * 5min read cache, 60s device-list cache, 1hr ping cache
  * Cache invalidation on writes
  * Graceful {configured:false} when no token
  * TailscaleCoordError class with code mapping (unauthorized, not_found,
    rate_limited, server_error)
  * fetchImpl injection point for tests; native https in production
  * 45 unit tests covering all paths

src/context/index.js — new tailscaleCoord namespace:
  * getClient() lazy-builds a fresh client each call (token re-read from
    credentialManager so settings changes take effect without restart)
  * loadMetadata/saveMetadata for tailscale-config.json
  * setApiToken/hasApiToken wrappers around credentialManager

routes/tailscale-admin.js — new routes:
  * GET    /api/v1/tailscale/settings         — config status, never the token
  * PUT    /api/v1/tailscale/settings         — validate + store encrypted
  * DELETE /api/v1/tailscale/settings         — wipe token + metadata
  * POST   /api/v1/tailscale/settings/test    — ping without saving
  * GET    /api/v1/tailscale/admin/devices    — full device list
  * DELETE /api/v1/tailscale/admin/devices/:id — revoke device
  * GET    /api/v1/tailscale/admin/users      — tailnet users
  * GET    /api/v1/tailscale/admin/keys       — pre-auth key metadata
  * POST   /api/v1/tailscale/admin/keys       — create pre-auth key (returns secret ONCE)
  * DELETE /api/v1/tailscale/admin/keys/:id   — revoke pre-auth key
  * 29 route integration tests with supertest

src/app.js — wired the new router into the /api/v1/tailscale mount.

BACKLOG.md — DC-042 marked done, DC-043 added with full design notes.
            Note: deliberately no token auto-rotation — Tailscale API keys
            don't auto-renew, and silently re-issuing admin credentials
            would erode the audit-trail checkpoint that token expiry
            provides.

Tests: 1167 -> 1212 (+45), all green. Lint clean.
This commit is contained in:
Krystie
2026-07-06 21:28:03 -07:00
parent d04238621f
commit 6fb4f9b169
7 changed files with 1937 additions and 0 deletions
@@ -0,0 +1,575 @@
/**
* Integration tests for routes/tailscale-admin.js — the Tailscale settings +
* admin API surface (PUT/GET/DELETE settings, /admin/devices, /admin/keys).
*
* Strategy:
* - Use supertest against a real Express app mounting the router
* - Mock `tailscaleCoord` (the ctx namespace) so we don't hit real Tailscale
* - Mock `credentialManager` indirectly via the mocked `tailscaleCoord.setApiToken`
* - The route does `new TailscaleCoordClient(...)` inline for the validation
* path; we mock that whole module to inject a fake client
*/
/* eslint-disable require-await, no-unused-vars */
// require-await: many test helper stubs are `async () => value` to match the
// shape of the real function signatures — they don't need to await.
// no-unused-vars: `fakeClient = makeFakeClient()` in some tests exists only to
// satisfy the linter that the helper is reachable; tests that don't exercise a
// particular method intentionally leave it unused.
'use strict';
const express = require('express');
const request = require('supertest');
// --- Mock the coord client module so the PUT/POST routes can instantiate it
// without making real HTTP calls.
jest.mock('../../src/managers/tailscale-coord', () => {
const real = jest.requireActual('../../src/managers/tailscale-coord');
return {
...real,
TailscaleCoordClient: jest.fn(),
TailscaleCoordError: real.TailscaleCoordError,
};
});
const { TailscaleCoordClient, TailscaleCoordError } = require('../../src/managers/tailscale-coord');
function asyncHandler(fn) {
return (req, res, _next) => Promise.resolve(fn(req, res, _next)).catch(_next);
}
function createApp({ initialMetadata = { configured: false }, initialToken = null, mockClient } = {}) {
const stored = { token: initialToken };
let metadata = initialMetadata;
const tailscaleCoord = {
loadMetadata: () => metadata,
saveMetadata: (m) => { metadata = m; },
setApiToken: jest.fn(async (token) => { stored.token = token; }),
getClient: jest.fn(async () => {
// If a token is stored, hand back the mockClient; otherwise a fresh
// unconfigured mock
const FakeClient = jest.requireActual('../../src/managers/tailscale-coord').TailscaleCoordClient;
return new FakeClient({ apiToken: stored.token });
}),
hasApiToken: jest.fn(async () => !!stored.token),
};
const app = express();
app.use(express.json());
const routes = require('../../routes/tailscale-admin');
app.use('/api/v1/tailscale', routes({
tailscaleCoord,
asyncHandler,
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
logError: jest.fn(),
}));
return { app, tailscaleCoord, stored, getMetadata: () => metadata };
}
// Helper: builds a fake coord client instance the way the route uses it
function makeFakeClient({ apiToken = 'tskey-api-fake', ping, listDevices, listAuthKeys, listUsers, createAuthKey, deleteAuthKey, deleteDevice, getAcl, updateAcl } = {}) {
return {
apiToken,
isConfigured: () => !!apiToken,
setApiToken: jest.fn(),
ping: ping || jest.fn(async () => ({ domain: 'fake.ts.net' })),
listDevices: listDevices || jest.fn(async () => []),
listAuthKeys: listAuthKeys || jest.fn(async () => []),
listUsers: listUsers || jest.fn(async () => []),
createAuthKey: createAuthKey || jest.fn(async () => ({ id: 'k1', key: 'tskey-auth-fake' })),
deleteAuthKey: deleteAuthKey || jest.fn(async () => ({ success: true })),
deleteDevice: deleteDevice || jest.fn(async () => ({ success: true })),
getAcl: getAcl || jest.fn(async () => ({ acls: [] })),
updateAcl: updateAcl || jest.fn(async () => ({})),
};
}
describe('routes/tailscale-admin: GET /settings', () => {
test('returns configured:false when metadata is empty', async () => {
const { app } = createApp();
const res = await request(app).get('/api/v1/tailscale/settings');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.configured).toBe(false);
});
test('returns tailnetName + deviceCount when configured', async () => {
const { app } = createApp({
initialMetadata: { configured: true, tailnetName: 'foo.ts.net', deviceCount: 9, keyValidatedAt: '2026-07-07T00:00:00Z' },
});
const res = await request(app).get('/api/v1/tailscale/settings');
expect(res.status).toBe(200);
expect(res.body.configured).toBe(true);
expect(res.body.tailnetName).toBe('foo.ts.net');
expect(res.body.deviceCount).toBe(9);
expect(res.body.keyValidatedAt).toBe('2026-07-07T00:00:00Z');
});
test('never returns the raw token (even if it would be in metadata)', async () => {
const { app } = createApp({
initialMetadata: { configured: true, tailnetName: 'foo.ts.net', apiToken: 'SECRET-SHOULD-NOT-LEAK' },
});
const res = await request(app).get('/api/v1/tailscale/settings');
expect(res.body.apiToken).toBeUndefined();
expect(JSON.stringify(res.body)).not.toContain('SECRET-SHOULD-NOT-LEAK');
});
});
describe('routes/tailscale-admin: PUT /settings', () => {
test('400 on missing apiToken', async () => {
const { app } = createApp();
const res = await request(app).put('/api/v1/tailscale/settings').send({});
expect(res.status).toBe(400);
});
test('400 on apiToken not starting with tskey-api-', async () => {
const { app } = createApp();
const res = await request(app).put('/api/v1/tailscale/settings').send({ apiToken: 'not-a-token' });
expect(res.status).toBe(400);
});
test('200 + saves token + writes metadata on valid token', async () => {
const fakeClient = makeFakeClient({
ping: jest.fn(async () => ({ domain: 'real.ts.net' })),
listDevices: jest.fn(async () => [{ id: 'd1' }, { id: 'd2' }, { id: 'd3' }]),
});
TailscaleCoordClient.mockImplementation(() => fakeClient);
const { app, tailscaleCoord, stored } = createApp();
const res = await request(app)
.put('/api/v1/tailscale/settings')
.send({ apiToken: 'tskey-api-valid-token' });
expect(res.status).toBe(200);
expect(res.body.configured).toBe(true);
expect(res.body.tailnetName).toBe('real.ts.net');
expect(res.body.deviceCount).toBe(3);
expect(tailscaleCoord.setApiToken).toHaveBeenCalledWith('tskey-api-valid-token');
expect(stored.token).toBe('tskey-api-valid-token');
});
test('401 on Tailscale rejection', async () => {
const fakeClient = makeFakeClient({
ping: jest.fn(async () => { throw new TailscaleCoordError('unauthorized', { status: 401, code: 'unauthorized' }); }),
});
TailscaleCoordClient.mockImplementation(() => fakeClient);
const { app, tailscaleCoord } = createApp();
const res = await request(app)
.put('/api/v1/tailscale/settings')
.send({ apiToken: 'tskey-api-bad-token' });
expect(res.status).toBe(401);
expect(tailscaleCoord.setApiToken).not.toHaveBeenCalled();
});
test('502 on other Tailscale errors', async () => {
const fakeClient = makeFakeClient({
ping: jest.fn(async () => { throw new TailscaleCoordError('server error', { status: 500, code: 'server_error' }); }),
});
TailscaleCoordClient.mockImplementation(() => fakeClient);
const { app } = createApp();
const res = await request(app)
.put('/api/v1/tailscale/settings')
.send({ apiToken: 'tskey-api-fails' });
expect(res.status).toBe(502);
});
test('proceeds even if device count fetch fails', async () => {
const fakeClient = makeFakeClient({
ping: jest.fn(async () => ({ domain: 'real.ts.net' })),
listDevices: jest.fn(async () => { throw new Error('boom'); }),
});
TailscaleCoordClient.mockImplementation(() => fakeClient);
const { app } = createApp();
const res = await request(app)
.put('/api/v1/tailscale/settings')
.send({ apiToken: 'tskey-api-valid-token' });
expect(res.status).toBe(200);
expect(res.body.deviceCount).toBeNull();
expect(res.body.tailnetName).toBe('real.ts.net');
});
});
describe('routes/tailscale-admin: DELETE /settings', () => {
test('clears token + metadata, returns configured:false', async () => {
const { app, tailscaleCoord, stored, getMetadata } = createApp({
initialMetadata: { configured: true, tailnetName: 'foo.ts.net' },
initialToken: 'tskey-api-something',
});
const res = await request(app).delete('/api/v1/tailscale/settings');
expect(res.status).toBe(200);
expect(res.body.configured).toBe(false);
expect(tailscaleCoord.setApiToken).toHaveBeenCalledWith(null);
expect(stored.token).toBeNull();
expect(getMetadata()).toEqual({ configured: false });
});
});
describe('routes/tailscale-admin: POST /settings/test', () => {
test('returns valid:false when no token configured', async () => {
const { app } = createApp({ initialToken: null });
const res = await request(app).post('/api/v1/tailscale/settings/test').send({});
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
expect(res.body.error).toMatch(/no Tailscale API token/i);
});
test('returns valid:true + tailnetName on successful ping (stored token)', async () => {
// Build an app where getClient returns a fake with our desired ping
const fakeClient = makeFakeClient({ ping: jest.fn(async () => ({ domain: 'stored.ts.net' })) });
const app = express();
app.use(express.json());
const routes = require('../../routes/tailscale-admin');
const stored = { token: 'tskey-api-stored' };
const tailscaleCoord = {
loadMetadata: () => ({ configured: true }),
saveMetadata: jest.fn(),
setApiToken: jest.fn(),
getClient: jest.fn(async () => fakeClient),
hasApiToken: jest.fn(),
};
app.use('/api/v1/tailscale', routes({
tailscaleCoord, asyncHandler,
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
}));
const res = await request(app).post('/api/v1/tailscale/settings/test').send({});
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
expect(res.body.tailnetName).toBe('stored.ts.net');
expect(fakeClient.ping).toHaveBeenCalledWith({ skipCache: true });
});
test('returns valid:false on Tailscale unauthorized', async () => {
const fakeClient = makeFakeClient({
ping: jest.fn(async () => { throw new TailscaleCoordError('unauthorized', { status: 401, code: 'unauthorized' }); }),
});
const app = express();
app.use(express.json());
const routes = require('../../routes/tailscale-admin');
const tailscaleCoord = {
loadMetadata: () => ({ configured: true }),
saveMetadata: jest.fn(),
setApiToken: jest.fn(),
getClient: jest.fn(async () => fakeClient),
hasApiToken: jest.fn(),
};
app.use('/api/v1/tailscale', routes({
tailscaleCoord, asyncHandler,
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
}));
const res = await request(app).post('/api/v1/tailscale/settings/test').send({});
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
expect(res.body.error).toMatch(/unauthorized/);
});
test('uses body.apiToken override when provided', async () => {
const fakeClient = makeFakeClient({ ping: jest.fn(async () => ({ domain: 'override.ts.net' })) });
const app = express();
app.use(express.json());
const routes = require('../../routes/tailscale-admin');
const tailscaleCoord = {
loadMetadata: () => ({ configured: false }),
saveMetadata: jest.fn(),
setApiToken: jest.fn(),
getClient: jest.fn(async () => fakeClient), // pre-loaded fake
hasApiToken: jest.fn(),
};
app.use('/api/v1/tailscale', routes({
tailscaleCoord, asyncHandler,
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
}));
const res = await request(app)
.post('/api/v1/tailscale/settings/test')
.send({ apiToken: 'tskey-api-test-only' });
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
expect(fakeClient.setApiToken).toHaveBeenCalledWith('tskey-api-test-only');
});
});
describe('routes/tailscale-admin: GET /admin/devices', () => {
test('503 when no token configured', async () => {
const { app } = createApp({ initialToken: null });
const res = await request(app).get('/api/v1/tailscale/admin/devices');
expect(res.status).toBe(503);
});
test('returns devices list when configured', async () => {
const fakeClient = makeFakeClient({
listDevices: jest.fn(async () => [{ id: 'd1', hostname: 'a' }, { id: 'd2', hostname: 'b' }]),
});
const app = express();
app.use(express.json());
const routes = require('../../routes/tailscale-admin');
const tailscaleCoord = {
loadMetadata: () => ({ configured: true }),
saveMetadata: jest.fn(),
setApiToken: jest.fn(),
getClient: jest.fn(async () => fakeClient),
hasApiToken: jest.fn(),
};
app.use('/api/v1/tailscale', routes({
tailscaleCoord, asyncHandler,
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
}));
const res = await request(app).get('/api/v1/tailscale/admin/devices');
expect(res.status).toBe(200);
expect(res.body.devices).toHaveLength(2);
expect(res.body.count).toBe(2);
});
test('401 when token invalid', async () => {
const fakeClient = makeFakeClient({
listDevices: jest.fn(async () => { throw new TailscaleCoordError('unauth', { status: 401, code: 'unauthorized' }); }),
});
const app = express();
app.use(express.json());
const routes = require('../../routes/tailscale-admin');
const tailscaleCoord = {
loadMetadata: () => ({ configured: true }),
saveMetadata: jest.fn(),
setApiToken: jest.fn(),
getClient: jest.fn(async () => fakeClient),
hasApiToken: jest.fn(),
};
app.use('/api/v1/tailscale', routes({
tailscaleCoord, asyncHandler,
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
}));
const res = await request(app).get('/api/v1/tailscale/admin/devices');
expect(res.status).toBe(401);
});
});
describe('routes/tailscale-admin: DELETE /admin/devices/:id', () => {
test('503 when no token configured', async () => {
const { app } = createApp({ initialToken: null });
const res = await request(app).delete('/api/v1/tailscale/admin/devices/d1');
expect(res.status).toBe(503);
});
test('returns success on 200', async () => {
const fakeClient = makeFakeClient({ deleteDevice: jest.fn(async () => ({ success: true })) });
const app = express();
app.use(express.json());
const routes = require('../../routes/tailscale-admin');
const tailscaleCoord = {
loadMetadata: () => ({ configured: true }),
saveMetadata: jest.fn(),
setApiToken: jest.fn(),
getClient: jest.fn(async () => fakeClient),
hasApiToken: jest.fn(),
};
app.use('/api/v1/tailscale', routes({
tailscaleCoord, asyncHandler,
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
}));
const res = await request(app).delete('/api/v1/tailscale/admin/devices/d1');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(fakeClient.deleteDevice).toHaveBeenCalledWith('d1');
});
test('404 when device not found', async () => {
const fakeClient = makeFakeClient({
deleteDevice: jest.fn(async () => { throw new TailscaleCoordError('not found', { status: 404, code: 'not_found' }); }),
});
const app = express();
app.use(express.json());
const routes = require('../../routes/tailscale-admin');
const tailscaleCoord = {
loadMetadata: () => ({ configured: true }),
saveMetadata: jest.fn(),
setApiToken: jest.fn(),
getClient: jest.fn(async () => fakeClient),
hasApiToken: jest.fn(),
};
app.use('/api/v1/tailscale', routes({
tailscaleCoord, asyncHandler,
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
}));
const res = await request(app).delete('/api/v1/tailscale/admin/devices/missing');
expect(res.status).toBe(404);
});
});
describe('routes/tailscale-admin: GET /admin/users', () => {
test('returns users list', async () => {
const fakeClient = makeFakeClient({
listUsers: jest.fn(async () => [{ id: 'u1', displayName: 'Sami' }, { id: 'u2', displayName: 'Friend' }]),
});
const app = express();
app.use(express.json());
const routes = require('../../routes/tailscale-admin');
const tailscaleCoord = {
loadMetadata: () => ({ configured: true }),
saveMetadata: jest.fn(),
setApiToken: jest.fn(),
getClient: jest.fn(async () => fakeClient),
hasApiToken: jest.fn(),
};
app.use('/api/v1/tailscale', routes({
tailscaleCoord, asyncHandler,
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
}));
const res = await request(app).get('/api/v1/tailscale/admin/users');
expect(res.status).toBe(200);
expect(res.body.users).toHaveLength(2);
});
test('503 when not configured', async () => {
const { app } = createApp({ initialToken: null });
const res = await request(app).get('/api/v1/tailscale/admin/users');
expect(res.status).toBe(503);
});
});
describe('routes/tailscale-admin: pre-auth keys', () => {
test('GET /admin/keys returns keys list', async () => {
const fakeClient = makeFakeClient({
listAuthKeys: jest.fn(async () => [{ id: 'k1', description: 'foo' }]),
});
const app = express();
app.use(express.json());
const routes = require('../../routes/tailscale-admin');
const tailscaleCoord = {
loadMetadata: () => ({ configured: true }),
saveMetadata: jest.fn(),
setApiToken: jest.fn(),
getClient: jest.fn(async () => fakeClient),
hasApiToken: jest.fn(),
};
app.use('/api/v1/tailscale', routes({
tailscaleCoord, asyncHandler,
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
}));
const res = await request(app).get('/api/v1/tailscale/admin/keys');
expect(res.status).toBe(200);
expect(res.body.keys).toHaveLength(1);
expect(res.body.count).toBe(1);
});
test('POST /admin/keys creates a key and returns the secret', async () => {
const fakeClient = makeFakeClient({
createAuthKey: jest.fn(async (opts) => ({ id: 'k1', key: 'tskey-auth-secret', ...opts })),
});
const app = express();
app.use(express.json());
const routes = require('../../routes/tailscale-admin');
const tailscaleCoord = {
loadMetadata: () => ({ configured: true }),
saveMetadata: jest.fn(),
setApiToken: jest.fn(),
getClient: jest.fn(async () => fakeClient),
hasApiToken: jest.fn(),
};
app.use('/api/v1/tailscale', routes({
tailscaleCoord, asyncHandler,
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
}));
const res = await request(app).post('/api/v1/tailscale/admin/keys').send({
reusable: false,
ephemeral: true,
tags: ['tag:guest'],
description: 'Plex invite',
expirySeconds: 86400,
});
expect(res.status).toBe(200);
expect(res.body.id).toBe('k1');
expect(res.body.key).toBe('tskey-auth-secret');
expect(fakeClient.createAuthKey).toHaveBeenCalledWith(expect.objectContaining({
tags: ['tag:guest'],
expirySeconds: 86400,
}));
});
test('POST /admin/keys rejects non-array tags', async () => {
const fakeClient = makeFakeClient();
const app = express();
app.use(express.json());
const routes = require('../../routes/tailscale-admin');
const tailscaleCoord = {
loadMetadata: () => ({ configured: true }),
saveMetadata: jest.fn(),
setApiToken: jest.fn(),
getClient: jest.fn(async () => fakeClient),
hasApiToken: jest.fn(),
};
app.use('/api/v1/tailscale', routes({
tailscaleCoord, asyncHandler,
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
}));
const res = await request(app).post('/api/v1/tailscale/admin/keys').send({ tags: 'tag:foo' });
expect(res.status).toBe(400);
});
test('POST /admin/keys rejects negative expirySeconds', async () => {
const fakeClient = makeFakeClient();
const app = express();
app.use(express.json());
const routes = require('../../routes/tailscale-admin');
const tailscaleCoord = {
loadMetadata: () => ({ configured: true }),
saveMetadata: jest.fn(),
setApiToken: jest.fn(),
getClient: jest.fn(async () => fakeClient),
hasApiToken: jest.fn(),
};
app.use('/api/v1/tailscale', routes({
tailscaleCoord, asyncHandler,
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
}));
const res = await request(app).post('/api/v1/tailscale/admin/keys').send({ expirySeconds: -1 });
expect(res.status).toBe(400);
});
test('DELETE /admin/keys/:id returns success', async () => {
const fakeClient = makeFakeClient({
deleteAuthKey: jest.fn(async () => ({ success: true })),
});
const app = express();
app.use(express.json());
const routes = require('../../routes/tailscale-admin');
const tailscaleCoord = {
loadMetadata: () => ({ configured: true }),
saveMetadata: jest.fn(),
setApiToken: jest.fn(),
getClient: jest.fn(async () => fakeClient),
hasApiToken: jest.fn(),
};
app.use('/api/v1/tailscale', routes({
tailscaleCoord, asyncHandler,
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
}));
const res = await request(app).delete('/api/v1/tailscale/admin/keys/k1');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(fakeClient.deleteAuthKey).toHaveBeenCalledWith('k1');
});
});
describe('routes/tailscale-admin: security boundary', () => {
test('GET /settings never leaks the apiToken field from metadata', async () => {
const { app } = createApp({
initialMetadata: { configured: true, tailnetName: 'foo.ts.net', apiToken: 'RAW-LEAK', apiKey: 'LEAK2' },
});
const res = await request(app).get('/api/v1/tailscale/settings');
expect(JSON.stringify(res.body)).not.toContain('RAW-LEAK');
expect(JSON.stringify(res.body)).not.toContain('LEAK2');
});
test('DELETE /settings wipes stored token', async () => {
const fakeClient = makeFakeClient();
const { app, stored } = createApp({ initialToken: 'tskey-api-real' });
await request(app).delete('/api/v1/tailscale/settings');
expect(stored.token).toBeNull();
});
});
@@ -0,0 +1,605 @@
/**
* 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 <token> 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');
});
});