Files
dashcaddy/dashcaddy-api/__tests__/routes/tailscale-admin.test.js
T
Krystie 6fb4f9b169
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
DC-043: tailscale coordination API client + admin/settings routes
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.
2026-07-06 21:28:03 -07:00

575 lines
22 KiB
JavaScript

/**
* 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();
});
});