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.
575 lines
22 KiB
JavaScript
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();
|
|
});
|
|
}); |