/** * DC-171 — DNS provider plug-in parity. * * Pins the capability matrix for every shipped adapter against the * documented DC-171 matrix, verifies the RFC2136/BIND AXFR listRecords * implementation (parse + TSIG keyfile + guidance errors), and asserts the * setup wizard's provider-hints stay in sync with the adapters' * getCapabilities() so the choice-time reporting can't silently drift. */ 'use strict'; const fs = require('fs'); const path = require('path'); jest.mock('child_process', () => ({ execFile: jest.fn(), })); const { execFile } = require('child_process'); const { promisify } = require('util'); // The adapter promisifies execFile at require time; rebuild the promise form // per-test from the mocked execFile. let execFileImpl; beforeEach(() => { execFile.mockImplementation((cmd, args, opts, cb) => { execFileImpl(cmd, args, opts) .then((r) => cb(null, r)) .catch((e) => cb(e)); }); }); function loadProvider(file) { const Cls = require(path.join(REPO, 'src', 'dns', 'dns-providers', file)); return new Cls({}, { log: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }, }); } const REPO = path.join(__dirname, '..', '..'); // ── DC-171 capability matrix ──────────────────────────────────────────────── describe('DC-171 capability matrix (provider plug-in parity)', () => { const MATRIX = { // protocol-complete adapters 'technitium.js': { must: ['create-record', 'delete-record', 'resolve', 'list-records', 'zones'], forbidden: [], }, 'cloudflare.js': { must: ['create-record', 'delete-record', 'resolve', 'list-records'], forbidden: [], }, // RFC 2136 is push-only; DC-171 added AXFR-based listing (BIND 9 parity). 'rfc2136.js': { must: ['create-record', 'delete-record', 'resolve', 'credentials', 'list-records'], forbidden: ['logs', 'restart', 'update-check', 'zones'], }, // instructions-only by design 'manual.js': { must: [], forbidden: ['list-records', 'logs', 'restart', 'update-check'], }, }; for (const [file, { must, forbidden }] of Object.entries(MATRIX)) { test(`${file} declares exactly the DC-171 matrix`, () => { const p = loadProvider(file); const caps = p.getCapabilities(); for (const cap of must) { expect(caps).toContain(cap); expect(p.supportsCapability(cap)).toBe(true); } for (const cap of forbidden) { expect(caps).not.toContain(cap); expect(p.supportsCapability(cap)).toBe(false); } }); } test('every registered adapter file is covered by the matrix', () => { const dir = path.join(REPO, 'src', 'dns', 'dns-providers'); const adapters = fs.readdirSync(dir) .filter((f) => f.endsWith('.js') && f !== 'base.js' && f !== 'registry.js') .sort(); expect(adapters).toEqual(Object.keys(MATRIX).sort()); }); }); // ── RFC2136/BIND AXFR listRecords ─────────────────────────────────────────── describe('RFC2136 listRecords via AXFR (BIND 9 parity, DC-171)', () => { function makeProvider(config) { const p = loadProvider('rfc2136.js'); Object.assign(p, { server: '192.0.2.10', port: 53, zone: 'home.example.com', tsigKeyName: 'dashcaddy-key', tsigSecret: 'base64secret==', tsigAlgorithm: 'hmac-sha256', ...config, }); return p; } // Emulates `dig AXFR +onesoa` stdout: one SOA bookend, no ;; comments. const AXFR_OUTPUT = [ 'home.example.com. 3600 IN SOA ns1.home.example.com. admin.home.example.com. 42 3600 600 86400 300', 'home.example.com. 300 IN NS ns1.home.example.com.', 'host.home.example.com. 300 IN A 10.0.0.5', 'nas.home.example.com. 300 IN A 10.0.0.6', 'plex.home.example.com. 300 IN CNAME home.example.com.', ].join('\n'); function mockDig(stdout, stderr = '') { execFileImpl = async () => { const err = new Error('dig exited'); err.stderr = stderr; err.stdout = stdout; if (stderr) throw err; return { stdout, stderr: '' }; }; } test('parses AXFR output into records and skips the second SOA bookend', async () => { const fsMock = jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(); const unlinkMock = jest.spyOn(fs.promises, 'unlink').mockResolvedValue(); mockDig(AXFR_OUTPUT); const p = makeProvider(); const result = await p.listRecords({}); expect(result.zone).toBe('home.example.com'); // +onesoa: exactly one SOA row survives expect(result.records.filter((r) => r.type === 'SOA')).toHaveLength(1); expect(result.count).toBe(result.records.length); const a = result.records.find((r) => r.name === 'host.home.example.com.'); expect(a).toMatchObject({ type: 'A', value: '10.0.0.5', ttl: 300 }); const cname = result.records.find((r) => r.type === 'CNAME'); expect(cname.value).toBe('home.example.com.'); // TSIG keyfile was written 0600 and cleaned up expect(fsMock.mock.calls[0][2]).toMatchObject({ mode: 0o600 }); expect(unlinkMock).toHaveBeenCalled(); fsMock.mockRestore(); unlinkMock.mockRestore(); }); test('transfer denial carries the allow-transfer guidance with the key name', async () => { jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(); jest.spyOn(fs.promises, 'unlink').mockResolvedValue(); mockDig('', ';; Transfer failed: REFUSED'); const p = makeProvider(); await expect(p.listRecords({})).rejects.toThrow(/allow-transfer \{ key dashcaddy-key; \};/); }); test('no-TSIG denial guides toward allow-transfer generally', async () => { jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(); jest.spyOn(fs.promises, 'unlink').mockResolvedValue(); mockDig('', ';; Transfer failed: REFUSED'); const p = makeProvider({ tsigKeyName: null, tsigSecret: null }); await expect(p.listRecords({})).rejects.toThrow(/Configure 'allow-transfer'/); }); test('missing zone config throws before invoking dig', async () => { const p = makeProvider({ zone: null }); await expect(p.listRecords({})).rejects.toThrow(/zone/i); expect(execFile).not.toHaveBeenCalled(); }); test('builds the exact dig argument vector with the TSIG keyfile', async () => { const writeFileMock = jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(); jest.spyOn(fs.promises, 'unlink').mockResolvedValue(); mockDig(AXFR_OUTPUT); const p = makeProvider(); await p.listRecords({ zone: 'other.example.com' }); const [, args] = execFile.mock.calls[0]; expect(args[0]).toBe('@192.0.2.10'); expect(args.slice(1, 3)).toEqual(['-p', '53']); expect(args[3]).toBe('AXFR'); expect(args[4]).toBe('other.example.com.'); expect(args.slice(5)).toContain('+onesoa'); const kIdx = args.indexOf('-k'); expect(kIdx).toBeGreaterThan(-1); // keyfile content is valid BIND key syntax with the configured identity const keyContent = writeFileMock.mock.calls[0][1]; expect(keyContent).toContain('key "dashcaddy-key"'); expect(keyContent).toContain('algorithm hmac-sha256;'); expect(keyContent).toContain('secret "base64secret=="'); }); test('missing dig binary maps ENOENT to an install hint', async () => { jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(); const unlinkMock = jest.spyOn(fs.promises, 'unlink').mockResolvedValue(); execFileImpl = async () => { const err = new Error("spawn dig ENOENT"); err.code = 'ENOENT'; throw err; }; const p = makeProvider(); await expect(p.listRecords({})).rejects.toThrow(/bind9utils|bind-utils/); expect(unlinkMock).toHaveBeenCalled(); // keyfile still cleaned up }); test('non-denial dig failure is rethrown with context, keyfile cleaned up', async () => { jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(); const unlinkMock = jest.spyOn(fs.promises, 'unlink').mockResolvedValue(); execFileImpl = async () => { throw new Error('communication timed out'); }; const p = makeProvider(); await expect(p.listRecords({})).rejects.toThrow(/AXFR failed: communication timed out/); expect(unlinkMock).toHaveBeenCalled(); }); test('rejects hostile TSIG fields before writing any keyfile', async () => { const writeFileMock = jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(); const badName = makeProvider({ tsigKeyName: 'evil"; }; ' }); await expect(badName.listRecords({})).rejects.toThrow(/Invalid TSIG key name/); const badAlgo = makeProvider({ tsigAlgorithm: 'hmac-rainbow' }); await expect(badAlgo.listRecords({})).rejects.toThrow(/Unsupported TSIG algorithm/); const badSecret = makeProvider({ tsigSecret: 'abc "def' }); await expect(badSecret.listRecords({})).rejects.toThrow(/Invalid TSIG secret/); expect(writeFileMock).not.toHaveBeenCalled(); // nothing written for bad inputs }); }); // ── Setup wizard choice-time hints stay in sync with the adapters ─────────── describe('DC-171 setup wizard provider hints sync', () => { // status/ lives one level above dashcaddy-api in the monorepo const MONOREPO = path.join(__dirname, '..', '..', '..'); test('every wizard