Skip to main content

dnslib/vendors/cloudflare/
service.rs

1//! Cloudflare implementations of the vendor-neutral DNS service traits.
2//!
3//! Cloudflare is a cloud DNS provider with full DNS CRUD capability.
4//! The integration supports:
5//!   - `list_zones`    → GET /zones
6//!   - `list_records`  → GET /zones/{id}/dns_records
7//!   - `create_zone`   → POST /zones
8//!   - `delete_zone`   → DELETE /zones/{id}
9//!   - `add_record`    → POST /zones/{id}/dns_records
10//!   - `delete_record` → GET /zones/{id}/dns_records?name=&type= → DELETE /zones/{id}/dns_records/{id}
11//!   - `get_settings`  → GET /user/tokens/verify
12//!
13//!   - `import_zone_file` → POST /zones/{id}/dns_records/import
14//!   - `export_zone_file` → GET /zones/{id}/dns_records/export
15//!
16//! Cache, stats, access lists, enable/disable zone return `Error::Unsupported`.
17
18use serde_json::Value;
19use tracing::instrument;
20
21use crate::control_plane::config::VendorKind;
22use crate::core::dns::capabilities::VendorCapabilities;
23use crate::core::dns::logs::{LogLine, LogsOptions, LogsRead};
24use crate::core::dns::records::RecordData;
25use crate::core::dns::responses::{ListRecordsResponse, ZoneInfo, ZoneRecord};
26use crate::core::dns::service::{
27    AccessListRead, AccessListWrite, CacheRead, CacheWrite, DnsVendor, ListRecordsOptions,
28    RecordWrite, SettingsRead, StatsRead, ZoneExport, ZoneImport, ZoneRead, ZoneWrite,
29};
30use crate::core::error::{Error, Result};
31use crate::vendors::cloudflare::client::CloudflareClient;
32use crate::vendors::cloudflare::mapping::*;
33
34// ─── Zone ID resolution ───────────────────────────────────────────────────────
35
36impl CloudflareClient {
37    async fn resolve_zone_id(&self, zone_name: &str) -> Result<String> {
38        let data = self
39            .get("/zones", &[("name", zone_name.to_string())])
40            .await?;
41        let zones = data
42            .as_array()
43            .ok_or_else(|| Error::parse("Cloudflare zones response is not an array"))?;
44        zones
45            .first()
46            .and_then(|z| z.get("id"))
47            .and_then(|id| id.as_str())
48            .map(ToOwned::to_owned)
49            .ok_or_else(|| Error::Api {
50                message: format!("zone '{zone_name}' not found"),
51            })
52    }
53}
54
55// ─── DnsVendor ────────────────────────────────────────────────────────────────
56
57impl DnsVendor for CloudflareClient {
58    fn kind(&self) -> VendorKind {
59        VendorKind::Cloudflare
60    }
61
62    fn capabilities(&self) -> VendorCapabilities {
63        VendorCapabilities {
64            zones: true,
65            records: true,
66            cache: false,
67            access_lists: false,
68            settings: true,
69            zone_import: true,
70            zone_export: true,
71            logs: false,
72        }
73    }
74}
75
76// ─── ZoneRead ─────────────────────────────────────────────────────────────────
77
78impl ZoneRead for CloudflareClient {
79    #[instrument(skip(self), fields(vendor = "cloudflare", operation = "list_zones"))]
80    async fn list_zones(&self, page: u32, per_page: u32) -> Result<Value> {
81        self.get(
82            "/zones",
83            &[
84                ("page", page.to_string()),
85                ("per_page", per_page.to_string()),
86            ],
87        )
88        .await
89    }
90
91    #[instrument(skip(self), fields(vendor = "cloudflare", operation = "list_records"))]
92    async fn list_records<'a>(
93        &'a self,
94        domain: &'a str,
95        zone: Option<&'a str>,
96        _options: ListRecordsOptions,
97    ) -> Result<ListRecordsResponse> {
98        let zone_name = zone.unwrap_or(domain);
99        let zone_id = self.resolve_zone_id(zone_name).await?;
100
101        let data = self
102            .get(&format!("/zones/{zone_id}/dns_records"), &[])
103            .await?;
104
105        let records_arr = data
106            .as_array()
107            .ok_or_else(|| Error::parse("Cloudflare dns_records response is not an array"))?;
108
109        let records: Vec<ZoneRecord> = records_arr
110            .iter()
111            .map(|r| cloudflare_record_to_zone_record(r, zone_name))
112            .collect();
113
114        let zone_info = ZoneInfo {
115            id: Some(zone_id),
116            name: zone_name.to_string(),
117            zone_type: "Primary".to_string(),
118            disabled: false,
119            dnssec_status: None,
120        };
121
122        Ok(ListRecordsResponse::single(zone_info, records))
123    }
124}
125
126// ─── ZoneWrite ────────────────────────────────────────────────────────────────
127
128impl ZoneWrite for CloudflareClient {
129    #[instrument(skip(self), fields(vendor = "cloudflare", operation = "create_zone"))]
130    async fn create_zone<'a>(&'a self, zone: &'a str, _zone_type: &'a str) -> Result<Value> {
131        self.post(
132            "/zones",
133            &serde_json::json!({ "name": zone, "jump_start": false }),
134        )
135        .await
136    }
137
138    #[instrument(skip(self), fields(vendor = "cloudflare", operation = "delete_zone"))]
139    async fn delete_zone<'a>(&'a self, zone: &'a str) -> Result<Value> {
140        let zone_id = self.resolve_zone_id(zone).await?;
141        self.delete(&format!("/zones/{zone_id}")).await
142    }
143
144    async fn enable_zone<'a>(&'a self, _zone: &'a str) -> Result<Value> {
145        Err(Error::unsupported("Cloudflare", "enable zone"))
146    }
147
148    async fn disable_zone<'a>(&'a self, _zone: &'a str) -> Result<Value> {
149        Err(Error::unsupported("Cloudflare", "disable zone"))
150    }
151}
152
153// ─── RecordWrite ──────────────────────────────────────────────────────────────
154
155impl RecordWrite for CloudflareClient {
156    #[instrument(
157        skip(self, record),
158        fields(vendor = "cloudflare", operation = "add_record")
159    )]
160    async fn add_record<'a>(
161        &'a self,
162        zone: &'a str,
163        domain: &'a str,
164        ttl: u32,
165        record: &'a RecordData,
166    ) -> Result<Value> {
167        let zone_id = self.resolve_zone_id(zone).await?;
168        let body = record_data_to_cloudflare_body(domain, ttl, record);
169        self.post(&format!("/zones/{zone_id}/dns_records"), &body)
170            .await
171    }
172
173    #[instrument(
174        skip(self, type_params),
175        fields(vendor = "cloudflare", operation = "delete_record")
176    )]
177    async fn delete_record<'a>(
178        &'a self,
179        zone: &'a str,
180        domain: &'a str,
181        type_params: &'a [(&'a str, String)],
182    ) -> Result<Value> {
183        let zone_id = self.resolve_zone_id(zone).await?;
184
185        let record_type = type_params
186            .iter()
187            .find(|(k, _)| *k == "type")
188            .map(|(_, v)| v.as_str())
189            .unwrap_or("");
190
191        let fqdn = if domain == "@" {
192            zone.to_string()
193        } else if domain.ends_with('.') {
194            domain.trim_end_matches('.').to_string()
195        } else if domain.contains('.') {
196            domain.to_string()
197        } else {
198            format!("{domain}.{zone}")
199        };
200
201        let data = self
202            .get(
203                &format!("/zones/{zone_id}/dns_records"),
204                &[("name", fqdn.clone()), ("type", record_type.to_string())],
205            )
206            .await?;
207
208        let records = data
209            .as_array()
210            .ok_or_else(|| Error::parse("Cloudflare dns_records response is not an array"))?;
211
212        // If the caller supplied a value-bearing parameter (e.g. `ipAddress`
213        // for A/AAAA, `cname` for CNAME), restrict the match to records whose
214        // Cloudflare `content` field equals that value. Without this filter an
215        // rrset with several values would have its first entry deleted at
216        // random rather than the requested one.
217        let expected_content = expected_cloudflare_content(record_type, type_params);
218        let matched = records
219            .iter()
220            .find(|r| match expected_content {
221                Some(expected) => r.get("content").and_then(|c| c.as_str()) == Some(expected),
222                None => true,
223            })
224            .ok_or_else(|| Error::Api {
225                message: match expected_content {
226                    Some(value) => {
227                        format!("no {record_type} record '{fqdn}' with value '{value}' found")
228                    }
229                    None => format!("no {record_type} record found for '{fqdn}'"),
230                },
231            })?;
232
233        let record_id = matched
234            .get("id")
235            .and_then(|id| id.as_str())
236            .ok_or_else(|| Error::parse("Cloudflare dns_records entry missing id"))?
237            .to_owned();
238
239        self.delete(&format!("/zones/{zone_id}/dns_records/{record_id}"))
240            .await
241    }
242}
243
244/// Returns the value Cloudflare stores in the `content` field for the given
245/// record type, looked up from the canonical `type_params` API payload.
246/// Returns `None` for record types whose value lives in a structured `data`
247/// object (MX, SRV, CAA, …) — those fall back to first-match behaviour.
248fn expected_cloudflare_content<'a>(
249    record_type: &str,
250    type_params: &'a [(&'a str, String)],
251) -> Option<&'a str> {
252    let key = match record_type {
253        "A" | "AAAA" => "ipAddress",
254        "CNAME" => "cname",
255        "NS" => "nameserver",
256        "TXT" => "text",
257        "PTR" => "name",
258        "DNAME" => "dname",
259        _ => return None,
260    };
261    type_params
262        .iter()
263        .find(|(k, _)| *k == key)
264        .map(|(_, v)| v.as_str())
265}
266
267// ─── Unsupported operations ───────────────────────────────────────────────────
268
269impl CacheRead for CloudflareClient {
270    async fn list_cache<'a>(&'a self, _domain: &'a str) -> Result<Value> {
271        Err(Error::unsupported("Cloudflare", "cache listing"))
272    }
273}
274
275impl CacheWrite for CloudflareClient {
276    async fn delete_cache_zone<'a>(&'a self, _domain: &'a str) -> Result<Value> {
277        Err(Error::unsupported("Cloudflare", "cache zone deletion"))
278    }
279
280    async fn flush_cache(&self) -> Result<Value> {
281        Err(Error::unsupported("Cloudflare", "cache flush"))
282    }
283}
284
285impl StatsRead for CloudflareClient {
286    async fn get_stats<'a>(&'a self, _stats_type: &'a str) -> Result<Value> {
287        Err(Error::unsupported("Cloudflare", "stats"))
288    }
289}
290
291impl AccessListRead for CloudflareClient {
292    async fn list_blocked(&self) -> Result<Value> {
293        Err(Error::unsupported("Cloudflare", "blocked list"))
294    }
295
296    async fn list_allowed(&self) -> Result<Value> {
297        Err(Error::unsupported("Cloudflare", "allowed list"))
298    }
299}
300
301impl AccessListWrite for CloudflareClient {
302    async fn add_blocked<'a>(&'a self, _domain: &'a str) -> Result<Value> {
303        Err(Error::unsupported("Cloudflare", "add blocked"))
304    }
305
306    async fn delete_blocked<'a>(&'a self, _domain: &'a str) -> Result<Value> {
307        Err(Error::unsupported("Cloudflare", "delete blocked"))
308    }
309
310    async fn add_allowed<'a>(&'a self, _domain: &'a str) -> Result<Value> {
311        Err(Error::unsupported("Cloudflare", "add allowed"))
312    }
313
314    async fn delete_allowed<'a>(&'a self, _domain: &'a str) -> Result<Value> {
315        Err(Error::unsupported("Cloudflare", "delete allowed"))
316    }
317}
318
319impl ZoneImport for CloudflareClient {
320    #[instrument(
321        skip(self, file_bytes),
322        fields(vendor = "cloudflare", operation = "import_zone_file")
323    )]
324    async fn import_zone_file<'a>(
325        &'a self,
326        zone: &'a str,
327        file_name: String,
328        file_bytes: Vec<u8>,
329        overwrite: bool,
330        overwrite_zone: bool,
331        _overwrite_soa_serial: bool,
332    ) -> Result<Value> {
333        if overwrite_zone {
334            tracing::warn!(
335                "overwrite_zone is not supported by Cloudflare — import will be additive; \
336                 delete records manually first if a clean replace is needed"
337            );
338        }
339        if !overwrite {
340            tracing::warn!(
341                "overwrite=false is not supported by Cloudflare — \
342                 existing records will still be updated by the import"
343            );
344        }
345        let zone_id = self.resolve_zone_id(zone).await?;
346        self.post_multipart(
347            &format!("/zones/{zone_id}/dns_records/import"),
348            file_name,
349            file_bytes,
350        )
351        .await
352    }
353}
354
355impl ZoneExport for CloudflareClient {
356    #[instrument(
357        skip(self),
358        fields(vendor = "cloudflare", operation = "export_zone_file")
359    )]
360    async fn export_zone_file<'a>(&'a self, zone: &'a str) -> Result<String> {
361        let zone_id = self.resolve_zone_id(zone).await?;
362        self.get_text(&format!("/zones/{zone_id}/dns_records/export"), &[])
363            .await
364    }
365}
366
367impl SettingsRead for CloudflareClient {
368    #[instrument(skip(self), fields(vendor = "cloudflare", operation = "get_settings"))]
369    async fn get_settings(&self) -> Result<Value> {
370        self.get("/user/tokens/verify", &[]).await
371    }
372}
373
374impl LogsRead for CloudflareClient {
375    async fn get_logs(&self, _: LogsOptions) -> Result<Vec<LogLine>> {
376        Err(Error::unsupported("Cloudflare", "logs"))
377    }
378}
379
380// ─── Tests ────────────────────────────────────────────────────────────────────
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use serde_json::json;
386
387    fn make_client() -> CloudflareClient {
388        CloudflareClient::new(
389            "https://api.cloudflare.com/client/v4".to_string(),
390            crate::core::secret::ApiToken::new("test-token"),
391        )
392        .unwrap()
393    }
394
395    // ── VendorKind / capabilities ─────────────────────────────────────────────
396
397    #[test]
398    fn kind_returns_cloudflare() {
399        let client = make_client();
400        assert_eq!(client.kind(), VendorKind::Cloudflare);
401    }
402
403    #[test]
404    fn capabilities_match_supported_operations() {
405        let caps = make_client().capabilities();
406        assert!(caps.zones);
407        assert!(caps.records);
408        assert!(!caps.cache);
409        assert!(!caps.access_lists);
410        assert!(caps.settings);
411        assert!(caps.zone_import);
412        assert!(caps.zone_export);
413        assert!(!caps.logs);
414    }
415
416    #[tokio::test]
417    async fn get_logs_is_unsupported() {
418        use crate::core::dns::logs::LogsOptions;
419        let err = make_client()
420            .get_logs(LogsOptions::default())
421            .await
422            .unwrap_err();
423        assert!(matches!(
424            err,
425            Error::Unsupported {
426                vendor: "Cloudflare",
427                ..
428            }
429        ));
430    }
431
432    // ── Unsupported operations return correct error ────────────────────────────
433
434    #[tokio::test]
435    async fn enable_zone_is_unsupported() {
436        let err = make_client().enable_zone("example.com").await.unwrap_err();
437        assert!(matches!(
438            err,
439            Error::Unsupported {
440                vendor: "Cloudflare",
441                ..
442            }
443        ));
444    }
445
446    #[tokio::test]
447    async fn disable_zone_is_unsupported() {
448        let err = make_client().disable_zone("example.com").await.unwrap_err();
449        assert!(matches!(
450            err,
451            Error::Unsupported {
452                vendor: "Cloudflare",
453                ..
454            }
455        ));
456    }
457
458    #[tokio::test]
459    async fn list_cache_is_unsupported() {
460        let err = make_client().list_cache("example.com").await.unwrap_err();
461        assert!(matches!(
462            err,
463            Error::Unsupported {
464                vendor: "Cloudflare",
465                ..
466            }
467        ));
468    }
469
470    #[tokio::test]
471    async fn flush_cache_is_unsupported() {
472        let err = make_client().flush_cache().await.unwrap_err();
473        assert!(matches!(
474            err,
475            Error::Unsupported {
476                vendor: "Cloudflare",
477                ..
478            }
479        ));
480    }
481
482    #[tokio::test]
483    async fn get_stats_is_unsupported() {
484        let err = make_client().get_stats("last7days").await.unwrap_err();
485        assert!(matches!(
486            err,
487            Error::Unsupported {
488                vendor: "Cloudflare",
489                ..
490            }
491        ));
492    }
493
494    #[tokio::test]
495    async fn list_blocked_is_unsupported() {
496        let err = make_client().list_blocked().await.unwrap_err();
497        assert!(matches!(
498            err,
499            Error::Unsupported {
500                vendor: "Cloudflare",
501                ..
502            }
503        ));
504    }
505
506    #[tokio::test]
507    async fn zone_import_attempts_api_call_with_default_flags() {
508        // overwrite=true, overwrite_zone=false — network error confirms it reaches the API
509        let err = make_client()
510            .import_zone_file("example.com", "zone.txt".into(), vec![], true, false, false)
511            .await
512            .unwrap_err();
513        assert!(!matches!(err, Error::Unsupported { .. }));
514    }
515
516    #[tokio::test]
517    async fn zone_import_overwrite_zone_warns_and_proceeds() {
518        // overwrite_zone=true emits a warning but still reaches the API (not an error)
519        let err = make_client()
520            .import_zone_file("example.com", "zone.txt".into(), vec![], true, true, false)
521            .await
522            .unwrap_err();
523        assert!(!matches!(err, Error::Unsupported { .. }));
524    }
525
526    #[tokio::test]
527    async fn zone_import_no_overwrite_warns_and_proceeds() {
528        // overwrite=false emits a warning but still reaches the API (not an error)
529        let err = make_client()
530            .import_zone_file(
531                "example.com",
532                "zone.txt".into(),
533                vec![],
534                false,
535                false,
536                false,
537            )
538            .await
539            .unwrap_err();
540        assert!(!matches!(err, Error::Unsupported { .. }));
541    }
542
543    // ── Record normalization ──────────────────────────────────────────────────
544
545    #[test]
546    fn a_record_normalization() {
547        let cf = json!({
548            "id": "abc", "name": "www.example.com", "type": "A",
549            "content": "1.2.3.4", "ttl": 300, "proxied": false
550        });
551        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
552        assert_eq!(rec.name, "www");
553        assert_eq!(rec.record_type, "A");
554        assert_eq!(rec.ttl, 300);
555        assert!(!rec.disabled);
556        assert_eq!(rec.data["ipAddress"], "1.2.3.4");
557        assert_eq!(rec.data["proxied"], false);
558    }
559
560    #[test]
561    fn apex_record_name_becomes_at() {
562        let cf = json!({
563            "id": "abc", "name": "example.com", "type": "A",
564            "content": "1.2.3.4", "ttl": 300, "proxied": false
565        });
566        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
567        assert_eq!(rec.name, "@");
568    }
569
570    #[test]
571    fn mx_record_normalization() {
572        let cf = json!({
573            "id": "abc", "name": "example.com", "type": "MX",
574            "content": "mail.example.com", "priority": 10, "ttl": 300
575        });
576        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
577        assert_eq!(rec.record_type, "MX");
578        assert_eq!(rec.data["preference"], 10);
579        assert_eq!(rec.data["exchange"], "mail.example.com");
580    }
581
582    #[test]
583    fn txt_record_normalization() {
584        let cf = json!({
585            "id": "abc", "name": "example.com", "type": "TXT",
586            "content": "v=spf1 ~all", "ttl": 300
587        });
588        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
589        assert_eq!(rec.data["text"], "v=spf1 ~all");
590        assert_eq!(rec.data["splitText"], false);
591    }
592
593    #[test]
594    fn cname_record_normalization() {
595        let cf = json!({
596            "id": "abc", "name": "www.example.com", "type": "CNAME",
597            "content": "example.com", "ttl": 300, "proxied": false
598        });
599        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
600        assert_eq!(rec.data["cname"], "example.com");
601    }
602
603    #[test]
604    fn srv_record_normalization() {
605        let cf = json!({
606            "id": "abc", "name": "_sip._tcp.example.com", "type": "SRV",
607            "data": { "priority": 10, "weight": 20, "port": 5060, "target": "sip.example.com" },
608            "ttl": 300
609        });
610        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
611        assert_eq!(rec.record_type, "SRV");
612        assert_eq!(rec.data["priority"], 10);
613        assert_eq!(rec.data["weight"], 20);
614        assert_eq!(rec.data["port"], 5060);
615        assert_eq!(rec.data["target"], "sip.example.com");
616    }
617
618    #[test]
619    fn unknown_type_falls_back_to_value_field() {
620        let cf = json!({
621            "id": "abc", "name": "example.com", "type": "LOC",
622            "content": "51 30 0.000 N 0 7 0.000 W 0m", "ttl": 300
623        });
624        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
625        assert_eq!(rec.record_type, "LOC");
626        assert!(rec.data.get("value").is_some());
627    }
628
629    #[test]
630    fn aaaa_record_normalization() {
631        let cf = json!({
632            "id": "abc", "name": "www.example.com", "type": "AAAA",
633            "content": "2001:db8::1", "ttl": 300, "proxied": false
634        });
635        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
636        assert_eq!(rec.name, "www");
637        assert_eq!(rec.record_type, "AAAA");
638        assert_eq!(rec.data["ipAddress"], "2001:db8::1");
639    }
640
641    #[test]
642    fn dname_record_normalization() {
643        let cf = json!({
644            "id": "abc", "name": "example.com", "type": "DNAME",
645            "content": "other.example.com", "ttl": 300
646        });
647        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
648        assert_eq!(rec.record_type, "DNAME");
649        assert_eq!(rec.data["dname"], "other.example.com");
650    }
651
652    #[test]
653    fn sshfp_record_normalization() {
654        let cf = json!({
655            "id": "abc", "name": "example.com", "type": "SSHFP",
656            "content": "1 2 abcdef", "ttl": 300,
657            "data": { "algorithm": 1, "type": 2, "fingerprint": "abcdef" }
658        });
659        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
660        assert_eq!(rec.record_type, "SSHFP");
661        assert_eq!(rec.data["sshfpAlgorithm"], "RSA");
662        assert_eq!(rec.data["sshfpFingerprintType"], "SHA256");
663        assert_eq!(rec.data["sshfpFingerprint"], "abcdef");
664    }
665
666    #[test]
667    fn tlsa_record_normalization() {
668        let cf = json!({
669            "id": "abc", "name": "_443._tcp.example.com", "type": "TLSA",
670            "content": "3 1 1 deadbeef", "ttl": 300,
671            "data": { "usage": 3, "selector": 1, "matching_type": 1, "certificate": "deadbeef" }
672        });
673        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
674        assert_eq!(rec.record_type, "TLSA");
675        assert_eq!(rec.data["tlsaCertificateUsage"], "DANE-EE");
676        assert_eq!(rec.data["tlsaSelector"], "SPKI");
677        assert_eq!(rec.data["tlsaMatchingType"], "SHA2-256");
678        assert_eq!(rec.data["tlsaCertificateAssociationData"], "deadbeef");
679    }
680
681    #[test]
682    fn ds_record_normalization() {
683        let cf = json!({
684            "id": "abc", "name": "example.com", "type": "DS",
685            "content": "1234 13 2 abcdef", "ttl": 300,
686            "data": { "key_tag": 1234, "algorithm": 13, "digest_type": 2, "digest": "abcdef" }
687        });
688        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
689        assert_eq!(rec.record_type, "DS");
690        assert_eq!(rec.data["keyTag"], 1234);
691        assert_eq!(rec.data["algorithm"], "ECDSAP256SHA256");
692        assert_eq!(rec.data["digestType"], "SHA256");
693        assert_eq!(rec.data["digest"], "abcdef");
694    }
695
696    #[test]
697    fn https_record_normalization() {
698        let cf = json!({
699            "id": "abc", "name": "example.com", "type": "HTTPS",
700            "content": "1 . alpn=h2", "ttl": 300,
701            "data": { "priority": 1, "target": ".", "value": "alpn=h2" }
702        });
703        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
704        assert_eq!(rec.record_type, "HTTPS");
705        assert_eq!(rec.data["svcPriority"], 1);
706        assert_eq!(rec.data["svcTargetName"], ".");
707        assert_eq!(rec.data["svcParams"], "alpn=h2");
708    }
709
710    #[test]
711    fn naptr_record_normalization() {
712        let cf = json!({
713            "id": "abc", "name": "example.com", "type": "NAPTR",
714            "content": "100 10 U E2U+sip !^.*$! .", "ttl": 300,
715            "data": {
716                "order": 100, "preference": 10,
717                "flags": "U", "service": "E2U+sip",
718                "regexp": "!^.*$!", "replacement": "."
719            }
720        });
721        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
722        assert_eq!(rec.record_type, "NAPTR");
723        assert_eq!(rec.data["naptrOrder"], 100);
724        assert_eq!(rec.data["naptrServices"], "E2U+sip");
725        assert_eq!(rec.data["naptrFlags"], "U");
726    }
727
728    #[test]
729    fn uri_record_normalization() {
730        let cf = json!({
731            "id": "abc", "name": "example.com", "type": "URI",
732            "content": "10 1 https://example.com", "ttl": 300,
733            "data": { "priority": 10, "weight": 1, "content": "https://example.com" }
734        });
735        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
736        assert_eq!(rec.record_type, "URI");
737        assert_eq!(rec.data["uriPriority"], 10);
738        assert_eq!(rec.data["uriWeight"], 1);
739        assert_eq!(rec.data["uri"], "https://example.com");
740    }
741
742    #[test]
743    fn proxied_flag_preserved_in_data() {
744        let cf = json!({
745            "id": "abc", "name": "www.example.com", "type": "A",
746            "content": "1.2.3.4", "ttl": 1, "proxied": true
747        });
748        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
749        assert_eq!(rec.data["proxied"], true);
750    }
751
752    #[test]
753    fn record_id_preserved_in_data() {
754        let cf = json!({
755            "id": "record-id-xyz", "name": "www.example.com", "type": "A",
756            "content": "1.2.3.4", "ttl": 300, "proxied": false
757        });
758        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
759        assert_eq!(rec.data["id"], "record-id-xyz");
760    }
761
762    // ── record_data_to_cloudflare_body ────────────────────────────────────────
763
764    #[test]
765    fn a_record_body() {
766        let record = RecordData::A {
767            ip: "1.2.3.4".parse().unwrap(),
768        };
769        let body = record_data_to_cloudflare_body("www.example.com", 300, &record);
770        assert_eq!(body["type"], "A");
771        assert_eq!(body["content"], "1.2.3.4");
772        assert_eq!(body["ttl"], 300);
773        assert_eq!(body["proxied"], false);
774    }
775
776    #[test]
777    fn mx_record_body() {
778        let record = RecordData::Mx {
779            preference: 10,
780            exchange: "mail.example.com".into(),
781        };
782        let body = record_data_to_cloudflare_body("example.com", 300, &record);
783        assert_eq!(body["type"], "MX");
784        assert_eq!(body["content"], "mail.example.com");
785        assert_eq!(body["priority"], 10);
786    }
787
788    #[test]
789    fn aaaa_record_body() {
790        let record = RecordData::Aaaa {
791            ip: "2001:db8::1".parse().unwrap(),
792        };
793        let body = record_data_to_cloudflare_body("www.example.com", 300, &record);
794        assert_eq!(body["type"], "AAAA");
795        assert_eq!(body["content"], "2001:db8::1");
796        assert_eq!(body["ttl"], 300);
797        assert_eq!(body["proxied"], false);
798    }
799
800    #[test]
801    fn srv_record_body_uses_data_object() {
802        let record = RecordData::Srv {
803            priority: 10,
804            weight: 20,
805            port: 5060,
806            target: "sip.example.com".into(),
807        };
808        let body = record_data_to_cloudflare_body("_sip._tcp.example.com", 300, &record);
809        assert_eq!(body["type"], "SRV");
810        assert_eq!(body["data"]["priority"], 10);
811        assert_eq!(body["data"]["port"], 5060);
812    }
813
814    #[test]
815    fn dname_record_body() {
816        let record = RecordData::Dname {
817            dname: "other.example.com".into(),
818        };
819        let body = record_data_to_cloudflare_body("example.com", 300, &record);
820        assert_eq!(body["type"], "DNAME");
821        assert_eq!(body["content"], "other.example.com");
822    }
823
824    #[test]
825    fn sshfp_record_body() {
826        use crate::core::dns::records::{SshfpAlgorithm, SshfpFingerprintType};
827        let record = RecordData::Sshfp {
828            algorithm: SshfpAlgorithm::Rsa,
829            fingerprint_type: SshfpFingerprintType::Sha256,
830            fingerprint: "abcdef".into(),
831        };
832        let body = record_data_to_cloudflare_body("example.com", 300, &record);
833        assert_eq!(body["type"], "SSHFP");
834        assert_eq!(body["data"]["algorithm"], 1);
835        assert_eq!(body["data"]["type"], 2);
836        assert_eq!(body["data"]["fingerprint"], "abcdef");
837    }
838
839    #[test]
840    fn tlsa_record_body() {
841        use crate::core::dns::records::{TlsaCertUsage, TlsaMatchingType, TlsaSelector};
842        let record = RecordData::Tlsa {
843            cert_usage: TlsaCertUsage::DaneEe,
844            selector: TlsaSelector::Spki,
845            matching_type: TlsaMatchingType::Sha2_256,
846            cert_association_data: "deadbeef".into(),
847        };
848        let body = record_data_to_cloudflare_body("_443._tcp.example.com", 300, &record);
849        assert_eq!(body["type"], "TLSA");
850        assert_eq!(body["data"]["usage"], 3);
851        assert_eq!(body["data"]["selector"], 1);
852        assert_eq!(body["data"]["matching_type"], 1);
853        assert_eq!(body["data"]["certificate"], "deadbeef");
854    }
855
856    #[test]
857    fn ds_record_body() {
858        use crate::core::dns::records::{DigestType, DsAlgorithm};
859        let record = RecordData::Ds {
860            key_tag: 1234,
861            algorithm: DsAlgorithm::Ecdsap256sha256,
862            digest_type: DigestType::Sha256,
863            digest: "abcdef".into(),
864        };
865        let body = record_data_to_cloudflare_body("example.com", 300, &record);
866        assert_eq!(body["type"], "DS");
867        assert_eq!(body["data"]["key_tag"], 1234);
868        assert_eq!(body["data"]["algorithm"], 13);
869        assert_eq!(body["data"]["digest_type"], 2);
870        assert_eq!(body["data"]["digest"], "abcdef");
871    }
872
873    #[test]
874    fn https_record_body() {
875        let record = RecordData::Https {
876            svc_priority: 1,
877            svc_target_name: ".".into(),
878            svc_params: Some("alpn=h2".into()),
879            auto_ipv4_hint: false,
880            auto_ipv6_hint: false,
881        };
882        let body = record_data_to_cloudflare_body("example.com", 300, &record);
883        assert_eq!(body["type"], "HTTPS");
884        assert_eq!(body["data"]["priority"], 1);
885        assert_eq!(body["data"]["target"], ".");
886        assert_eq!(body["data"]["value"], "alpn=h2");
887    }
888
889    #[test]
890    fn naptr_record_body() {
891        let record = RecordData::Naptr {
892            order: 100,
893            preference: 10,
894            flags: "U".into(),
895            services: "E2U+sip".into(),
896            regexp: "!^.*$!".into(),
897            replacement: ".".into(),
898        };
899        let body = record_data_to_cloudflare_body("example.com", 300, &record);
900        assert_eq!(body["type"], "NAPTR");
901        assert_eq!(body["data"]["order"], 100);
902        assert_eq!(body["data"]["service"], "E2U+sip");
903        assert_eq!(body["data"]["flags"], "U");
904    }
905
906    #[test]
907    fn uri_record_body() {
908        let record = RecordData::Uri {
909            priority: 10,
910            weight: 1,
911            uri: "https://example.com".into(),
912        };
913        let body = record_data_to_cloudflare_body("example.com", 300, &record);
914        assert_eq!(body["type"], "URI");
915        assert_eq!(body["data"]["priority"], 10);
916        assert_eq!(body["data"]["weight"], 1);
917        assert_eq!(body["data"]["content"], "https://example.com");
918    }
919
920    #[test]
921    fn expected_content_extracts_value_for_simple_types() {
922        let params = vec![
923            ("type", "A".to_string()),
924            ("ipAddress", "1.2.3.4".to_string()),
925        ];
926        assert_eq!(expected_cloudflare_content("A", &params), Some("1.2.3.4"));
927
928        let params = vec![
929            ("type", "CNAME".to_string()),
930            ("cname", "x.example.com".to_string()),
931        ];
932        assert_eq!(
933            expected_cloudflare_content("CNAME", &params),
934            Some("x.example.com")
935        );
936
937        let params = vec![("type", "TXT".to_string()), ("text", "v=spf1".to_string())];
938        assert_eq!(expected_cloudflare_content("TXT", &params), Some("v=spf1"));
939    }
940
941    #[test]
942    fn expected_content_returns_none_for_structured_types() {
943        let params = vec![
944            ("type", "MX".to_string()),
945            ("preference", "10".to_string()),
946            ("exchange", "mail.example.com".to_string()),
947        ];
948        assert_eq!(expected_cloudflare_content("MX", &params), None);
949
950        let params = vec![("type", "SRV".to_string())];
951        assert_eq!(expected_cloudflare_content("SRV", &params), None);
952    }
953}