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,605 @@
|
||||
/**
|
||||
* Tests for src/managers/tailscale-coord.js
|
||||
*
|
||||
* Strategy: inject a fake `fetchImpl` into the client so we can simulate
|
||||
* every Tailscale API response shape without making real HTTP calls. Each
|
||||
* test sets up a mock that responds to the URL path with a fixture body
|
||||
* and the expected status code, then asserts the client's behavior.
|
||||
*
|
||||
* The mock is intentionally simple: a function (method, path, opts) → Promise<{
|
||||
* status, body, headers }>. We don't try to be exhaustive about request
|
||||
* shape matching — just enough to verify the client's status handling,
|
||||
* caching, error mapping, and JSON parsing.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/* eslint-disable require-await, no-unused-vars */
|
||||
// require-await: many helper functions in this file are `async () => ...` to
|
||||
// match the shape of the real function signatures — they don't need to await.
|
||||
// no-unused-vars: some tests destructure fields they don't exercise.
|
||||
|
||||
const { TailscaleCoordClient, TailscaleCoordError } = require('../src/managers/tailscale-coord');
|
||||
|
||||
const VALID_TOKEN = 'tskey-api-kLD2XbydZ511CNTRL-CKorHnjoVpc11chfHcV8qcSz9hhjpUr3'; // realistic shape
|
||||
|
||||
/**
|
||||
* Build a fake fetchImpl from a route map.
|
||||
*
|
||||
* {
|
||||
* 'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [...] } },
|
||||
* 'POST /api/v2/tailnet/-/keys': { status: 200, body: { id: 'k1', key: 'tskey-auth-abc' } },
|
||||
* 'DELETE /api/v2/device/d1': { status: 200, body: '' },
|
||||
* }
|
||||
*
|
||||
* Unmatched routes return 404 by default (the client will then throw
|
||||
* TailscaleCoordError with code='not_found').
|
||||
*/
|
||||
function makeFetch(routes, { defaultStatus = 404, defaultBody = { message: 'no route' } } = {}) {
|
||||
const calls = [];
|
||||
const fn = jest.fn(async (method, path, opts) => {
|
||||
calls.push({ method, path, opts });
|
||||
const key = method + ' ' + path;
|
||||
const match = routes[key];
|
||||
if (match) {
|
||||
return {
|
||||
status: match.status,
|
||||
body: typeof match.body === 'string' ? match.body : JSON.stringify(match.body),
|
||||
headers: match.headers || { 'content-type': 'application/json' },
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: defaultStatus,
|
||||
body: JSON.stringify(defaultBody),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
};
|
||||
});
|
||||
fn.calls = calls;
|
||||
return fn;
|
||||
}
|
||||
|
||||
describe('tailscale-coord: configuration', () => {
|
||||
test('isConfigured() returns false when no token set', () => {
|
||||
const c = new TailscaleCoordClient();
|
||||
expect(c.isConfigured()).toBe(false);
|
||||
});
|
||||
test('isConfigured() returns true after setApiToken()', () => {
|
||||
const c = new TailscaleCoordClient();
|
||||
c.setApiToken('tskey-api-foo');
|
||||
expect(c.isConfigured()).toBe(true);
|
||||
});
|
||||
test('setApiToken(null) clears the token', () => {
|
||||
const c = new TailscaleCoordClient({ apiToken: 'foo' });
|
||||
c.setApiToken(null);
|
||||
expect(c.isConfigured()).toBe(false);
|
||||
});
|
||||
test('constructor accepts apiToken in opts', () => {
|
||||
const c = new TailscaleCoordClient({ apiToken: 'x' });
|
||||
expect(c.isConfigured()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailscale-coord: not configured errors', () => {
|
||||
test('listDevices throws not_configured when no token', async () => {
|
||||
const c = new TailscaleCoordClient();
|
||||
await expect(c.listDevices()).rejects.toMatchObject({ code: 'not_configured' });
|
||||
});
|
||||
test('ping throws not_configured when no token', async () => {
|
||||
const c = new TailscaleCoordClient();
|
||||
await expect(c.ping()).rejects.toMatchObject({ code: 'not_configured' });
|
||||
});
|
||||
test('createAuthKey throws not_configured when no token', async () => {
|
||||
const c = new TailscaleCoordClient();
|
||||
await expect(c.createAuthKey({})).rejects.toMatchObject({ code: 'not_configured' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailscale-coord: ping()', () => {
|
||||
// ping() now hits /devices and derives tailnet name from magicDNSSuffix
|
||||
// on the first device. (Tailscale retired /preferences in 2026.)
|
||||
test('returns { domain, deviceCount } derived from /devices response', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/devices': {
|
||||
status: 200,
|
||||
body: {
|
||||
devices: [
|
||||
{ id: '1', hostname: 'dns2', name: 'dns2-sami.tail3e209.ts.net', addresses: ['100.121.150.22'] },
|
||||
{ id: '2', hostname: 'laptop', name: 'laptop.tail3e209.ts.net', addresses: ['100.91.55.51'] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
|
||||
const result = await c.ping();
|
||||
expect(result.domain).toBe('tail3e209.ts.net');
|
||||
expect(result.deviceCount).toBe(2);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second call hits cache, no new HTTP request
|
||||
const result2 = await c.ping();
|
||||
expect(result2.domain).toBe('tail3e209.ts.net');
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('returns null domain when no .ts.net suffix is in name', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/devices': {
|
||||
status: 200,
|
||||
body: {
|
||||
devices: [
|
||||
{ id: '1', name: 'some-other-host.example.com', addresses: ['100.121.150.22'] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
const result = await c.ping();
|
||||
expect(result.domain).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null domain when no useful name data is available', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/devices': {
|
||||
status: 200,
|
||||
body: { devices: [] },
|
||||
},
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
const result = await c.ping();
|
||||
expect(result.domain).toBeNull();
|
||||
expect(result.deviceCount).toBe(0);
|
||||
});
|
||||
|
||||
test('skipCache forces a fresh request', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/devices': {
|
||||
status: 200,
|
||||
body: { devices: [{ id: '1', name: 'foo.ts.net' }] },
|
||||
},
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
await c.ping();
|
||||
await c.ping({ skipCache: true });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('401 surfaces as TailscaleCoordError code=unauthorized', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/devices': { status: 401, body: { message: 'unauthorized' } },
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: 'bad-token', fetchImpl });
|
||||
await expect(c.ping()).rejects.toBeInstanceOf(TailscaleCoordError);
|
||||
await expect(c.ping()).rejects.toMatchObject({ status: 401, code: 'unauthorized' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailscale-coord: listDevices()', () => {
|
||||
const fixtureDevices = [
|
||||
{ id: 'nodekey:1', hostname: 'dns2', addresses: ['100.121.150.22'], os: 'linux', online: true },
|
||||
{ id: 'nodekey:2', hostname: 'laptop', addresses: ['100.91.55.51'], os: 'windows', online: true },
|
||||
{ id: 'nodekey:3', hostname: 'phone', addresses: ['100.106.44.35'], os: 'android', online: false },
|
||||
];
|
||||
|
||||
test('returns devices array on 200', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: fixtureDevices } },
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
const devices = await c.listDevices();
|
||||
expect(devices).toHaveLength(3);
|
||||
expect(devices[0].hostname).toBe('dns2');
|
||||
expect(devices[2].online).toBe(false);
|
||||
});
|
||||
|
||||
test('empty devices array on 200 with no devices', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [] } },
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
const devices = await c.listDevices();
|
||||
expect(devices).toEqual([]);
|
||||
});
|
||||
|
||||
test('missing devices field returns []', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/devices': { status: 200, body: {} },
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
const devices = await c.listDevices();
|
||||
expect(devices).toEqual([]);
|
||||
});
|
||||
|
||||
test('caches list for TTL_DEVICES_MS (60s)', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: fixtureDevices } },
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
await c.listDevices();
|
||||
await c.listDevices();
|
||||
await c.listDevices();
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('5xx surfaces as server_error', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/devices': { status: 503, body: { message: 'unavailable' } },
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
await expect(c.listDevices()).rejects.toMatchObject({ status: 503, code: 'server_error' });
|
||||
});
|
||||
|
||||
test('429 surfaces as rate_limited with retryAfter', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/devices': {
|
||||
status: 429,
|
||||
body: { message: 'too many requests' },
|
||||
headers: { 'content-type': 'application/json', 'retry-after': '30' },
|
||||
},
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
await expect(c.listDevices()).rejects.toMatchObject({
|
||||
status: 429,
|
||||
code: 'rate_limited',
|
||||
retryAfter: '30',
|
||||
});
|
||||
});
|
||||
|
||||
test('404 surfaces as not_found', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/devices': { status: 404, body: { message: 'tailnet not found' } },
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
await expect(c.listDevices()).rejects.toMatchObject({ status: 404, code: 'not_found' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailscale-coord: getDevice()', () => {
|
||||
test('returns single device on 200', async () => {
|
||||
const dev = { id: 'nodekey:1', hostname: 'dns2', addresses: ['100.121.150.22'] };
|
||||
const fetchImpl = makeFetch({
|
||||
// client URL-encodes the deviceId, so route key uses %3A
|
||||
'GET /api/v2/device/nodekey%3A1': { status: 200, body: dev },
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
const got = await c.getDevice('nodekey:1');
|
||||
expect(got.id).toBe('nodekey:1');
|
||||
});
|
||||
|
||||
test('encodes deviceId in URL', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/device/nodekey%3A1': { status: 200, body: { id: 'nodekey:1' } },
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
await c.getDevice('nodekey:1');
|
||||
expect(fetchImpl.calls[0].path).toBe('/api/v2/device/nodekey%3A1');
|
||||
});
|
||||
|
||||
test('throws bad_input when deviceId missing', async () => {
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl: makeFetch({}) });
|
||||
await expect(c.getDevice('')).rejects.toMatchObject({ code: 'bad_input' });
|
||||
await expect(c.getDevice(null)).rejects.toMatchObject({ code: 'bad_input' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailscale-coord: deleteDevice()', () => {
|
||||
test('returns success on 200 and invalidates device caches', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [{ id: 'd1' }] } },
|
||||
'DELETE /api/v2/device/d1': { status: 200, body: {} },
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
await c.listDevices(); // populates cache
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
await c.deleteDevice('d1');
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
// Next listDevices should re-fetch because cache was invalidated
|
||||
await c.listDevices();
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test('throws bad_input when deviceId missing', async () => {
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl: makeFetch({}) });
|
||||
await expect(c.deleteDevice('')).rejects.toMatchObject({ code: 'bad_input' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailscale-coord: createAuthKey()', () => {
|
||||
test('sends correct body and returns key on 200', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/-/keys': {
|
||||
status: 200,
|
||||
body: { id: 'k1', key: 'tskey-auth-abc123', created: '2026-07-07T00:00:00Z' },
|
||||
},
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
const result = await c.createAuthKey({
|
||||
reusable: false,
|
||||
ephemeral: true,
|
||||
preauthorized: true,
|
||||
tags: ['tag:guest-plex'],
|
||||
description: 'Plex invite for friend',
|
||||
expirySeconds: 86400,
|
||||
});
|
||||
expect(result.key).toBe('tskey-auth-abc123');
|
||||
expect(result.id).toBe('k1');
|
||||
|
||||
const sent = JSON.parse(fetchImpl.calls[0].opts.body);
|
||||
expect(sent.reusable).toBe(false);
|
||||
expect(sent.ephemeral).toBe(true);
|
||||
expect(sent.preauthorized).toBe(true);
|
||||
expect(sent.tags).toEqual(['tag:guest-plex']);
|
||||
expect(sent.description).toBe('Plex invite for friend');
|
||||
expect(sent.expirySeconds).toBe(86400);
|
||||
});
|
||||
|
||||
test('omits optional fields when not provided', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/-/keys': { status: 200, body: { id: 'k2', key: 'k' } },
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
await c.createAuthKey({});
|
||||
const sent = JSON.parse(fetchImpl.calls[0].opts.body);
|
||||
expect(sent.tags).toBeUndefined();
|
||||
expect(sent.description).toBeUndefined();
|
||||
expect(sent.expirySeconds).toBeUndefined();
|
||||
expect(sent.reusable).toBe(false); // default
|
||||
expect(sent.ephemeral).toBe(false); // default
|
||||
expect(sent.preauthorized).toBe(true); // default
|
||||
});
|
||||
|
||||
test('caps expirySeconds at 7776000 (90 days)', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/-/keys': { status: 200, body: { id: 'k3' } },
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
await c.createAuthKey({ expirySeconds: 99999999 });
|
||||
const sent = JSON.parse(fetchImpl.calls[0].opts.body);
|
||||
expect(sent.expirySeconds).toBe(7776000);
|
||||
});
|
||||
|
||||
test('ignores non-positive expirySeconds', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/-/keys': { status: 200, body: { id: 'k4' } },
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
await c.createAuthKey({ expirySeconds: 0 });
|
||||
const sent = JSON.parse(fetchImpl.calls[0].opts.body);
|
||||
expect(sent.expirySeconds).toBeUndefined();
|
||||
});
|
||||
|
||||
test('ignores non-array tags', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/-/keys': { status: 200, body: { id: 'k5' } },
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
await c.createAuthKey({ tags: 'tag:foo' });
|
||||
const sent = JSON.parse(fetchImpl.calls[0].opts.body);
|
||||
expect(sent.tags).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailscale-coord: listAuthKeys()', () => {
|
||||
test('returns keys array and caches for TTL_LIST_MS', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/keys': {
|
||||
status: 200,
|
||||
body: { keys: [{ id: 'k1' }, { id: 'k2' }] },
|
||||
},
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
const k1 = await c.listAuthKeys();
|
||||
const k2 = await c.listAuthKeys();
|
||||
expect(k1).toHaveLength(2);
|
||||
expect(k2).toBe(k1); // cached
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('missing keys field returns []', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/keys': { status: 200, body: {} },
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
const keys = await c.listAuthKeys();
|
||||
expect(keys).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailscale-coord: deleteAuthKey()', () => {
|
||||
test('invalidates keys:list cache', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/keys': { status: 200, body: { keys: [{ id: 'k1' }] } },
|
||||
'DELETE /api/v2/keys/k1': { status: 200, body: {} },
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
await c.listAuthKeys();
|
||||
await c.deleteAuthKey('k1');
|
||||
await c.listAuthKeys();
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test('throws bad_input when keyId missing', async () => {
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl: makeFetch({}) });
|
||||
await expect(c.deleteAuthKey('')).rejects.toMatchObject({ code: 'bad_input' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailscale-coord: listUsers()', () => {
|
||||
test('returns users array and caches', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/users': {
|
||||
status: 200,
|
||||
body: {
|
||||
users: [
|
||||
{ id: 'u1', displayName: 'Sami', loginName: 'sami@github', role: 'admin' },
|
||||
{ id: 'u2', displayName: 'Friend', loginName: 'friend@gmail.com', role: 'member' },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
const u = await c.listUsers();
|
||||
expect(u).toHaveLength(2);
|
||||
expect(u[0].role).toBe('admin');
|
||||
await c.listUsers();
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailscale-coord: ACL', () => {
|
||||
const aclFixture = {
|
||||
acls: [{ action: 'accept', src: ['autogroup:member'], dst: ['*:*'] }],
|
||||
ssh: [{ action: 'accept', src: ['autogroup:member'], dst: ['autogroup:self'], users: ['root', 'autogroup:nonroot'] }],
|
||||
};
|
||||
|
||||
test('getAcl returns parsed body (not cached)', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/acl': { status: 200, body: aclFixture },
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
const a1 = await c.getAcl();
|
||||
const a2 = await c.getAcl();
|
||||
expect(a1.acls[0].action).toBe('accept');
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2); // explicitly not cached
|
||||
});
|
||||
|
||||
test('updateAcl sends the object as JSON body', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/-/acl': { status: 200, body: {} },
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
await c.updateAcl(aclFixture);
|
||||
const sent = JSON.parse(fetchImpl.calls[0].opts.body);
|
||||
expect(sent.acls[0].src).toContain('autogroup:member');
|
||||
});
|
||||
|
||||
test('updateAcl throws bad_input on non-object', async () => {
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl: makeFetch({}) });
|
||||
await expect(c.updateAcl(null)).rejects.toMatchObject({ code: 'bad_input' });
|
||||
await expect(c.updateAcl('a string')).rejects.toMatchObject({ code: 'bad_input' });
|
||||
await expect(c.updateAcl([])).rejects.toMatchObject({ code: 'bad_input' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailscale-coord: HTTP shape', () => {
|
||||
test('sends Authorization: Bearer <token> header', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [{ id: '1', name: 'foo.ts.net' }] } },
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
await c.ping();
|
||||
expect(fetchImpl.calls[0].opts.headers.Authorization).toBe('Bearer ' + VALID_TOKEN);
|
||||
});
|
||||
|
||||
test('sends Content-Type: application/json on POST with body', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'POST /api/v2/tailnet/-/keys': { status: 200, body: { id: 'k1' } },
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
await c.createAuthKey({ tags: ['tag:x'] });
|
||||
expect(fetchImpl.calls[0].opts.headers['Content-Type']).toBe('application/json');
|
||||
});
|
||||
|
||||
test('does not send Content-Type when no body', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [{ id: '1', name: 'foo.ts.net' }] } },
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
await c.ping();
|
||||
expect(fetchImpl.calls[0].opts.headers['Content-Type']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('parses string JSON body correctly', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/devices': {
|
||||
status: 200,
|
||||
body: JSON.stringify({ devices: [{ id: '1', name: 'foo.ts.net' }] }),
|
||||
},
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
const result = await c.ping();
|
||||
expect(result.domain).toBe('foo.ts.net');
|
||||
expect(result.deviceCount).toBe(1);
|
||||
});
|
||||
|
||||
test('non-JSON 200 body returned as string', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/acl': { status: 200, body: 'not-json', headers: { 'content-type': 'text/plain' } },
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
const result = await c.getAcl();
|
||||
expect(result).toBe('not-json');
|
||||
});
|
||||
|
||||
test('extracts retryAfter from response headers', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/devices': {
|
||||
status: 429,
|
||||
body: { message: 'slow down' },
|
||||
headers: { 'content-type': 'application/json', 'retry-after': '60' },
|
||||
},
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
try {
|
||||
await c.listDevices();
|
||||
throw new Error('expected throw');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(TailscaleCoordError);
|
||||
expect(e.retryAfter).toBe('60');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailscale-coord: cache lifecycle', () => {
|
||||
test('setApiToken clears all caches', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [{ id: '1', name: 'foo.ts.net' }] } },
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
await c.ping();
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
c.setApiToken('tskey-api-other');
|
||||
await c.ping();
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('expired cache entries re-fetch', async () => {
|
||||
const fetchImpl = makeFetch({
|
||||
'GET /api/v2/tailnet/-/devices': {
|
||||
status: 200,
|
||||
body: { devices: [{ id: '1', name: 'a.ts.net' }] },
|
||||
},
|
||||
});
|
||||
const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
|
||||
await c.ping();
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
// Manually expire the cache entry
|
||||
c._cache.set('ping', { expiresAt: Date.now() - 1000, value: { stale: true } });
|
||||
const fresh = await c.ping();
|
||||
expect(fresh.domain).toBe('a.ts.net');
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailscale-coord: error class', () => {
|
||||
test('TailscaleCoordError carries status, code, body, retryAfter', () => {
|
||||
const e = new TailscaleCoordError('test', { status: 429, body: { x: 1 }, retryAfter: '60', code: 'rate_limited' });
|
||||
expect(e.message).toBe('test');
|
||||
expect(e.status).toBe(429);
|
||||
expect(e.code).toBe('rate_limited');
|
||||
expect(e.body).toEqual({ x: 1 });
|
||||
expect(e.retryAfter).toBe('60');
|
||||
expect(e).toBeInstanceOf(Error);
|
||||
expect(e).toBeInstanceOf(TailscaleCoordError);
|
||||
});
|
||||
|
||||
test('default code derives from status', () => {
|
||||
expect(new TailscaleCoordError('x', { status: 401 }).code).toBe('unauthorized');
|
||||
expect(new TailscaleCoordError('x', { status: 403 }).code).toBe('unauthorized');
|
||||
expect(new TailscaleCoordError('x', { status: 404 }).code).toBe('not_found');
|
||||
expect(new TailscaleCoordError('x', { status: 429 }).code).toBe('rate_limited');
|
||||
expect(new TailscaleCoordError('x', { status: 500 }).code).toBe('server_error');
|
||||
expect(new TailscaleCoordError('x', { status: 502 }).code).toBe('server_error');
|
||||
expect(new TailscaleCoordError('x', {}).code).toBe('unknown');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user