/** * PowerDNS adapter tests (DC-171 phase 2). * * Covers the PowerDNS Authoritative HTTP API adapter: zone-id resolution * (exact id from the zones list), RRset flatten (listRecords), additive * merge vs REPLACE (createRecord) with fail-closed reads, content-level * delete vs full rrset DELETE (deleteRecord), cross-zone qualification, * auth guidance errors, and the dig-based resolveRecords path. */ 'use strict'; const path = require('path'); jest.mock('child_process', () => ({ execFile: jest.fn(), })); const { execFile } = require('child_process'); const REPO = path.join(__dirname, '..', '..'); const PowerDNS = require(path.join(REPO, 'src', 'dns', 'dns-providers', 'powerdns.js')); // ── fetch plumbing ────────────────────────────────────────────────────────── function jsonResponse(status, body) { return { ok: status >= 200 && status < 300, status, statusText: status === 401 ? 'Unauthorized' : 'Err', text: async () => JSON.stringify(body), }; } const ZONES_LIST = [ { id: 'sami', name: 'sami.', kind: 'Master', serial: 2026102901 }, { id: 'other.zone.', name: 'other.zone.', kind: 'Native', serial: 7 }, ]; const ZONE_DOC = { id: 'sami', name: 'sami.', kind: 'Master', serial: 2026102901, rrsets: [ { name: 'sami.', type: 'SOA', ttl: 900, records: [{ content: 'ns1.sami. hostadmin.sami. 1 3600 900 604800 300', disabled: false }], }, { name: 'app.sami.', type: 'A', ttl: 300, records: [{ content: '192.0.2.10', disabled: false }], }, { name: 'multi.sami.', type: 'A', ttl: 300, records: [ { content: '192.0.2.11', disabled: false }, { content: '192.0.2.12', disabled: false }, ], }, ], }; /** * Default fetch responder: GET /zones → list, GET /zones/{id} → doc, * PATCH → 204. Overridable per-test via `routes`. */ function makeProvider(overrides = {}, { routes, fetchError } = {}) { const fetchCalls = []; const fetchT = jest.fn(async (url, opts = {}) => { fetchCalls.push({ url, opts, body: opts.body ? JSON.parse(opts.body) : null }); if (fetchError) throw fetchError; if (routes) return routes(url, opts); if (url.endsWith('/zones')) return jsonResponse(200, ZONES_LIST); if (/\/zones\/[^/]+$/.test(url)) return jsonResponse(200, ZONE_DOC); return jsonResponse(204, {}); }); const p = new PowerDNS({ server: '192.0.2.30', apiPort: 8081, dnsPort: 53, apiToken: 'pdns-secret', zone: 'sami', ...overrides, }, { log: { debug() {}, info() {}, warn() {}, error() {} }, credentialManager: { get: () => null }, fetchT, }); return { p, fetchCalls, fetchT }; } const patchCalls = (fetchCalls) => fetchCalls.filter(c => c.opts.method === 'PATCH'); // ── listRecords ───────────────────────────────────────────────────────────── describe('PowerDNS adapter — listRecords (DC-171 native enumeration)', () => { test('resolves the zone id from the zones list, then flattens RRsets', async () => { const { p, fetchCalls } = makeProvider(); const result = await p.listRecords({}); // first call is the zones list, second the zone document expect(fetchCalls[0].url).toContain('/servers/localhost/zones'); expect(fetchCalls[1].url).toContain('/zones/sami'); expect(result.zone).toBe('sami'); expect(result.count).toBe(4); // 1 SOA + 1 A + 2 multi-A contents const multi = result.records.filter(r => r.name === 'multi.sami.'); expect(multi.map(r => r.value).sort()).toEqual(['192.0.2.11', '192.0.2.12']); }); test('unknown zone errors with the available-zone list', async () => { const { p } = makeProvider(); await expect(p.listRecords({ zone: 'missing' })) .rejects.toThrow(/Zone 'missing' not found.*sami\./s); }); }); // ── createRecord ──────────────────────────────────────────────────────────── describe('PowerDNS adapter — createRecord (merge vs REPLACE, fail-closed)', () => { test('additive create merges with existing rrset contents', async () => { const { p, fetchCalls } = makeProvider(); const result = await p.createRecord({ domain: 'multi.sami', type: 'A', value: '192.0.2.99', ttl: 300, }); expect(result.status).toBe('success'); const patch = patchCalls(fetchCalls)[0]; expect(patch.body.rrsets[0].changetype).toBe('REPLACE'); expect(patch.body.rrsets[0].name).toBe('multi.sami.'); expect(patch.body.rrsets[0].records.map(r => r.content).sort()) .toEqual(['192.0.2.11', '192.0.2.12', '192.0.2.99']); }); test('overwrite=true REPLACEs the rrset with just the new content, no read', async () => { const { p, fetchCalls } = makeProvider(); await p.createRecord({ domain: 'app.sami', type: 'A', value: '192.0.2.10', overwrite: true, }); expect(fetchCalls).toHaveLength(2); // zones list + PATCH only const patch = patchCalls(fetchCalls)[0]; expect(patch.body.rrsets[0].records).toHaveLength(1); }); test('additive create FAILS CLOSED when the rrset read fails (no clobbering PATCH)', async () => { const { p, fetchCalls } = makeProvider({}, { routes: (url, opts = {}) => { if (opts.method === 'PATCH') return jsonResponse(204, {}); if (url.endsWith('/zones')) return jsonResponse(200, ZONES_LIST); return jsonResponse(500, { error: 'backend read failed' }); }, }); await expect(p.createRecord({ domain: 'multi.sami', type: 'A', value: '192.0.2.99' })) .rejects.toThrow(/failed \(500\)/); expect(patchCalls(fetchCalls)).toHaveLength(0); // never clobbered }); test('cross-zone create qualifies the name and patches the OVERRIDE zone', async () => { const { p, fetchCalls } = makeProvider(); await p.createRecord({ domain: 'host.other.zone', zone: 'other.zone', type: 'A', value: '192.0.2.77', }); const patch = patchCalls(fetchCalls)[0]; expect(patch.url).toContain('/zones/other.zone.'); expect(patch.body.rrsets[0].name).toBe('host.other.zone.'); }); }); // ── deleteRecord ──────────────────────────────────────────────────────────── describe('PowerDNS adapter — deleteRecord (content-level vs rrset, fail-closed)', () => { test('removes one content from a multi-value rrset, keeps the rest', async () => { const { p, fetchCalls } = makeProvider(); const result = await p.deleteRecord({ domain: 'multi.sami', type: 'A', value: '192.0.2.11', }); expect(result).toMatchObject({ status: 'success', remaining: 1 }); const patch = patchCalls(fetchCalls)[0]; expect(patch.body.rrsets[0].changetype).toBe('REPLACE'); // REPLACE payloads must carry the rrset's existing TTL (PowerDNS requires it) expect(patch.body.rrsets[0].ttl).toBe(300); expect(patch.body.rrsets[0].records).toEqual([{ content: '192.0.2.12', disabled: false }]); }); test('without a value, DELETEs the whole rrset', async () => { const { p, fetchCalls } = makeProvider(); const result = await p.deleteRecord({ domain: 'app.sami', type: 'A' }); expect(result.status).toBe('success'); const patch = patchCalls(fetchCalls)[0]; expect(patch.body.rrsets[0].changetype).toBe('DELETE'); }); test('content-level delete FAILS CLOSED when the rrset read fails (no blind DELETE)', async () => { const { p, fetchCalls } = makeProvider({}, { routes: (url, opts = {}) => { if (opts.method === 'PATCH') return jsonResponse(204, {}); if (url.endsWith('/zones')) return jsonResponse(200, ZONES_LIST); return jsonResponse(500, { error: 'backend read failed' }); }, }); await expect(p.deleteRecord({ domain: 'multi.sami', type: 'A', value: '192.0.2.11' })) .rejects.toThrow(/failed \(500\)/); expect(patchCalls(fetchCalls)).toHaveLength(0); }); test('deleting a NONEXISTENT value is a no-op not-found with ZERO writes', async () => { const { p, fetchCalls } = makeProvider(); const result = await p.deleteRecord({ domain: 'multi.sami', type: 'A', value: '203.0.113.99', // not in the rrset }); expect(result.status).toBe('not-found'); expect(result.deleted).toBeNull(); expect(result.remaining).toBe(2); // siblings untouched expect(result.message).toMatch(/not present in the A records/); expect(patchCalls(fetchCalls)).toHaveLength(0); // no destructive write }); test('deleting from an ABSENT rrset is a no-op not-found with ZERO writes', async () => { const { p, fetchCalls } = makeProvider(); const result = await p.deleteRecord({ domain: 'ghost.sami', type: 'A', value: '192.0.2.1', }); expect(result.status).toBe('not-found'); expect(result.message).toMatch(/No A records exist for 'ghost\.sami\.'/); expect(patchCalls(fetchCalls)).toHaveLength(0); }); test('deleting the LAST remaining content falls through to the full DELETE', async () => { const { p, fetchCalls } = makeProvider(); const result = await p.deleteRecord({ domain: 'app.sami', type: 'A', value: '192.0.2.10', // the only content }); expect(result.status).toBe('success'); const patch = patchCalls(fetchCalls)[0]; expect(patch.body.rrsets[0].changetype).toBe('DELETE'); }); test.each([ ['empty string', ''], ['whitespace', ' '], ['null', null], ])('a SUPPLIED-but-empty value (%s) is rejected with ZERO writes', async (_label, badValue) => { const { p, fetchCalls } = makeProvider(); const result = await p.deleteRecord({ domain: 'multi.sami', type: 'A', value: badValue, }); expect(result.status).toBe('error'); expect(result.deleted).toBeNull(); expect(result.message).toMatch(/Nothing was deleted/); expect(patchCalls(fetchCalls)).toHaveLength(0); }); test('an OMITTED value (undefined) remains the explicit whole-rrset DELETE', async () => { const { p, fetchCalls } = makeProvider(); const result = await p.deleteRecord({ domain: 'multi.sami', type: 'A' }); expect(result.status).toBe('success'); expect(patchCalls(fetchCalls)).toHaveLength(1); expect(patchCalls(fetchCalls)[0].body.rrsets[0].changetype).toBe('DELETE'); }); test('additive create preserves the existing rrset TTL when the caller omits ttl', async () => { const { p, fetchCalls } = makeProvider(); await p.createRecord({ domain: 'app.sami', type: 'A', value: '192.0.2.10' }); const patch = patchCalls(fetchCalls)[0]; expect(patch.body.rrsets[0].ttl).toBe(300); // app.sami. rrset ttl from the zone doc }); test('rrset matching is case-insensitive (DNS semantics)', async () => { const { p, fetchCalls } = makeProvider({}, { routes: (url, opts = {}) => { if (opts.method === 'PATCH') return jsonResponse(204, {}); if (url.endsWith('/zones')) return jsonResponse(200, ZONES_LIST); // UPPERCASED owner names as some PowerDNS versions return them return jsonResponse(200, { ...ZONE_DOC, rrsets: ZONE_DOC.rrsets.map(rr => ({ ...rr, name: rr.name.toUpperCase() })), }); }, }); const result = await p.deleteRecord({ domain: 'MULTI.SAMI', type: 'a', value: '192.0.2.11', }); expect(result.status).toBe('success'); expect(result.remaining).toBe(1); }); }); // ── resolveRecords ────────────────────────────────────────────────────────── describe('PowerDNS adapter — resolveRecords (dig against the DNS listener)', () => { function mockDig(stdout) { execFile.mockImplementation((cmd, args, opts, cb) => { Promise.resolve({ stdout }).then(r => cb(null, r)).catch(e => cb(e)); }); } test('uses the DNS port (not the API port) and returns short answers', async () => { mockDig('192.0.2.10\n192.0.2.110'); const { p } = makeProvider(); const result = await p.resolveRecords({ domain: 'app.sami', type: 'A' }); const [, args] = execFile.mock.calls[0]; expect(args[0]).toBe('@192.0.2.30'); expect(args.slice(1, 3)).toEqual(['-p', '53']); // DNS listener, not 8081 expect(args).toContain('+short'); expect(result.records).toEqual([ { value: '192.0.2.10', type: 'A' }, { value: '192.0.2.110', type: 'A' }, ]); expect(result.source).toBe('dig'); }); }); // ── auth + errors ─────────────────────────────────────────────────────────── describe('PowerDNS adapter — auth + errors carry actionable guidance', () => { test('missing API key errors with the pdns.conf pointer', async () => { const { p } = makeProvider({ apiToken: null }); await expect(p.listRecords({})).rejects.toThrow(/api-key in pdns\.conf/); }); test('401 maps to an API-key guidance error', async () => { const { p } = makeProvider({}, { routes: async () => jsonResponse(401, { error: 'Unauthorized' }), }); await expect(p.listRecords({})).rejects.toThrow(/X-API-Key matches api-key/); }); test('connection refused maps to the webserver=yes hint', async () => { const err = new Error('fetch failed'); err.cause = { code: 'ECONNREFUSED' }; const { p } = makeProvider({}, { fetchError: err }); await expect(p.authenticate()).resolves.toMatchObject({ status: 'error', message: expect.stringMatching(/webserver=yes/), }); }); test('authenticate() success reports the server version', async () => { const { p } = makeProvider({}, { routes: (url) => ( url.endsWith('/servers/localhost') ? jsonResponse(200, { id: 'localhost', version: '4.7.0' }) : jsonResponse(200, ZONES_LIST) ), }); const auth = await p.authenticate(); expect(auth.status).toBe('success'); expect(auth.message).toMatch(/4\.7\.0/); }); }); // ── listZones ─────────────────────────────────────────────────────────────── describe('PowerDNS adapter — listZones', () => { test('maps zone metadata', async () => { const { p } = makeProvider({}, { routes: async () => jsonResponse(200, [ { id: 'sami', name: 'sami.', kind: 'Master', serial: 2026102901, dnssec: false, masters: [] }, { id: 'internal', name: 'internal.', kind: 'Native', serial: 5, dnssec: true, masters: ['192.0.2.1'] }, ]), }); const result = await p.listZones(); expect(result.zones).toHaveLength(2); expect(result.zones[1]).toMatchObject({ name: 'internal.', kind: 'Native', dnssec: true, masters: ['192.0.2.1'], }); }); });