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.
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user