Files
dashcaddy/dashcaddy-api/__tests__/dns/powerdns-adapter.test.js
T
Hermes 6c9f9e59a3 [grade=B urn:ump:ez33fpgyydl3nyjnwaaplisz7slunupcf5dnr42pdblwx7chfcma] feat(DC-171): phase 2 — PowerDNS adapter (full plug-in parity) + shared wizard DNS serialisation
NEW powerdns.js adapter: PowerDNS Authoritative HTTP API (X-API-Key).
Full parity (zones + native RRset enumeration — no AXFR needed):
listZones/listRecords, additive create that merges with existing rrset
contents and FAILS CLOSED when the rrset cannot be read (blind REPLACE
would erase values), content-level delete with positive value-presence
proof (absent rrset/value → not-found with ZERO writes; empty/null value
rejected; only genuine whole-rrset intent issues DELETE), TTL preserved
in REPLACE payloads and on additive creates, cross-zone qualification via
the server-resolved zone id/name, case-insensitive rrset matching,
dig-based resolve against the DNS listener (distinct from the API port),
error guidance naming the exact pdns.conf prerequisite.

WIZARD SERIALISATION EXTRACTED to shared js/dns-provider-config.js
(browser global + module.exports): per-provider port defaults
(PowerDNS 8081, RFC2136 53, legacy Technitium 5380), strict port
validation (full-string digits, 1-65535), provider-specific aliases
(server/apiPort/dnsPort/apiToken). The backend tests now execute the
EXACT shipped wizard code, joined through the real createProviderDnsContext
factory to adapter authentication (X-API-Key asserted). provider-dns.js
dual-accepts generic wizard keys and provider-specific fields and
normalises ports to integers (fixes the same latent gap for rfc2136).

Also fixed: rfc2136 parity-matrix rows now exact-sorted-equality across
all five adapters; wizard confirmation summary uses the provider default.

Judge: 7 rounds — r1 C (silent-erase on failed read, cross-zone bug,
missing resolve tests) → r2 C (deleteRecord positive-proof semantics) →
r3 C (TTL in REPLACE, count honesty) → r4 C (empty-value fall-through)
→ r5 C (wizard ip/port/token vs builder field mismatch) → r6 C (5380
default defeating provider ports, untested serialisation) → r7 B zero
blockers (urn:ump:ez33fpgyydl3nyjnwaaplisz7slunupcf5dnr42pdblwx7chfcma).
Post-grade polish applied: strict port validation + range, summary uses
provider defaults, joined serialisation→factory→adapter test, invalid
port table tests.

Suite: backend 176 suites / 3791 tests, frontend 68/68.
2026-10-29 23:46:03 -07:00

396 lines
15 KiB
JavaScript

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