490 lines
20 KiB
JavaScript
490 lines
20 KiB
JavaScript
/**
|
|
* Tests for src/network/technitium.js (DC-146).
|
|
*
|
|
* Hermetic: fetchImpl is a jest.fn() stub, credentialManager and the
|
|
* network registry are stub objects (same rigor as
|
|
* __tests__/network-provider-tailscale.test.js). No real Technitium,
|
|
* no real sockets.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const {
|
|
createTechnitiumClient,
|
|
createTechnitiumService,
|
|
} = require('../src/network/technitium');
|
|
const { ProviderError } = require('../src/network/errors');
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* HTTP stub helpers
|
|
* ------------------------------------------------------------------ */
|
|
|
|
const _JSON_HEADERS = { 'Content-Type': 'application/json' };
|
|
|
|
function jsonResponse(status, body) {
|
|
return {
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
statusText: 'stub',
|
|
json: async () => Promise.resolve(body),
|
|
};
|
|
}
|
|
|
|
function textResponse(status, text) {
|
|
return {
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
statusText: text,
|
|
json: async () => Promise.reject(new Error('not json')),
|
|
};
|
|
}
|
|
|
|
/** fetch stub that routes by URL path; handlers appended as [matcher, fn]. */
|
|
function makeFetch(routes = [], { defaultResponse } = {}) {
|
|
const calls = [];
|
|
const fn = async (url, init) => {
|
|
calls.push({ url: String(url), init });
|
|
const u = new URL(String(url));
|
|
for (const [match, handler] of routes) {
|
|
if (u.pathname.endsWith(match)) return handler(u, init, calls.length - 1);
|
|
}
|
|
if (defaultResponse) return defaultResponse(u, init);
|
|
return jsonResponse(404, { status: 'error', errorMessage: 'no route' });
|
|
};
|
|
fn.mock = {}; // jest.fn()-shape compatibility if inspected
|
|
fn.calls = calls;
|
|
return fn;
|
|
}
|
|
|
|
function credMgr(token) {
|
|
const retrieve = async () => (token === undefined ? null : token);
|
|
return { retrieve: jest.fn(retrieve) };
|
|
}
|
|
|
|
const noopLogger = { info() {}, warn: jest.fn(), error: jest.fn(), debug() {} };
|
|
|
|
function makeClient(routes, { token = 'tok123', host = 'dns.local', ...rest } = {}) {
|
|
const fetchImpl = typeof routes === 'function' ? routes : makeFetch(routes);
|
|
const getToken = async () => token;
|
|
const client = createTechnitiumClient({
|
|
host,
|
|
getToken,
|
|
fetchImpl,
|
|
logger: noopLogger,
|
|
...rest,
|
|
});
|
|
return { client, fetchImpl };
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* Client: zones
|
|
* ------------------------------------------------------------------ */
|
|
|
|
describe('technitium client: listZones + ensureZone idempotency', () => {
|
|
test('listZones normalizes the response array', async () => {
|
|
const { client } = makeClient([
|
|
['/zones/list', () => jsonResponse(200, { status: 'ok', response: [{ name: 'corp', type: 'Primary' }] })],
|
|
]);
|
|
const zones = await client.listZones();
|
|
expect(zones).toEqual([{ name: 'corp', type: 'Primary' }]);
|
|
});
|
|
|
|
test('ensureZone creates a Primary zone only when absent', async () => {
|
|
const zones = [];
|
|
const fetchImpl = makeFetch([
|
|
['/zones/list', () => jsonResponse(200, { status: 'ok', response: zones })],
|
|
['/zones/create', () => { zones.push({ name: 'corp', type: 'Primary' }); return jsonResponse(200, { status: 'ok' }); }],
|
|
]);
|
|
const client = createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl });
|
|
|
|
const first = await client.ensureZone('corp');
|
|
expect(first).toEqual({ created: true, unchanged: false });
|
|
expect(fetchImpl.calls.filter((c) => c.url.includes('/zones/create'))).toHaveLength(1);
|
|
|
|
const second = await client.ensureZone('corp'); // now present
|
|
expect(second).toEqual({ created: false, unchanged: true });
|
|
expect(fetchImpl.calls.filter((c) => c.url.includes('/zones/create'))).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* Client: upsertRecord three paths
|
|
* ------------------------------------------------------------------ */
|
|
|
|
describe('technitium client: upsertRecord', () => {
|
|
function recordApi(existing, handlers = {}) {
|
|
let rec = existing;
|
|
const routes = [
|
|
['/zones/records/get', (u) => {
|
|
if (handlers.get) return handlers.get(u);
|
|
if (!rec) return jsonResponse(200, { status: 'error', errorMessage: 'Record not found.' });
|
|
return jsonResponse(200, { status: 'ok', response: rec });
|
|
}],
|
|
['/zones/records/delete', (u, init) => {
|
|
if (handlers.del) return handlers.del(u, init);
|
|
rec = null;
|
|
return jsonResponse(200, { status: 'ok' });
|
|
}],
|
|
['/zones/records/add', (u, init) => {
|
|
if (handlers.add) return handlers.add(u, init);
|
|
const body = JSON.parse(init.body);
|
|
rec = { name: body.name, type: body.type, rData: { ipAddress: body.ipAddress } };
|
|
return jsonResponse(200, { status: 'ok' });
|
|
}],
|
|
];
|
|
return makeFetch(routes);
|
|
}
|
|
|
|
test('absent record → created (single add, no delete)', async () => {
|
|
const fetchImpl = recordApi(null);
|
|
const client = createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl });
|
|
const out = await client.upsertRecord({ zone: 'corp', name: 'app', type: 'A', rData: { ipAddress: '10.53.0.5' } });
|
|
expect(out).toEqual({ created: true });
|
|
expect(fetchImpl.calls.filter((c) => c.url.includes('/add'))).toHaveLength(1);
|
|
expect(fetchImpl.calls.filter((c) => c.url.includes('/delete'))).toHaveLength(0);
|
|
});
|
|
|
|
test('present record with same rData → unchanged no-op (no add, no delete)', async () => {
|
|
const fetchImpl = recordApi({ name: 'app', type: 'A', rData: { ipAddress: '10.53.0.5' } });
|
|
const client = createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl });
|
|
const out = await client.upsertRecord({ zone: 'corp', name: 'app', type: 'A', rData: { ipAddress: '10.53.0.5' } });
|
|
expect(out).toEqual({ unchanged: true });
|
|
expect(fetchImpl.calls).toHaveLength(1); // only the get
|
|
});
|
|
|
|
test('present record with different rData → delete + add → updated', async () => {
|
|
const fetchImpl = recordApi({ name: 'app', type: 'A', rData: { ipAddress: '10.53.0.9' } });
|
|
const client = createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl });
|
|
const out = await client.upsertRecord({ zone: 'corp', name: 'app', type: 'A', rData: { ipAddress: '10.53.0.5' } });
|
|
expect(out).toEqual({ updated: true });
|
|
const add = fetchImpl.calls.find((c) => c.url.includes('/add'));
|
|
expect(JSON.parse(add.init.body)).toMatchObject({ zone: 'corp', name: 'app', type: 'A', ipAddress: '10.53.0.5' });
|
|
expect(fetchImpl.calls.some((c) => c.url.includes('/delete'))).toBe(true);
|
|
});
|
|
|
|
test('auth token rides on every request (query param on GET, body on POST)', async () => {
|
|
const fetchImpl = recordApi(null);
|
|
const client = createTechnitiumClient({ host: 'h', getToken: async () => 'sekrit', fetchImpl });
|
|
await client.upsertRecord({ zone: 'corp', name: 'app', type: 'A', rData: { ipAddress: '10.53.0.5' } });
|
|
const get = fetchImpl.calls[0];
|
|
expect(new URL(get.url).searchParams.get('token')).toBe('sekrit');
|
|
const add = fetchImpl.calls.find((c) => c.url.includes('/add'));
|
|
expect(JSON.parse(add.init.body).token).toBe('sekrit');
|
|
});
|
|
});
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* Client: error mapping + timeout
|
|
* ------------------------------------------------------------------ */
|
|
|
|
describe('technitium client: error mapping', () => {
|
|
test('HTTP 401/403 → ProviderError auth_failed', async () => {
|
|
const { client } = makeClient([
|
|
['/zones/list', () => jsonResponse(401, { status: 'error', errorMessage: 'Unauthorized' })],
|
|
]);
|
|
await expect(client.listZones()).rejects.toMatchObject({ code: 'auth_failed', name: 'ProviderError' });
|
|
});
|
|
|
|
test('envelope status:"error" with "not found" → not_found (getRecord → null)', async () => {
|
|
const { client } = makeClient([
|
|
['/zones/records/get', () => jsonResponse(200, { status: 'error', errorMessage: 'Record not found.' })],
|
|
]);
|
|
await expect(client.getRecord('corp', 'app', 'A')).resolves.toBeNull();
|
|
});
|
|
|
|
test('envelope token error → auth_failed', async () => {
|
|
const { client } = makeClient([
|
|
['/zones/list', () => jsonResponse(200, { status: 'error', errorMessage: 'Invalid token' })],
|
|
]);
|
|
await expect(client.listZones()).rejects.toMatchObject({ code: 'auth_failed' });
|
|
});
|
|
|
|
test('HTTP 500 → api_error; transport throw → network_error', async () => {
|
|
const { client: c1 } = makeClient([
|
|
['/zones/list', () => textResponse(500, 'boom')],
|
|
]);
|
|
await expect(c1.listZones()).rejects.toMatchObject({ code: 'api_error' });
|
|
|
|
const fetchThrows = jest.fn(async () => { const e = new Error('ECONNREFUSED'); e.name = 'FetchError'; throw e; });
|
|
const c2 = createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl: fetchThrows });
|
|
await expect(c2.listZones()).rejects.toMatchObject({ code: 'network_error' });
|
|
});
|
|
|
|
test('timeout: fetch that never settles → network_error within the budget', async () => {
|
|
const fetchImpl = jest.fn(() => new Promise(() => {})); // hangs forever
|
|
const client = createTechnitiumClient({
|
|
host: 'h',
|
|
getToken: async () => 't',
|
|
fetchImpl,
|
|
timeoutMs: 50,
|
|
});
|
|
const t0 = Date.now();
|
|
await expect(client.listZones()).rejects.toMatchObject({ code: 'network_error' });
|
|
expect(Date.now() - t0).toBeLessThan(2000);
|
|
});
|
|
});
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* Service: syncRecords
|
|
* ------------------------------------------------------------------ */
|
|
|
|
describe('technitium service: syncRecords', () => {
|
|
function serviceWith(existingByFqdn) {
|
|
const state = { ...existingByFqdn };
|
|
const fetchImpl = makeFetch([
|
|
['/zones/records/get', (u) => {
|
|
const name = u.searchParams.get('name');
|
|
const rec = state[name];
|
|
if (!rec) return jsonResponse(200, { status: 'error', errorMessage: 'Record not found.' });
|
|
return jsonResponse(200, { status: 'ok', response: rec });
|
|
}],
|
|
['/zones/records/delete', (u, init) => {
|
|
const body = JSON.parse(init.body);
|
|
delete state[body.name];
|
|
return jsonResponse(200, { status: 'ok' });
|
|
}],
|
|
['/zones/records/add', (u, init) => {
|
|
const body = JSON.parse(init.body);
|
|
state[body.name] = { name: body.name, type: body.type, rData: { ipAddress: body.ipAddress } };
|
|
return jsonResponse(200, { status: 'ok' });
|
|
}],
|
|
]);
|
|
const svc = createTechnitiumService({
|
|
client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl }),
|
|
logger: noopLogger,
|
|
});
|
|
return { svc, state };
|
|
}
|
|
|
|
test('resources + devices → name.zone records; three upsert paths in one batch', async () => {
|
|
const { svc } = serviceWith({ 'app': { name: 'app', type: 'A', rData: { ipAddress: '10.53.0.5' } } });
|
|
const out = await svc.syncRecords({
|
|
zone: 'corp',
|
|
providerMode: 'wireguard',
|
|
resources: [
|
|
{ name: 'app', ip: '10.53.0.5' }, // unchanged
|
|
{ name: 'git', ip: '10.53.1.7' }, // created
|
|
],
|
|
devices: [
|
|
{ slug: 'laptop', ip: '10.53.2.9' }, // created
|
|
],
|
|
});
|
|
expect(out.zone).toBe('corp');
|
|
expect(out.count).toBe(3);
|
|
expect(Object.fromEntries(out.results.map((r) => [r.name, r.action]))).toEqual({
|
|
'app.corp': 'unchanged',
|
|
'git.corp': 'created',
|
|
'laptop.corp': 'created',
|
|
});
|
|
});
|
|
|
|
test('tailscale mode prefers tailnetIp; wireguard mode prefers ip', async () => {
|
|
const seen = {};
|
|
const fetchImpl = makeFetch([
|
|
['/zones/records/get', () => jsonResponse(200, { status: 'error', errorMessage: 'Record not found.' })],
|
|
['/zones/records/add', (u, init) => {
|
|
const b = JSON.parse(init.body);
|
|
seen[b.name] = b.ipAddress;
|
|
return jsonResponse(200, { status: 'ok' });
|
|
}],
|
|
]);
|
|
const svc = createTechnitiumService({
|
|
client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl }),
|
|
logger: noopLogger,
|
|
});
|
|
const entry = { name: 'nas', tailnetIp: '100.91.55.51', ip: '10.53.0.9' };
|
|
await svc.syncRecords({ zone: 'corp', providerMode: 'tailscale', resources: [entry] });
|
|
expect(seen.nas).toBe('100.91.55.51');
|
|
await svc.syncRecords({ zone: 'corp', providerMode: 'wireguard', resources: [entry] });
|
|
expect(seen.nas).toBe('10.53.0.9');
|
|
});
|
|
|
|
test('per-record failure isolation: one bad record does not block the rest', async () => {
|
|
let n = 0;
|
|
const fetchImpl = makeFetch([
|
|
['/zones/records/get', () => {
|
|
n += 1;
|
|
if (n === 1) return Promise.reject(new Error('ECONNRESET')); // first record fails at transport
|
|
return jsonResponse(200, { status: 'error', errorMessage: 'Record not found.' });
|
|
}],
|
|
['/zones/records/add', () => jsonResponse(200, { status: 'ok' })],
|
|
]);
|
|
const logger = { ...noopLogger, warn: jest.fn() };
|
|
const svc = createTechnitiumService({
|
|
client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl }),
|
|
logger,
|
|
});
|
|
const out = await svc.syncRecords({
|
|
zone: 'corp',
|
|
resources: [{ name: 'bad', ip: '10.53.0.1' }, { name: 'ok', ip: '10.53.0.2' }],
|
|
});
|
|
expect(out.count).toBe(2);
|
|
expect(out.results[0]).toMatchObject({ name: 'bad.corp', action: 'failed' });
|
|
expect(typeof out.results[0].error).toBe('string');
|
|
expect(out.results[0].error).toContain('network_error');
|
|
expect(out.results[1]).toEqual({ name: 'ok.corp', action: 'created' });
|
|
expect(logger.warn).toHaveBeenCalled();
|
|
});
|
|
|
|
test('missing zone → bad_input ProviderError', async () => {
|
|
const svc = createTechnitiumService({
|
|
client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl: makeFetch([]) }),
|
|
logger: noopLogger,
|
|
});
|
|
await expect(svc.syncRecords({ resources: [] })).rejects.toMatchObject({ code: 'bad_input' });
|
|
});
|
|
});
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* Service: verifyResolution
|
|
* ------------------------------------------------------------------ */
|
|
|
|
describe('technitium service: verifyResolution', () => {
|
|
test('falls back to API read-back when no dnsServer (method noted)', async () => {
|
|
const fetchImpl = makeFetch([
|
|
['/zones/records/get', () => jsonResponse(200, { status: 'ok', response: { name: 'app', type: 'A', rData: { ipAddress: '10.53.0.5' } } })],
|
|
]);
|
|
const svc = createTechnitiumService({
|
|
client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl }),
|
|
logger: noopLogger,
|
|
});
|
|
const out = await svc.verifyResolution({ name: 'app.corp', type: 'A', expectedIp: '10.53.0.5' });
|
|
expect(out.method).toBe('api-readback');
|
|
expect(out.verified).toBe(true);
|
|
});
|
|
|
|
test('read-back mismatch → verified:false', async () => {
|
|
const fetchImpl = makeFetch([
|
|
['/zones/records/get', () => jsonResponse(200, { status: 'ok', response: { rData: { ipAddress: '10.53.0.99' } } })],
|
|
]);
|
|
const svc = createTechnitiumService({
|
|
client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl }),
|
|
logger: noopLogger,
|
|
});
|
|
const out = await svc.verifyResolution({ name: 'app.corp', expectedIp: '10.53.0.5' });
|
|
expect(out.verified).toBe(false);
|
|
});
|
|
});
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* Service: configureSplitDns capability gating
|
|
* ------------------------------------------------------------------ */
|
|
|
|
describe('technitium service: configureSplitDns capability gate', () => {
|
|
function providerStub({ splitDnsApi, id = 'stub' }) {
|
|
return {
|
|
id: () => id,
|
|
capabilities: () => ({ invites: true, splitDnsApi, perPeerFirewall: false, selfHosted: true }),
|
|
configureDns: jest.fn(async ({ zone, server }) => ({ delegated: true, zone, server })),
|
|
};
|
|
}
|
|
|
|
test('tailscale (splitDnsApi:true) → delegates to provider.configureDns', async () => {
|
|
const ts = providerStub({ splitDnsApi: true, id: 'tailscale' });
|
|
const svc = createTechnitiumService({
|
|
client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl: makeFetch([]) }),
|
|
getActiveProvider: () => ts,
|
|
logger: noopLogger,
|
|
});
|
|
const out = await svc.configureSplitDns({ zone: 'corp', dnsServerIp: '10.53.0.1' });
|
|
expect(out).toMatchObject({ configured: true, provider: 'tailscale' });
|
|
expect(ts.configureDns).toHaveBeenCalledWith({ zone: 'corp', server: '10.53.0.1' });
|
|
});
|
|
|
|
test('wireguard (splitDnsApi:false) → {configured:false, reason} per spec', async () => {
|
|
const wg = providerStub({ splitDnsApi: false, id: 'wireguard' });
|
|
const svc = createTechnitiumService({
|
|
client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl: makeFetch([]) }),
|
|
getActiveProvider: () => wg,
|
|
logger: noopLogger,
|
|
});
|
|
const out = await svc.configureSplitDns({ zone: 'corp', dnsServerIp: '10.53.0.1' });
|
|
expect(out).toEqual({
|
|
configured: false,
|
|
reason: 'splitDnsApi unsupported — set DNS in client config',
|
|
});
|
|
});
|
|
|
|
test('no active provider → same unsupported result, no throw', async () => {
|
|
const svc = createTechnitiumService({
|
|
client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl: makeFetch([]) }),
|
|
getActiveProvider: () => { throw new ProviderError('none', { code: 'provider_not_active' }); },
|
|
logger: noopLogger,
|
|
});
|
|
await expect(svc.configureSplitDns({ zone: 'corp', dnsServerIp: '10.53.0.1' })).resolves.toMatchObject({
|
|
configured: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* Service: checkSetup (wizard, DC-152)
|
|
* ------------------------------------------------------------------ */
|
|
|
|
describe('technitium service: checkSetup red/green', () => {
|
|
test('green: reachable + valid token + zone present', async () => {
|
|
const fetchImpl = makeFetch([
|
|
['/zones/list', () => jsonResponse(200, { status: 'ok', response: [{ name: 'corp', type: 'Primary' }] })],
|
|
]);
|
|
const svc = createTechnitiumService({
|
|
client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl }),
|
|
logger: noopLogger,
|
|
});
|
|
await expect(svc.checkSetup({ zone: 'corp' })).resolves.toEqual({
|
|
apiReachable: true,
|
|
tokenValid: true,
|
|
zoneExists: true,
|
|
});
|
|
});
|
|
|
|
test('red: unreachable → apiReachable:false, tokenValid:false', async () => {
|
|
const fetchImpl = jest.fn(async () => { const e = new Error('connect ECONNREFUSED'); e.name = 'FetchError'; throw e; });
|
|
const svc = createTechnitiumService({
|
|
client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl }),
|
|
logger: noopLogger,
|
|
});
|
|
const out = await svc.checkSetup({ zone: 'corp' });
|
|
expect(out).toMatchObject({ apiReachable: false, tokenValid: false, zoneExists: false });
|
|
expect(out.error).toContain('network_error');
|
|
});
|
|
|
|
test('yellow: reachable but bad token → apiReachable:true, tokenValid:false', async () => {
|
|
const fetchImpl = makeFetch([
|
|
['/zones/list', () => jsonResponse(401, { status: 'error', errorMessage: 'Unauthorized' })],
|
|
]);
|
|
const svc = createTechnitiumService({
|
|
client: createTechnitiumClient({ host: 'h', getToken: async () => 'bad', fetchImpl }),
|
|
logger: noopLogger,
|
|
});
|
|
const out = await svc.checkSetup({ zone: 'corp' });
|
|
expect(out).toMatchObject({ apiReachable: true, tokenValid: false, zoneExists: false });
|
|
});
|
|
});
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* Service: credential-manager wiring
|
|
* ------------------------------------------------------------------ */
|
|
|
|
describe('technitium service: credential wiring', () => {
|
|
test('token resolved from credentialManager.retrieve("technitium"); {token} object supported', async () => {
|
|
const cm = credMgr({ token: 'obj-tok' });
|
|
const fetchImpl = makeFetch([
|
|
['/zones/list', () => jsonResponse(200, { status: 'ok', response: [] })],
|
|
]);
|
|
const svc = createTechnitiumService({
|
|
credentialManager: cm,
|
|
config: { technitium: { host: 'dns.internal' } },
|
|
fetchImpl,
|
|
logger: noopLogger,
|
|
});
|
|
await svc.listZones();
|
|
expect(cm.retrieve).toHaveBeenCalledWith('technitium');
|
|
expect(new URL(fetchImpl.calls[0].url).searchParams.get('token')).toBe('obj-tok');
|
|
});
|
|
|
|
test('client construction requires host or baseUrl (never hardcoded)', () => {
|
|
expect(() => createTechnitiumClient({ getToken: async () => 't', fetchImpl: makeFetch([]) }))
|
|
.toThrow(ProviderError);
|
|
});
|
|
});
|