611 lines
25 KiB
JavaScript
611 lines
25 KiB
JavaScript
/**
|
|
* Tests for src/network/providers/tailscale.js (DC-140).
|
|
*
|
|
* Hermetic: the coordination client is a jest.fn() stub object, the local
|
|
* manager is a jest.mock of ../managers/tailscale-manager (same rigor as
|
|
* __tests__/tailscale-manager.test.js), and the whois execFile call is a
|
|
* plain stub — no network, no child processes.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
jest.mock('../src/managers/tailscale-manager', () => ({
|
|
getSummary: jest.fn(),
|
|
getStatus: jest.fn(),
|
|
}));
|
|
|
|
const tailscaleManager = require('../src/managers/tailscale-manager');
|
|
const { createTailscaleProvider, toProviderError } = require('../src/network/providers/tailscale');
|
|
const { ProviderError } = require('../src/network/errors');
|
|
const { TailscaleCoordError } = require('../src/managers/tailscale-coord');
|
|
|
|
/** Coord client stub factory: every method is a jest.fn resolving []. */
|
|
function makeCoord(overrides = {}) {
|
|
return {
|
|
listDevices: jest.fn(async () => []),
|
|
listUsers: jest.fn(async () => []),
|
|
deleteDevice: jest.fn(async () => ({ success: true })),
|
|
request: jest.fn(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function devicesFixture() {
|
|
return [
|
|
{ id: 'dev1', name: 'laptop.tail3e209.ts.net', addresses: ['100.91.55.51'], user: '1001', os: 'linux' },
|
|
{ id: 'dev2', name: 'phone.tail3e209.ts.net', addresses: ['100.92.60.10'], user: '1001', os: 'android' },
|
|
{ id: 'dev3', name: 'nas.tail3e209.ts.net', addresses: ['100.93.7.7'], user: '2002', os: 'linux' },
|
|
];
|
|
}
|
|
|
|
/** execFile stub (promise-style, matching promisify(execFile)) with canned stdout. */
|
|
function makeExec(stdoutByIp = {}, { failAll = false } = {}) {
|
|
const calls = [];
|
|
const fn = (bin, args) => {
|
|
calls.push({ bin, args });
|
|
const ip = args && args[1];
|
|
if (failAll) return Promise.reject(new Error('tailscale CLI not available'));
|
|
return Promise.resolve({ stdout: stdoutByIp[ip] || '' });
|
|
};
|
|
fn.calls = calls;
|
|
return fn;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
tailscaleManager.getSummary.mockResolvedValue({ installed: true, connected: true, peerCount: 1 });
|
|
tailscaleManager.getStatus.mockResolvedValue(null);
|
|
});
|
|
|
|
describe('tailscale provider: id + capabilities', () => {
|
|
test('id() returns "tailscale"', () => {
|
|
expect(createTailscaleProvider().id()).toBe('tailscale');
|
|
});
|
|
|
|
test('capabilities() flags invites + splitDnsApi, no perPeerFirewall, not selfHosted', () => {
|
|
expect(createTailscaleProvider().capabilities()).toEqual({
|
|
invites: true,
|
|
splitDnsApi: true,
|
|
perPeerFirewall: false,
|
|
selfHosted: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('tailscale provider: getStatus()', () => {
|
|
test('wraps the local manager summary with provider framing', async () => {
|
|
tailscaleManager.getSummary.mockResolvedValue({ installed: true, connected: true, peerCount: 4 });
|
|
const p = createTailscaleProvider({ localManager: tailscaleManager });
|
|
const status = await p.getStatus();
|
|
expect(status).toMatchObject({ provider: 'tailscale', installed: true, connected: true });
|
|
expect(status.summary).toEqual({ installed: true, connected: true, peerCount: 4 });
|
|
});
|
|
|
|
test('degrades to installed:false when the CLI is missing', async () => {
|
|
tailscaleManager.getSummary.mockResolvedValue({ installed: false, connected: false });
|
|
const p = createTailscaleProvider({ localManager: tailscaleManager });
|
|
const status = await p.getStatus();
|
|
expect(status.installed).toBe(false);
|
|
expect(status.connected).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('tailscale provider: listDevices()', () => {
|
|
test('merges API devices with local online state by tailnet IP', async () => {
|
|
const coord = makeCoord({ listDevices: jest.fn(async () => devicesFixture()) });
|
|
tailscaleManager.getStatus.mockResolvedValue({
|
|
Peer: {
|
|
'nodekey:laptop': { TailscaleIPs: ['100.91.55.51'], Online: true },
|
|
},
|
|
});
|
|
const p = createTailscaleProvider({ coordClient: coord, localManager: tailscaleManager });
|
|
const devices = await p.listDevices();
|
|
expect(devices).toHaveLength(3);
|
|
expect(devices.find((d) => d.id === 'dev1')).toMatchObject({ ip: '100.91.55.51', online: true, userId: '1001' });
|
|
expect(devices.find((d) => d.id === 'dev3')).toMatchObject({ online: false });
|
|
});
|
|
|
|
test('works when local status is unavailable (all online false, no throw)', async () => {
|
|
const coord = makeCoord({ listDevices: jest.fn(async () => devicesFixture()) });
|
|
tailscaleManager.getStatus.mockResolvedValue(null);
|
|
const p = createTailscaleProvider({ coordClient: coord, localManager: tailscaleManager });
|
|
const devices = await p.listDevices();
|
|
expect(devices).toHaveLength(3);
|
|
expect(devices.every((d) => d.online === false)).toBe(true);
|
|
});
|
|
|
|
test('surfaces coord not-configured as ProviderError not_configured', async () => {
|
|
const p = createTailscaleProvider(); // no coord client attached
|
|
await expect(p.listDevices()).rejects.toMatchObject({
|
|
name: 'ProviderError',
|
|
code: 'not_configured',
|
|
retryable: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('tailscale provider: listPeople()', () => {
|
|
test('projects API users to { id, displayName, email, createdAt }', async () => {
|
|
const coord = makeCoord({
|
|
listUsers: jest.fn(async () => [
|
|
{ id: '1001', loginName: 'sami@example.com', displayName: 'Sami', created: '2026-01-01T00:00:00Z' },
|
|
{ id: '2002', loginName: 'guest-user', created: '2026-02-02T00:00:00Z' },
|
|
]),
|
|
});
|
|
const p = createTailscaleProvider({ coordClient: coord });
|
|
const people = await p.listPeople();
|
|
expect(people).toEqual([
|
|
{ id: '1001', displayName: 'Sami', email: 'sami@example.com', createdAt: '2026-01-01T00:00:00Z' },
|
|
{ id: '2002', displayName: 'guest-user', email: null, createdAt: '2026-02-02T00:00:00Z' },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('tailscale provider: createInvite()', () => {
|
|
test('with email: POSTs user-invites including the email field', async () => {
|
|
const coord = makeCoord({
|
|
request: jest.fn(async () => ({ id: 'inv1', inviteUrl: 'https://login.tailscale.com/a/abc123' })),
|
|
});
|
|
const p = createTailscaleProvider({ coordClient: coord });
|
|
const invite = await p.createInvite({ role: 'member', email: 'friend@example.com' });
|
|
expect(invite).toEqual({ inviteUrl: 'https://login.tailscale.com/a/abc123', id: 'inv1' });
|
|
expect(coord.request).toHaveBeenCalledWith('POST', '/tailnet/-/user-invites', {
|
|
body: { inviteTailnet: true, email: 'friend@example.com' },
|
|
});
|
|
});
|
|
|
|
test('without email: body omits the email field entirely (link-first)', async () => {
|
|
const coord = makeCoord({
|
|
request: jest.fn(async () => ({ id: 'inv2', inviteUrl: 'https://login.tailscale.com/a/nomail' })),
|
|
});
|
|
const p = createTailscaleProvider({ coordClient: coord });
|
|
const invite = await p.createInvite({ role: 'member' });
|
|
expect(invite.inviteUrl).toBe('https://login.tailscale.com/a/nomail');
|
|
expect(coord.request).toHaveBeenCalledWith('POST', '/tailnet/-/user-invites', {
|
|
body: { inviteTailnet: true },
|
|
});
|
|
});
|
|
|
|
test('missing inviteUrl in the response maps to a ProviderError', async () => {
|
|
const coord = makeCoord({ request: jest.fn(async () => ({ id: 'inv3' })) });
|
|
const p = createTailscaleProvider({ coordClient: coord });
|
|
await expect(p.createInvite({ role: 'member' })).rejects.toMatchObject({
|
|
name: 'ProviderError',
|
|
code: 'provider_error',
|
|
});
|
|
});
|
|
|
|
test('invalid email input is rejected as bad_input before any API call', async () => {
|
|
const coord = makeCoord();
|
|
const p = createTailscaleProvider({ coordClient: coord });
|
|
await expect(p.createInvite({ role: 'member', email: 'not-an-email' })).rejects.toMatchObject({
|
|
code: 'bad_input',
|
|
});
|
|
expect(coord.request).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('missing role is rejected as bad_input', async () => {
|
|
const p = createTailscaleProvider({ coordClient: makeCoord() });
|
|
await expect(p.createInvite({})).rejects.toMatchObject({ code: 'bad_input' });
|
|
});
|
|
});
|
|
|
|
describe('tailscale provider: revokeInvite()', () => {
|
|
test('DELETEs the user-invite by id', async () => {
|
|
const coord = makeCoord({ request: jest.fn(async () => ({})) });
|
|
const p = createTailscaleProvider({ coordClient: coord });
|
|
const res = await p.revokeInvite('inv1');
|
|
expect(res).toEqual({ revoked: true });
|
|
expect(coord.request).toHaveBeenCalledWith('DELETE', '/tailnet/-/user-invites/inv1');
|
|
});
|
|
|
|
test('requires an id', async () => {
|
|
const p = createTailscaleProvider({ coordClient: makeCoord() });
|
|
await expect(p.revokeInvite()).rejects.toMatchObject({ code: 'bad_input' });
|
|
});
|
|
});
|
|
|
|
describe('tailscale provider: blockPerson()', () => {
|
|
test('expires the key of EVERY device owned by the user', async () => {
|
|
const coord = makeCoord({ listDevices: jest.fn(async () => devicesFixture()) });
|
|
const p = createTailscaleProvider({ coordClient: coord });
|
|
const res = await p.blockPerson('1001');
|
|
expect(res).toEqual({ expired: ['dev1', 'dev2'], count: 2 });
|
|
const expireCalls = coord.request.mock.calls.filter((c) => c[1].endsWith('/expire'));
|
|
expect(expireCalls.map((c) => c[1])).toEqual(['/device/dev1/expire', '/device/dev2/expire']);
|
|
expireCalls.forEach((c) => expect(c[0]).toBe('POST'));
|
|
// no expiry for other users' devices
|
|
expect(coord.request.mock.calls.some((c) => c[1] === '/device/dev3/expire')).toBe(false);
|
|
});
|
|
|
|
test('user with zero devices → empty result, no expire calls', async () => {
|
|
const coord = makeCoord({ listDevices: jest.fn(async () => devicesFixture()) });
|
|
const p = createTailscaleProvider({ coordClient: coord });
|
|
const res = await p.blockPerson('9999');
|
|
expect(res).toEqual({ expired: [], count: 0 });
|
|
expect(coord.request).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('matches userId numerically-or-stringly (API returns numbers)', async () => {
|
|
const coord = makeCoord({
|
|
listDevices: jest.fn(async () => devicesFixture().map((d) => ({ ...d, user: Number(d.user) }))),
|
|
});
|
|
const p = createTailscaleProvider({ coordClient: coord });
|
|
const res = await p.blockPerson('1001');
|
|
expect(res.count).toBe(2);
|
|
});
|
|
|
|
test('requires a userId', async () => {
|
|
const p = createTailscaleProvider({ coordClient: makeCoord() });
|
|
await expect(p.blockPerson()).rejects.toMatchObject({ code: 'bad_input' });
|
|
});
|
|
});
|
|
|
|
describe('tailscale provider: unblockPerson()', () => {
|
|
test('is honest (corrected spec): re-allow at ACL level only, devices need re-auth', async () => {
|
|
const coord = makeCoord();
|
|
const p = createTailscaleProvider({ coordClient: coord });
|
|
await expect(p.unblockPerson('1001')).resolves.toEqual({ restored: true, reauthRequired: true });
|
|
// No device-key restore call exists — nothing may touch the coord API here.
|
|
expect(coord.request).not.toHaveBeenCalled();
|
|
expect(coord.listDevices).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('tailscale provider: removePerson()', () => {
|
|
test('DELETEs every device owned by the user', async () => {
|
|
const coord = makeCoord({ listDevices: jest.fn(async () => devicesFixture()) });
|
|
const p = createTailscaleProvider({ coordClient: coord });
|
|
const res = await p.removePerson('1001');
|
|
expect(res).toEqual({ deleted: ['dev1', 'dev2'], count: 2 });
|
|
expect(coord.deleteDevice).toHaveBeenCalledTimes(2);
|
|
expect(coord.deleteDevice).toHaveBeenCalledWith('dev1');
|
|
expect(coord.deleteDevice).toHaveBeenCalledWith('dev2');
|
|
expect(coord.deleteDevice).not.toHaveBeenCalledWith('dev3');
|
|
});
|
|
|
|
test('requires a userId', async () => {
|
|
const p = createTailscaleProvider({ coordClient: makeCoord() });
|
|
await expect(p.removePerson()).rejects.toMatchObject({ code: 'bad_input' });
|
|
});
|
|
});
|
|
|
|
describe('tailscale provider: revokeDevice()', () => {
|
|
test('expires the single device key via POST /device/{id}/expire', async () => {
|
|
const coord = makeCoord({ request: jest.fn(async () => ({})) });
|
|
const p = createTailscaleProvider({ coordClient: coord });
|
|
const res = await p.revokeDevice('dev2');
|
|
expect(res).toEqual({ expired: 'dev2' });
|
|
expect(coord.request).toHaveBeenCalledWith('POST', '/device/dev2/expire', { body: {} });
|
|
});
|
|
|
|
test('requires a deviceId', async () => {
|
|
const p = createTailscaleProvider({ coordClient: makeCoord() });
|
|
await expect(p.revokeDevice()).rejects.toMatchObject({ code: 'bad_input' });
|
|
});
|
|
});
|
|
|
|
describe('tailscale provider: syncAccess() (DC-153 contract tests)', () => {
|
|
const { LockoutError } = require('../src/network/acl-compiler');
|
|
const { AclPushError } = require('../src/network/acl-push');
|
|
|
|
/** aclSync engine mock factory (src/network/acl-sync.js surface). */
|
|
function makeAclSync(overrides = {}) {
|
|
return {
|
|
compile: jest.fn(async () => ({
|
|
policy: { acls: [] },
|
|
hash: 'sha256:abc',
|
|
usersAffected: 2,
|
|
})),
|
|
preview: jest.fn(async () => ({ feasible: true, errors: [] })),
|
|
push: jest.fn(async () => ({ pushed: true, hash: 'sha256:abc' })),
|
|
getState: jest.fn(() => null),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const input = () => ({
|
|
accessMap: { grants: [{ principal: { type: 'person', id: 'p1' }, resource: 'dashcaddy', allowed: true }] },
|
|
people: [
|
|
{ id: 'p1', providerUserId: '1001', groups: ['Admin'], status: 'active' },
|
|
{ id: 'p2', providerUserId: '2002', groups: [], status: 'active' },
|
|
],
|
|
resources: [{ name: 'dashcaddy', host: 'dns2.tail.ts.net', ports: ['*'] }],
|
|
hostDeviceId: 'dev-host',
|
|
});
|
|
|
|
test('happy path: compiles → previews → pushes, returns {pushed, hash, usersAffected}', async () => {
|
|
const aclSync = makeAclSync();
|
|
const p = createTailscaleProvider({ coordClient: makeCoord(), aclSync });
|
|
const res = await p.syncAccess(input());
|
|
expect(res).toEqual({ pushed: true, hash: 'sha256:abc', usersAffected: 2 });
|
|
// Compile receives the caller shapes + the derived guard inputs.
|
|
expect(aclSync.compile).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
accessMap: input().accessMap,
|
|
people: expect.any(Array),
|
|
resources: expect.any(Array),
|
|
hostDeviceId: 'dev-host',
|
|
adminUserIds: ['1001'], // derived from the active Admin-group person
|
|
hostResource: { host: 'dns2.tail.ts.net', ports: ['*'] },
|
|
})
|
|
);
|
|
// Preview runs on the compiled policy, BEFORE the push.
|
|
expect(aclSync.preview).toHaveBeenCalledTimes(1);
|
|
expect(aclSync.preview).toHaveBeenCalledWith({ acls: [] });
|
|
expect(aclSync.push).toHaveBeenCalledWith({ acls: [] });
|
|
});
|
|
|
|
test('LockoutError from compile → ProviderError lockout_guard, nothing previewed or pushed', async () => {
|
|
const aclSync = makeAclSync({
|
|
compile: jest.fn(() => {
|
|
throw new LockoutError('every active admin (1001) would be denied the host resource');
|
|
}),
|
|
});
|
|
const p = createTailscaleProvider({ coordClient: makeCoord(), aclSync });
|
|
await expect(p.syncAccess(input())).rejects.toMatchObject({
|
|
name: 'ProviderError',
|
|
code: 'lockout_guard',
|
|
retryable: false,
|
|
});
|
|
expect(aclSync.preview).not.toHaveBeenCalled();
|
|
expect(aclSync.push).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('preview errors[] → ProviderError acl_preview_failed, NOTHING pushed', async () => {
|
|
const aclSync = makeAclSync({
|
|
preview: jest.fn(() => Promise.reject(new AclPushError('ACL preview reported errors', {
|
|
code: 'acl_rejected',
|
|
cause: ['bad dst'],
|
|
}))),
|
|
});
|
|
const p = createTailscaleProvider({ coordClient: makeCoord(), aclSync });
|
|
await expect(p.syncAccess(input())).rejects.toMatchObject({
|
|
name: 'ProviderError',
|
|
code: 'acl_preview_failed',
|
|
retryable: false,
|
|
});
|
|
expect(aclSync.push).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('push API failure (acl_rejected) → ProviderError bad_input, mapped not leaked', async () => {
|
|
const aclSync = makeAclSync({
|
|
push: jest.fn(() => Promise.reject(new AclPushError('POST acl returned 400', {
|
|
code: 'acl_rejected',
|
|
}))),
|
|
});
|
|
const p = createTailscaleProvider({ coordClient: makeCoord(), aclSync });
|
|
await expect(p.syncAccess(input())).rejects.toMatchObject({
|
|
name: 'ProviderError',
|
|
code: 'bad_input',
|
|
});
|
|
});
|
|
|
|
test('explicit adminUserIds/hostResource pass through verbatim', async () => {
|
|
const aclSync = makeAclSync();
|
|
const p = createTailscaleProvider({ coordClient: makeCoord(), aclSync });
|
|
await p.syncAccess({
|
|
...input(),
|
|
adminUserIds: ['9999'],
|
|
hostResource: { host: 'other-host', ports: ['22'] },
|
|
});
|
|
expect(aclSync.compile).toHaveBeenCalledWith(
|
|
expect.objectContaining({ adminUserIds: ['9999'], hostResource: { host: 'other-host', ports: ['22'] } })
|
|
);
|
|
});
|
|
|
|
describe('default construction (dep OMITTED — real acl-compiler + acl-push wiring)', () => {
|
|
const fsReal = require('fs');
|
|
const os = require('os');
|
|
const pathMod = require('path');
|
|
let stateDir;
|
|
|
|
beforeAll(() => {
|
|
stateDir = fsReal.mkdtempSync(pathMod.join(os.tmpdir(), 'dc-ts-syncaccess-'));
|
|
global.__DC_ACL_STATE_DIR__ = stateDir; // acl-push state file location
|
|
});
|
|
afterAll(() => {
|
|
delete global.__DC_ACL_STATE_DIR__;
|
|
fsReal.rmSync(stateDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('coord mock: ACL preview + push POST bodies contain per-user srcs', async () => {
|
|
const request = jest.fn(async (method, apiPath) => {
|
|
if (apiPath === '/tailnet/-/acl/preview') return { feasible: true, errors: [] };
|
|
if (apiPath === '/tailnet/-/acl') return {};
|
|
throw new Error('unexpected ' + method + ' ' + apiPath);
|
|
});
|
|
const coord = makeCoord({ request });
|
|
const p = createTailscaleProvider({ coordClient: coord }); // aclSync OMITTED
|
|
const res = await p.syncAccess(input());
|
|
|
|
expect(res.pushed).toBe(true);
|
|
expect(res.hash).toMatch(/^sha256:[0-9a-f]{64}$/);
|
|
expect(res.usersAffected).toBe(1); // user 1001 only (host device excluded)
|
|
|
|
const previewCall = request.mock.calls.find((c) => c[1] === '/tailnet/-/acl/preview');
|
|
const pushCall = request.mock.calls.find((c) => c[1] === '/tailnet/-/acl');
|
|
expect(previewCall).toBeTruthy();
|
|
expect(pushCall).toBeTruthy();
|
|
// preview BEFORE push, both carrying the compiled policy
|
|
expect(request.mock.calls.indexOf(previewCall)).toBeLessThan(request.mock.calls.indexOf(pushCall));
|
|
for (const call of [previewCall, pushCall]) {
|
|
const acls = call[2].body.acls;
|
|
// per-user src (never a group), dst host:port per resource grant
|
|
expect(acls).toContainEqual({
|
|
action: 'accept',
|
|
src: ['1001'],
|
|
dst: ['dns2.tail.ts.net:*'],
|
|
proto: ['tcp', 'udp'],
|
|
});
|
|
// host device egress rule
|
|
expect(acls).toContainEqual({
|
|
action: 'accept',
|
|
src: ['dev-host'],
|
|
dst: ['*:*'],
|
|
proto: ['tcp', 'udp'],
|
|
});
|
|
}
|
|
});
|
|
|
|
test('no coord client attached → not_configured (never builds a dead engine)', async () => {
|
|
const p = createTailscaleProvider(); // no coordClient, no aclSync
|
|
await expect(p.syncAccess(input())).rejects.toMatchObject({
|
|
name: 'ProviderError',
|
|
code: 'not_configured',
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('tailscale provider: whois()', () => {
|
|
test('resolves identity via the local tailscale CLI', async () => {
|
|
const exec = makeExec({
|
|
'100.91.55.51': 'user: sami@example.com\nnodeKey: nodekey:abc123\nnode: laptop.tail3e209.ts.net\n',
|
|
});
|
|
const p = createTailscaleProvider({ coordClient: makeCoord(), execFileImpl: exec });
|
|
const who = await p.whois('100.91.55.51');
|
|
expect(exec.calls).toEqual([{ bin: 'tailscale', args: ['whois', '100.91.55.51'] }]);
|
|
expect(who).toEqual({
|
|
nodeKey: 'nodekey:abc123',
|
|
userId: null,
|
|
loginName: 'sami@example.com',
|
|
hostname: 'laptop.tail3e209.ts.net',
|
|
});
|
|
});
|
|
|
|
test('captures userid when present', async () => {
|
|
const exec = makeExec({ '1.2.3.4': 'userid: 1001\nuser: sami@example.com\n' });
|
|
const p = createTailscaleProvider({ execFileImpl: exec });
|
|
await expect(p.whois('1.2.3.4')).resolves.toMatchObject({ userId: 1001 });
|
|
});
|
|
|
|
test('CLI failure resolves to null (unknown identity is not an error)', async () => {
|
|
const exec = makeExec({}, { failAll: true });
|
|
const p = createTailscaleProvider({ execFileImpl: exec });
|
|
await expect(p.whois('100.0.0.1')).resolves.toBeNull();
|
|
});
|
|
|
|
test('empty CLI output resolves to null', async () => {
|
|
const exec = makeExec({});
|
|
const p = createTailscaleProvider({ execFileImpl: exec });
|
|
await expect(p.whois('100.0.0.2')).resolves.toBeNull();
|
|
});
|
|
|
|
test('missing ip is bad_input', async () => {
|
|
const p = createTailscaleProvider();
|
|
await expect(p.whois()).rejects.toMatchObject({ code: 'bad_input' });
|
|
});
|
|
|
|
test('never touches the coordination client', async () => {
|
|
const coord = makeCoord();
|
|
const exec = makeExec({ '9.9.9.9': 'user: x@example.com\n' });
|
|
const p = createTailscaleProvider({ coordClient: coord, execFileImpl: exec });
|
|
await p.whois('9.9.9.9');
|
|
expect(coord.request).not.toHaveBeenCalled();
|
|
expect(coord.listDevices).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('tailscale provider: configureDns()', () => {
|
|
test('POSTs split-DNS nameservers for the zone (capability-flagged)', async () => {
|
|
const coord = makeCoord({ request: jest.fn(async () => ({})) });
|
|
const p = createTailscaleProvider({ coordClient: coord });
|
|
const res = await p.configureDns({ zone: 'internal', server: '100.100.100.10' });
|
|
expect(res).toEqual({ configured: true, zone: 'internal', server: '100.100.100.10' });
|
|
expect(coord.request).toHaveBeenCalledWith('POST', '/tailnet/-/dns/nameservers', {
|
|
body: { dns: ['100.100.100.10'], domains: ['internal'] },
|
|
});
|
|
});
|
|
|
|
test('requires both zone and server', async () => {
|
|
const p = createTailscaleProvider({ coordClient: makeCoord() });
|
|
await expect(p.configureDns({ zone: 'internal' })).rejects.toMatchObject({ code: 'bad_input' });
|
|
await expect(p.configureDns({ server: '1.1.1.1' })).rejects.toMatchObject({ code: 'bad_input' });
|
|
});
|
|
});
|
|
|
|
describe('tailscale provider: error mapping', () => {
|
|
test('listPeople surfaces a coord error as ProviderError', async () => {
|
|
const coord = makeCoord({
|
|
listUsers: jest.fn(async () => {
|
|
throw new TailscaleCoordError('Tailscale API 404: no users', { status: 404 });
|
|
}),
|
|
});
|
|
const p = createTailscaleProvider({ coordClient: coord });
|
|
await expect(p.listPeople()).rejects.toBeInstanceOf(ProviderError);
|
|
await expect(p.listPeople()).rejects.toMatchObject({ code: 'not_found' });
|
|
});
|
|
|
|
test('unauthorized coord error → ProviderError code unauthorized, not retryable', async () => {
|
|
const coord = makeCoord({
|
|
listUsers: jest.fn(async () => {
|
|
throw new TailscaleCoordError('Tailscale API 401: bad token', { status: 401 });
|
|
}),
|
|
});
|
|
const p = createTailscaleProvider({ coordClient: coord });
|
|
await expect(p.listPeople()).rejects.toMatchObject({
|
|
name: 'ProviderError',
|
|
code: 'unauthorized',
|
|
retryable: false,
|
|
});
|
|
});
|
|
|
|
test('rate_limited coord error → ProviderError retryable, retryAfter preserved', async () => {
|
|
const coord = makeCoord({
|
|
listUsers: jest.fn(async () => {
|
|
throw new TailscaleCoordError('Tailscale API 429: slow down', {
|
|
status: 429,
|
|
retryAfter: '30',
|
|
});
|
|
}),
|
|
});
|
|
const p = createTailscaleProvider({ coordClient: coord });
|
|
await expect(p.listPeople()).rejects.toMatchObject({
|
|
code: 'rate_limited',
|
|
retryable: true,
|
|
retryAfter: '30',
|
|
});
|
|
});
|
|
|
|
test('server_error coord error → ProviderError retryable', async () => {
|
|
const coord = makeCoord({
|
|
listUsers: jest.fn(async () => {
|
|
throw new TailscaleCoordError('Tailscale API 502: bad gateway', { status: 502 });
|
|
}),
|
|
});
|
|
const p = createTailscaleProvider({ coordClient: coord });
|
|
await expect(p.listPeople()).rejects.toMatchObject({ code: 'server_error', retryable: true });
|
|
});
|
|
|
|
test('generic thrown errors become retryable provider_error', () => {
|
|
const mapped = toProviderError(new Error('boom'));
|
|
expect(mapped).toBeInstanceOf(ProviderError);
|
|
expect(mapped.code).toBe('provider_error');
|
|
expect(mapped.retryable).toBe(true);
|
|
});
|
|
|
|
test('ProviderError passes through toProviderError unchanged', () => {
|
|
const original = new ProviderError('keep me', { code: 'bad_input' });
|
|
expect(toProviderError(original)).toBe(original);
|
|
});
|
|
|
|
test('coord errors thrown mid-block propagate as ProviderError', async () => {
|
|
const coord = makeCoord({ listDevices: jest.fn(async () => devicesFixture()) });
|
|
coord.request = jest.fn(async () => {
|
|
throw new TailscaleCoordError('Tailscale API 403: forbidden', { status: 403 });
|
|
});
|
|
const p = createTailscaleProvider({ coordClient: coord });
|
|
await expect(p.blockPerson('1001')).rejects.toMatchObject({ code: 'unauthorized' });
|
|
});
|
|
});
|
|
|
|
describe('tailscale provider: attachCoordClient()', () => {
|
|
test('late-binding a client flips not_configured → working', async () => {
|
|
const p = createTailscaleProvider();
|
|
await expect(p.listPeople()).rejects.toMatchObject({ code: 'not_configured' });
|
|
p.attachCoordClient(makeCoord({ listUsers: jest.fn(async () => []) }));
|
|
await expect(p.listPeople()).resolves.toEqual([]);
|
|
});
|
|
});
|