After the DC-005 module reorganization (41 files moved into src/ subdirs),
138 test suites failed because the refactor script's path-rewrite logic
missed three categories:
1. Files inside src/ doing 'require("./src/...")' — should be 'require("../...")'
2. Files in src/X/Y/ doing 'require("../../../src/...")' — should be 'require("../../...")'
3. Test files in __tests__/ with leftover 'require("../../../src/...")' paths
Root cause: the original refactor script ran before all files were moved,
so it computed relative paths against stale filesystem state.
Result:
- 30/30 test suites pass
- 879/879 tests pass (was: 18/30 suites, 614/687 tests)
Also fixed:
- routes/apps/restore.js: wrong responses import path
- routes/*/*.js: '../../src/utilities/X' → '../src/utilities/X' (depth 2 routes)
270 lines
9.6 KiB
JavaScript
270 lines
9.6 KiB
JavaScript
/**
|
||
* Cloudflare DNS Provider Adapter
|
||
* Manages DNS records via the Cloudflare API v4.
|
||
*/
|
||
const BaseDNSProvider = require('./base');
|
||
|
||
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
|
||
|
||
class CloudflareDNSProvider extends BaseDNSProvider {
|
||
constructor(config, ctx) {
|
||
super(config, ctx);
|
||
this.providerId = 'cloudflare';
|
||
this.displayName = 'Cloudflare DNS';
|
||
|
||
// Resolve API token: explicit config takes priority, then credential manager
|
||
this.apiToken = config.apiToken
|
||
|| (ctx.credentialManager && ctx.credentialManager.get('dns.cloudflare.apiToken'))
|
||
|| null;
|
||
this.zoneId = config.zoneId || null;
|
||
this.domain = config.domain || null;
|
||
}
|
||
|
||
// ── Helpers ────────────────────────────────────────────────────────────
|
||
|
||
/** Build common request headers for Cloudflare API calls */
|
||
_headers() {
|
||
return {
|
||
'Authorization': `Bearer ${this.apiToken}`,
|
||
'Content-Type': 'application/json',
|
||
};
|
||
}
|
||
|
||
/** Make an authenticated request to the Cloudflare API */
|
||
async _cfRequest(method, path, body) {
|
||
const url = `${CF_API_BASE}${path}`;
|
||
const opts = {
|
||
method,
|
||
headers: this._headers(),
|
||
};
|
||
if (body !== undefined) {
|
||
opts.body = JSON.stringify(body);
|
||
}
|
||
return this.ctx.fetchT(url, opts);
|
||
}
|
||
|
||
/** Map a Cloudflare DNS record to the normalised format expected by routes */
|
||
_mapRecord(rec) {
|
||
return {
|
||
id: rec.id,
|
||
type: rec.type,
|
||
name: rec.name,
|
||
value: rec.content,
|
||
ttl: rec.ttl,
|
||
proxied: rec.proxied || false,
|
||
};
|
||
}
|
||
|
||
// ── Capabilities ───────────────────────────────────────────────────────
|
||
|
||
supportsCapability(cap) {
|
||
return this.getCapabilities().includes(cap);
|
||
}
|
||
|
||
getCapabilities() {
|
||
return ['create-record', 'delete-record', 'resolve', 'list-records', 'credentials', 'zones'];
|
||
}
|
||
|
||
// ── Authentication ─────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Validate the API token by calling the Cloudflare verify endpoint.
|
||
* Stores basic zone info on success.
|
||
*/
|
||
async authenticate() {
|
||
this.ctx.log('[cloudflare] Authenticating – verifying API token…');
|
||
|
||
if (!this.apiToken) {
|
||
return { status: 'error', message: 'No Cloudflare API token provided' };
|
||
}
|
||
|
||
const res = await this._cfRequest('GET', '/user/tokens/verify');
|
||
const data = await res.json();
|
||
|
||
if (!data.success) {
|
||
const msg = (data.errors && data.errors[0] && data.errors[0].message) || 'Token verification failed';
|
||
this.ctx.log(`[cloudflare] Authentication failed: ${msg}`);
|
||
return { status: 'error', message: msg };
|
||
}
|
||
|
||
this.ctx.log(`[cloudflare] Token verified for status "${data.status}"`);
|
||
|
||
// Optionally fetch zone info if zoneId is configured
|
||
if (this.zoneId) {
|
||
try {
|
||
const zoneRes = await this._cfRequest('GET', `/zones/${this.zoneId}`);
|
||
const zoneData = await zoneRes.json();
|
||
if (zoneData.success && zoneData.result) {
|
||
this.zoneInfo = zoneData.result;
|
||
this.ctx.log(`[cloudflare] Zone loaded: ${zoneData.result.name} (${zoneData.result.id})`);
|
||
}
|
||
} catch (err) {
|
||
this.ctx.log(`[cloudflare] Could not fetch zone info: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
return { status: 'ok', response: { status: data.status } };
|
||
}
|
||
|
||
// ── Create Record ──────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Create a DNS record.
|
||
* If overwrite is true, first delete any existing record with the same name+type.
|
||
*/
|
||
async createRecord({ domain, zone, type, value, ttl, overwrite }) {
|
||
const targetDomain = domain || this.domain;
|
||
const targetZone = zone || this.zoneId;
|
||
|
||
if (!targetZone) {
|
||
return { status: 'error', message: 'No zone ID configured for Cloudflare' };
|
||
}
|
||
|
||
if (overwrite) {
|
||
this.ctx.log(`[cloudflare] Overwrite requested – deleting existing ${type} record for ${targetDomain}`);
|
||
try {
|
||
await this.deleteRecord({ domain: targetDomain, type, value });
|
||
} catch (err) {
|
||
this.ctx.log(`[cloudflare] No existing record to overwrite (or delete failed): ${err.message}`);
|
||
}
|
||
}
|
||
|
||
const body = {
|
||
type,
|
||
name: targetDomain,
|
||
content: value,
|
||
ttl: ttl || 1, // 1 = automatic TTL in Cloudflare
|
||
proxied: false,
|
||
};
|
||
|
||
this.ctx.log(`[cloudflare] Creating ${type} record: ${targetDomain} → ${value}`);
|
||
const res = await this._cfRequest('POST', `/zones/${targetZone}/dns_records`, body);
|
||
const data = await res.json();
|
||
|
||
if (!data.success) {
|
||
const msg = (data.errors && data.errors[0] && data.errors[0].message) || 'Record creation failed';
|
||
this.ctx.log(`[cloudflare] Create failed: ${msg}`);
|
||
return { status: 'error', message: msg };
|
||
}
|
||
|
||
return { status: 'ok', response: { record: this._mapRecord(data.result) } };
|
||
}
|
||
|
||
// ── Delete Record ──────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Delete DNS records matching domain+type.
|
||
* Lists matching records first, then deletes each one.
|
||
*/
|
||
async deleteRecord({ domain, type, value }) {
|
||
const targetDomain = domain || this.domain;
|
||
const targetZone = this.zoneId;
|
||
|
||
if (!targetZone) {
|
||
return { status: 'error', message: 'No zone ID configured for Cloudflare' };
|
||
}
|
||
|
||
// List records matching name + type
|
||
let queryPath = `/zones/${targetZone}/dns_records?name=${encodeURIComponent(targetDomain)}`;
|
||
if (type) {
|
||
queryPath += `&type=${encodeURIComponent(type)}`;
|
||
}
|
||
|
||
const listRes = await this._cfRequest('GET', queryPath);
|
||
const listData = await listRes.json();
|
||
|
||
if (!listData.success) {
|
||
const msg = (listData.errors && listData.errors[0] && listData.errors[0].message) || 'Failed to list records for deletion';
|
||
this.ctx.log(`[cloudflare] Delete – list failed: ${msg}`);
|
||
return { status: 'error', message: msg };
|
||
}
|
||
|
||
const matching = listData.result || [];
|
||
if (matching.length === 0) {
|
||
this.ctx.log(`[cloudflare] No records found for ${targetDomain} (${type || 'any type'})`);
|
||
return { status: 'ok', response: { deleted: 0 } };
|
||
}
|
||
|
||
// If a specific value is given, only delete records matching that value
|
||
const toDelete = value
|
||
? matching.filter((r) => r.content === value)
|
||
: matching;
|
||
|
||
let deleted = 0;
|
||
for (const record of toDelete) {
|
||
const delRes = await this._cfRequest('DELETE', `/zones/${targetZone}/dns_records/${record.id}`);
|
||
const delData = await delRes.json();
|
||
if (delData.success) {
|
||
deleted++;
|
||
this.ctx.log(`[cloudflare] Deleted record ${record.id} (${record.type} ${record.name})`);
|
||
} else {
|
||
const msg = (delData.errors && delData.errors[0] && delData.errors[0].message) || 'Delete failed';
|
||
this.ctx.log(`[cloudflare] Failed to delete record ${record.id}: ${msg}`);
|
||
}
|
||
}
|
||
|
||
return { status: 'ok', response: { deleted } };
|
||
}
|
||
|
||
// ── Resolve Records ───────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Resolve/query existing records for a domain.
|
||
* Returns records matching domain (and optionally type).
|
||
*/
|
||
async resolveRecords({ domain, zone, type }) {
|
||
const targetDomain = domain || this.domain;
|
||
const targetZone = zone || this.zoneId;
|
||
|
||
if (!targetZone) {
|
||
return { status: 'error', message: 'No zone ID configured for Cloudflare' };
|
||
}
|
||
|
||
let queryPath = `/zones/${targetZone}/dns_records?name=${encodeURIComponent(targetDomain)}`;
|
||
if (type) {
|
||
queryPath += `&type=${encodeURIComponent(type)}`;
|
||
}
|
||
|
||
this.ctx.log(`[cloudflare] Resolving records for ${targetDomain}${type ? ` (${type})` : ''}`);
|
||
const res = await this._cfRequest('GET', queryPath);
|
||
const data = await res.json();
|
||
|
||
if (!data.success) {
|
||
const msg = (data.errors && data.errors[0] && data.errors[0].message) || 'Resolve failed';
|
||
this.ctx.log(`[cloudflare] Resolve failed: ${msg}`);
|
||
return { status: 'error', message: msg };
|
||
}
|
||
|
||
const records = (data.result || []).map(this._mapRecord);
|
||
return { status: 'ok', response: { records } };
|
||
}
|
||
|
||
// ── List Records ───────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* List all DNS records in a zone.
|
||
*/
|
||
async listRecords({ zone }) {
|
||
const targetZone = zone || this.zoneId;
|
||
|
||
if (!targetZone) {
|
||
return { status: 'error', message: 'No zone ID configured for Cloudflare' };
|
||
}
|
||
|
||
this.ctx.log(`[cloudflare] Listing all records in zone ${targetZone}`);
|
||
const res = await this._cfRequest('GET', `/zones/${targetZone}/dns_records`);
|
||
const data = await res.json();
|
||
|
||
if (!data.success) {
|
||
const msg = (data.errors && data.errors[0] && data.errors[0].message) || 'List failed';
|
||
this.ctx.log(`[cloudflare] List failed: ${msg}`);
|
||
return { status: 'error', message: msg };
|
||
}
|
||
|
||
const records = (data.result || []).map(this._mapRecord);
|
||
return { status: 'ok', response: { records } };
|
||
}
|
||
}
|
||
|
||
module.exports = CloudflareDNSProvider;
|