/** * 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('400 on apiToken exceeding 256-char length cap (DC-080)', async () => { const { app } = createApp(); const oversized = 'tskey-api-' + 'x'.repeat(300); // > 256 chars const res = await request(app).put('/api/v1/tailscale/settings').send({ apiToken: oversized }); expect(res.status).toBe(400); expect(res.body.error || res.body.message).toMatch(/exceeds maximum length/i); }); test('400 on non-string apiToken (DC-080)', async () => { const { app } = createApp(); const res = await request(app).put('/api/v1/tailscale/settings').send({ apiToken: 12345 }); 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'); }); test('400 on body.apiToken not starting with tskey-api- (DC-080)', async () => { const fakeClient = makeFakeClient(); 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), 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: 'arbitrary-junk' }); expect(res.status).toBe(400); expect(fakeClient.setApiToken).not.toHaveBeenCalled(); }); test('400 on body.apiToken exceeding length cap (DC-080)', async () => { const fakeClient = makeFakeClient(); 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), hasApiToken: jest.fn(), }; app.use('/api/v1/tailscale', routes({ tailscaleCoord, asyncHandler, log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, })); const oversized = 'tskey-api-' + 'x'.repeat(300); const res = await request(app) .post('/api/v1/tailscale/settings/test') .send({ apiToken: oversized }); expect(res.status).toBe(400); expect(fakeClient.setApiToken).not.toHaveBeenCalled(); }); test('omitting apiToken is allowed (uses stored token path) (DC-080)', async () => { 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 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({}); // no apiToken in body expect(res.status).toBe(200); expect(res.body.valid).toBe(true); }); }); 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 null/123/object tags entries (DC-080)', 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() }, })); // Mixed: null, number, object — all must be rejected const res = await request(app).post('/api/v1/tailscale/admin/keys').send({ tags: ['tag:guest', null, 123, { x: 1 }] }); expect(res.status).toBe(400); expect(fakeClient.createAuthKey).not.toHaveBeenCalled(); }); test('POST /admin/keys rejects uppercase / whitespace / CRLF in tags (DC-080)', 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:guest', 'tag:foo bar', 'tag:x\r\ninjection'] }); expect(res.status).toBe(400); expect(fakeClient.createAuthKey).not.toHaveBeenCalled(); }); test('POST /admin/keys rejects description exceeding 120 chars (DC-080)', 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 longDesc = 'a'.repeat(200); // > 120 chars const res = await request(app).post('/api/v1/tailscale/admin/keys').send({ description: longDesc }); expect(res.status).toBe(400); expect(fakeClient.createAuthKey).not.toHaveBeenCalled(); }); test('POST /admin/keys accepts canonical lowercase tag: form (DC-080)', async () => { const fakeClient = makeFakeClient({ createAuthKey: jest.fn(async (opts) => ({ id: 'k2', key: 'tskey-secret-2', ...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({ tags: ['tag:guest-plex', 'tag:server'], expirySeconds: 86400, }); expect(res.status).toBe(200); expect(fakeClient.createAuthKey).toHaveBeenCalledWith(expect.objectContaining({ tags: ['tag:guest-plex', 'tag:server'], })); }); 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(); }); }); // DC-080 direct validator unit tests (no supertest, no Express) describe('routes/tailscale-admin: DC-080 validators (direct)', () => { const { _validators } = require('../../routes/tailscale-admin'); const { validateApiToken, validateTags, validateDescription, TAILSCALE_TOKEN_PREFIX, TAILSCALE_TOKEN_MAX_LEN, DESCRIPTION_MAX_LEN, } = _validators; describe('validateApiToken', () => { test('accepts canonical tskey-api-...', () => { expect(validateApiToken('tskey-api-abc123')).toBeNull(); }); test('rejects empty', () => { expect(validateApiToken('')).toMatch(/required/); }); test('rejects undefined / null', () => { expect(validateApiToken(undefined)).toMatch(/required/); expect(validateApiToken(null)).toMatch(/required/); }); test('rejects non-string (number, object, array)', () => { expect(validateApiToken(123)).toMatch(/must be a string/); expect(validateApiToken({})).toMatch(/must be a string/); expect(validateApiToken(['x'])).toMatch(/must be a string/); }); test('rejects wrong prefix', () => { expect(validateApiToken('not-a-token')).toMatch(/must start with/); }); test('accepts exactly at length cap', () => { const token = 'tskey-api-' + 'x'.repeat(TAILSCALE_TOKEN_MAX_LEN - 'tskey-api-'.length); expect(validateApiToken(token)).toBeNull(); }); test('rejects 1 over length cap', () => { const token = 'tskey-api-' + 'x'.repeat(TAILSCALE_TOKEN_MAX_LEN - 'tskey-api-'.length + 1); expect(validateApiToken(token)).toMatch(/exceeds maximum length/); }); }); describe('validateTags', () => { test('accepts undefined / null (optional)', () => { expect(validateTags(undefined)).toBeNull(); expect(validateTags(null)).toBeNull(); }); test('rejects non-array', () => { expect(validateTags('tag:foo')).toMatch(/must be an array/); expect(validateTags({})).toMatch(/must be an array/); }); test('rejects entries that are not strings', () => { expect(validateTags(['tag:a', null])).toMatch(/tags\[1\]/); expect(validateTags(['tag:a', 123])).toMatch(/tags\[1\]/); expect(validateTags(['tag:a', {}])).toMatch(/tags\[1\]/); }); test('rejects uppercase / whitespace / CRLF', () => { expect(validateTags(['TAG:foo'])).toMatch(/tags\[0\]/); expect(validateTags(['tag:foo bar'])).toMatch(/tags\[0\]/); expect(validateTags(['tag:foo\r\nbar'])).toMatch(/tags\[0\]/); }); test('rejects entries starting with non-alnum (no leading colon)', () => { expect(validateTags([':foo'])).toMatch(/tags\[0\]/); }); test('rejects bare "tag:" with empty name (Tailscale spec violation) (DC-080 round-2)', () => { expect(validateTags(['tag:'])).toMatch(/tags\[0\]/); }); test('rejects colon-only chars after tag: prefix (DC-080 round-2)', () => { expect(validateTags(['tag:::'])).toMatch(/tags\[0\]/); expect(validateTags(['tag:---'])).toMatch(/tags\[0\]/); }); test('accepts canonical tag:server form', () => { expect(validateTags(['tag:server'])).toBeNull(); expect(validateTags(['tag:guest-plex', 'tag:server'])).toBeNull(); }); test('rejects empty array entry', () => { expect(validateTags(['tag:a', ''])).toMatch(/tags\[1\]/); }); }); describe('validateDescription', () => { test('accepts undefined / null', () => { expect(validateDescription(undefined)).toBeNull(); expect(validateDescription(null)).toBeNull(); }); test('rejects non-string', () => { expect(validateDescription(123)).toMatch(/must be a string/); }); test('rejects over 120 chars', () => { const long = 'a'.repeat(DESCRIPTION_MAX_LEN + 1); expect(validateDescription(long)).toMatch(/exceeds maximum length/); }); test('accepts at the cap', () => { const exact = 'a'.repeat(DESCRIPTION_MAX_LEN); expect(validateDescription(exact)).toBeNull(); }); }); test('exports surface stays in sync with constants used inside validators', () => { // Guard against drift: if a future refactor renames a constant, this fails expect(TAILSCALE_TOKEN_PREFIX).toBe('tskey-api-'); expect(typeof TAILSCALE_TOKEN_MAX_LEN).toBe('number'); expect(typeof DESCRIPTION_MAX_LEN).toBe('number'); }); });