Files
Krystie 6fb4f9b169
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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.
2026-07-06 21:28:03 -07:00

605 lines
23 KiB
JavaScript

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