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