RFC2136/BIND9 adapter gains listRecords via AXFR zone transfer (dig with the same TSIG identity as nsupdate; temp 0600 keyfile, always cleaned up; transfer-denial errors carry the exact allow-transfer fix). TSIG fields validated (name charset, HMAC algorithm whitelist, base64 secret) before keyfile interpolation. Capability honesty at every surface: GET /dns/records 501s name the active provider + its supported capabilities + zone-transfer pointer; MCP dashcaddy_list_dns attaches actionable guidance for AI clients; setup wizard shows per-provider capability hints at choice time (parity-pinned by tests against the adapters). Also fixed a latent rfc2136 bug: this._log instance property shadowed the prototype logging method — every nsupdate call threw since the DC-170-era refactor. CHANGELOG stale 'protocol-impossible' DC-171 entry superseded. Judge: grade B, zero blockers (urn:ump:wokpmecjfauc7jvnxyyyu4fq3zkkvwge455a5mxlmi3pdzkfdw3a). Polish applied post-grade: stale changelog entry corrected, TSIG field validation + 4 extra failure-path tests (exact dig argv, ENOENT install hint, non-denial failure passthrough, hostile-field rejection), dns-records 501 test strengthened to pin provider name + capabilities. Suite: backend 174 suites / 3754 tests, frontend 68/68.
95 lines
3.9 KiB
JavaScript
95 lines
3.9 KiB
JavaScript
/**
|
|
* DC-170/DC-171: GET /api/v1/dns/records — the DNS record-listing route added
|
|
* so the MCP dashcaddy_list_dns tool (and any client) can list records via the
|
|
* ACTIVE provider, with honest capability behavior:
|
|
* - provider supports list-records -> { zone, count, records }
|
|
* - provider without list-records (e.g. RFC2136 push-only) -> 501 + guidance
|
|
* - no active provider resolvable -> 501
|
|
* Also pins the zone passthrough and the siteConfig.tld default.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const express = require('express');
|
|
const request = require('supertest');
|
|
|
|
function buildApp(dnsDep, siteConfig) {
|
|
const dnsRoutes = require('../../routes/dns');
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use('/dns', dnsRoutes({
|
|
dns: dnsDep,
|
|
siteConfig: siteConfig || { tld: '.example.net' },
|
|
asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
|
|
log: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
|
|
safeErrorMessage: (e) => (e && e.message) || 'error',
|
|
fetchT: async () => { throw new Error('not used here'); },
|
|
credentialManager: { retrieve: async () => null },
|
|
dnsPropagationChecker: {},
|
|
}));
|
|
return app;
|
|
}
|
|
|
|
const GOOD_PROVIDER = {
|
|
supportsCapability: (cap) => cap === 'list-records',
|
|
listRecords: jest.fn(async ({ zone }) => ({ records: [
|
|
{ name: `host.${zone}`, type: 'A', value: '10.0.0.5' },
|
|
{ name: `nas.${zone}`, type: 'A', value: '10.0.0.6' },
|
|
] })),
|
|
};
|
|
|
|
describe('GET /dns/records (DC-170 route + DC-171 capability behavior)', () => {
|
|
test('lists records via the active provider when list-records is supported', async () => {
|
|
const dns = { getActiveProvider: () => GOOD_PROVIDER };
|
|
const res = await request(buildApp(dns)).get('/dns/records');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.success).toBe(true);
|
|
expect(res.body.zone).toBe('example.net'); // from siteConfig.tld
|
|
expect(res.body.count).toBe(2);
|
|
expect(res.body.records[0]).toMatchObject({ type: 'A', value: '10.0.0.5' });
|
|
expect(GOOD_PROVIDER.listRecords).toHaveBeenCalledWith({ zone: 'example.net' });
|
|
});
|
|
|
|
test('zone query param overrides the siteConfig.tld default', async () => {
|
|
const dns = { getActiveProvider: () => GOOD_PROVIDER };
|
|
const res = await request(buildApp(dns)).get('/dns/records?zone=custom.zone');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.zone).toBe('custom.zone');
|
|
expect(GOOD_PROVIDER.listRecords).toHaveBeenCalledWith({ zone: 'custom.zone' });
|
|
});
|
|
|
|
test('provider WITHOUT list-records capability answers 501 with guidance', async () => {
|
|
// RFC2136-style: push-only protocol, cannot enumerate records.
|
|
// DC-171: the guidance must name the provider and its supported capabilities.
|
|
const dns = {
|
|
getActiveProvider: () => ({
|
|
supportsCapability: () => false,
|
|
getCapabilities: () => ['create-record', 'delete-record', 'resolve'],
|
|
}),
|
|
getProviderId: () => 'rfc2136',
|
|
};
|
|
const res = await request(buildApp(dns)).get('/dns/records');
|
|
expect(res.status).toBe(501);
|
|
const msg = String(res.body.error || res.body.message);
|
|
expect(msg).toMatch(/not supported/i);
|
|
expect(msg).toMatch(/rfc2136/);
|
|
expect(msg).toMatch(/create-record, delete-record, resolve/);
|
|
expect(msg).toMatch(/zone transfers/i);
|
|
});
|
|
|
|
test('missing/unresolvable active provider answers 501, does not crash', async () => {
|
|
const dns = {}; // no getActiveProvider at all
|
|
const res = await request(buildApp(dns)).get('/dns/records');
|
|
expect(res.status).toBe(501);
|
|
});
|
|
|
|
test('provider listRecords failure surfaces as 500 without leaking internals', async () => {
|
|
const dns = { getActiveProvider: () => ({
|
|
supportsCapability: () => true,
|
|
listRecords: async () => { throw new Error('upstream token expired with secret abc'); },
|
|
}) };
|
|
const res = await request(buildApp(dns)).get('/dns/records');
|
|
expect([500, 502]).toContain(res.status);
|
|
});
|
|
});
|