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::records::RecordData;
24use crate::core::dns::responses::{ListRecordsResponse, ZoneInfo, ZoneRecord};
25use crate::core::dns::logs::{LogLine, LogsOptions, LogsRead};
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) => {
222                    r.get("content").and_then(|c| c.as_str()) == Some(expected)
223                }
224                None => true,
225            })
226            .ok_or_else(|| Error::Api {
227                message: match expected_content {
228                    Some(value) => {
229                        format!("no {record_type} record '{fqdn}' with value '{value}' found")
230                    }
231                    None => format!("no {record_type} record found for '{fqdn}'"),
232                },
233            })?;
234
235        let record_id = matched
236            .get("id")
237            .and_then(|id| id.as_str())
238            .ok_or_else(|| Error::parse("Cloudflare dns_records entry missing id"))?
239            .to_owned();
240
241        self.delete(&format!("/zones/{zone_id}/dns_records/{record_id}"))
242            .await
243    }
244}
245
246/// Returns the value Cloudflare stores in the `content` field for the given
247/// record type, looked up from the canonical `type_params` API payload.
248/// Returns `None` for record types whose value lives in a structured `data`
249/// object (MX, SRV, CAA, …) — those fall back to first-match behaviour.
250fn expected_cloudflare_content<'a>(
251    record_type: &str,
252    type_params: &'a [(&'a str, String)],
253) -> Option<&'a str> {
254    let key = match record_type {
255        "A" | "AAAA" => "ipAddress",
256        "CNAME" => "cname",
257        "NS" => "nameserver",
258        "TXT" => "text",
259        "PTR" => "name",
260        "DNAME" => "dname",
261        _ => return None,
262    };
263    type_params
264        .iter()
265        .find(|(k, _)| *k == key)
266        .map(|(_, v)| v.as_str())
267}
268
269// ─── Unsupported operations ───────────────────────────────────────────────────
270
271impl CacheRead for CloudflareClient {
272    async fn list_cache<'a>(&'a self, _domain: &'a str) -> Result<Value> {
273        Err(Error::unsupported("Cloudflare", "cache listing"))
274    }
275}
276
277impl CacheWrite for CloudflareClient {
278    async fn delete_cache_zone<'a>(&'a self, _domain: &'a str) -> Result<Value> {
279        Err(Error::unsupported("Cloudflare", "cache zone deletion"))
280    }
281
282    async fn flush_cache(&self) -> Result<Value> {
283        Err(Error::unsupported("Cloudflare", "cache flush"))
284    }
285}
286
287impl StatsRead for CloudflareClient {
288    async fn get_stats<'a>(&'a self, _stats_type: &'a str) -> Result<Value> {
289        Err(Error::unsupported("Cloudflare", "stats"))
290    }
291}
292
293impl AccessListRead for CloudflareClient {
294    async fn list_blocked(&self) -> Result<Value> {
295        Err(Error::unsupported("Cloudflare", "blocked list"))
296    }
297
298    async fn list_allowed(&self) -> Result<Value> {
299        Err(Error::unsupported("Cloudflare", "allowed list"))
300    }
301}
302
303impl AccessListWrite for CloudflareClient {
304    async fn add_blocked<'a>(&'a self, _domain: &'a str) -> Result<Value> {
305        Err(Error::unsupported("Cloudflare", "add blocked"))
306    }
307
308    async fn delete_blocked<'a>(&'a self, _domain: &'a str) -> Result<Value> {
309        Err(Error::unsupported("Cloudflare", "delete blocked"))
310    }
311
312    async fn add_allowed<'a>(&'a self, _domain: &'a str) -> Result<Value> {
313        Err(Error::unsupported("Cloudflare", "add allowed"))
314    }
315
316    async fn delete_allowed<'a>(&'a self, _domain: &'a str) -> Result<Value> {
317        Err(Error::unsupported("Cloudflare", "delete allowed"))
318    }
319}
320
321impl ZoneImport for CloudflareClient {
322    #[instrument(
323        skip(self, file_bytes),
324        fields(vendor = "cloudflare", operation = "import_zone_file")
325    )]
326    async fn import_zone_file<'a>(
327        &'a self,
328        zone: &'a str,
329        file_name: String,
330        file_bytes: Vec<u8>,
331        overwrite: bool,
332        overwrite_zone: bool,
333        _overwrite_soa_serial: bool,
334    ) -> Result<Value> {
335        if overwrite_zone {
336            tracing::warn!(
337                "overwrite_zone is not supported by Cloudflare — import will be additive; \
338                 delete records manually first if a clean replace is needed"
339            );
340        }
341        if !overwrite {
342            tracing::warn!(
343                "overwrite=false is not supported by Cloudflare — \
344                 existing records will still be updated by the import"
345            );
346        }
347        let zone_id = self.resolve_zone_id(zone).await?;
348        self.post_multipart(
349            &format!("/zones/{zone_id}/dns_records/import"),
350            file_name,
351            file_bytes,
352        )
353        .await
354    }
355}
356
357impl ZoneExport for CloudflareClient {
358    #[instrument(
359        skip(self),
360        fields(vendor = "cloudflare", operation = "export_zone_file")
361    )]
362    async fn export_zone_file<'a>(&'a self, zone: &'a str) -> Result<String> {
363        let zone_id = self.resolve_zone_id(zone).await?;
364        self.get_text(&format!("/zones/{zone_id}/dns_records/export"), &[])
365            .await
366    }
367}
368
369impl SettingsRead for CloudflareClient {
370    #[instrument(skip(self), fields(vendor = "cloudflare", operation = "get_settings"))]
371    async fn get_settings(&self) -> Result<Value> {
372        self.get("/user/tokens/verify", &[]).await
373    }
374}
375
376impl LogsRead for CloudflareClient {
377    async fn get_logs(&self, _: LogsOptions) -> Result<Vec<LogLine>> {
378        Err(Error::unsupported("Cloudflare", "logs"))
379    }
380}
381
382// ─── Tests ────────────────────────────────────────────────────────────────────
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use serde_json::json;
388
389    fn make_client() -> CloudflareClient {
390        CloudflareClient::new(
391            "https://api.cloudflare.com/client/v4".to_string(),
392            crate::core::secret::ApiToken::new("test-token"),
393        )
394        .unwrap()
395    }
396
397    // ── VendorKind / capabilities ─────────────────────────────────────────────
398
399    #[test]
400    fn kind_returns_cloudflare() {
401        let client = make_client();
402        assert_eq!(client.kind(), VendorKind::Cloudflare);
403    }
404
405    #[test]
406    fn capabilities_match_supported_operations() {
407        let caps = make_client().capabilities();
408        assert!(caps.zones);
409        assert!(caps.records);
410        assert!(!caps.cache);
411        assert!(!caps.access_lists);
412        assert!(caps.settings);
413        assert!(caps.zone_import);
414        assert!(caps.zone_export);
415        assert!(!caps.logs);
416    }
417
418    #[tokio::test]
419    async fn get_logs_is_unsupported() {
420        use crate::core::dns::logs::LogsOptions;
421        let err = make_client().get_logs(LogsOptions::default()).await.unwrap_err();
422        assert!(matches!(err, Error::Unsupported { vendor: "Cloudflare", .. }));
423    }
424
425    // ── Unsupported operations return correct error ────────────────────────────
426
427    #[tokio::test]
428    async fn enable_zone_is_unsupported() {
429        let err = make_client().enable_zone("example.com").await.unwrap_err();
430        assert!(matches!(
431            err,
432            Error::Unsupported {
433                vendor: "Cloudflare",
434                ..
435            }
436        ));
437    }
438
439    #[tokio::test]
440    async fn disable_zone_is_unsupported() {
441        let err = make_client().disable_zone("example.com").await.unwrap_err();
442        assert!(matches!(
443            err,
444            Error::Unsupported {
445                vendor: "Cloudflare",
446                ..
447            }
448        ));
449    }
450
451    #[tokio::test]
452    async fn list_cache_is_unsupported() {
453        let err = make_client().list_cache("example.com").await.unwrap_err();
454        assert!(matches!(
455            err,
456            Error::Unsupported {
457                vendor: "Cloudflare",
458                ..
459            }
460        ));
461    }
462
463    #[tokio::test]
464    async fn flush_cache_is_unsupported() {
465        let err = make_client().flush_cache().await.unwrap_err();
466        assert!(matches!(
467            err,
468            Error::Unsupported {
469                vendor: "Cloudflare",
470                ..
471            }
472        ));
473    }
474
475    #[tokio::test]
476    async fn get_stats_is_unsupported() {
477        let err = make_client().get_stats("last7days").await.unwrap_err();
478        assert!(matches!(
479            err,
480            Error::Unsupported {
481                vendor: "Cloudflare",
482                ..
483            }
484        ));
485    }
486
487    #[tokio::test]
488    async fn list_blocked_is_unsupported() {
489        let err = make_client().list_blocked().await.unwrap_err();
490        assert!(matches!(
491            err,
492            Error::Unsupported {
493                vendor: "Cloudflare",
494                ..
495            }
496        ));
497    }
498
499    #[tokio::test]
500    async fn zone_import_attempts_api_call_with_default_flags() {
501        // overwrite=true, overwrite_zone=false — network error confirms it reaches the API
502        let err = make_client()
503            .import_zone_file("example.com", "zone.txt".into(), vec![], true, false, false)
504            .await
505            .unwrap_err();
506        assert!(!matches!(err, Error::Unsupported { .. }));
507    }
508
509    #[tokio::test]
510    async fn zone_import_overwrite_zone_warns_and_proceeds() {
511        // overwrite_zone=true emits a warning but still reaches the API (not an error)
512        let err = make_client()
513            .import_zone_file("example.com", "zone.txt".into(), vec![], true, true, false)
514            .await
515            .unwrap_err();
516        assert!(!matches!(err, Error::Unsupported { .. }));
517    }
518
519    #[tokio::test]
520    async fn zone_import_no_overwrite_warns_and_proceeds() {
521        // overwrite=false emits a warning but still reaches the API (not an error)
522        let err = make_client()
523            .import_zone_file(
524                "example.com",
525                "zone.txt".into(),
526                vec![],
527                false,
528                false,
529                false,
530            )
531            .await
532            .unwrap_err();
533        assert!(!matches!(err, Error::Unsupported { .. }));
534    }
535
536    // ── Record normalization ──────────────────────────────────────────────────
537
538    #[test]
539    fn a_record_normalization() {
540        let cf = json!({
541            "id": "abc", "name": "www.example.com", "type": "A",
542            "content": "1.2.3.4", "ttl": 300, "proxied": false
543        });
544        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
545        assert_eq!(rec.name, "www");
546        assert_eq!(rec.record_type, "A");
547        assert_eq!(rec.ttl, 300);
548        assert!(!rec.disabled);
549        assert_eq!(rec.data["ipAddress"], "1.2.3.4");
550        assert_eq!(rec.data["proxied"], false);
551    }
552
553    #[test]
554    fn apex_record_name_becomes_at() {
555        let cf = json!({
556            "id": "abc", "name": "example.com", "type": "A",
557            "content": "1.2.3.4", "ttl": 300, "proxied": false
558        });
559        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
560        assert_eq!(rec.name, "@");
561    }
562
563    #[test]
564    fn mx_record_normalization() {
565        let cf = json!({
566            "id": "abc", "name": "example.com", "type": "MX",
567            "content": "mail.example.com", "priority": 10, "ttl": 300
568        });
569        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
570        assert_eq!(rec.record_type, "MX");
571        assert_eq!(rec.data["preference"], 10);
572        assert_eq!(rec.data["exchange"], "mail.example.com");
573    }
574
575    #[test]
576    fn txt_record_normalization() {
577        let cf = json!({
578            "id": "abc", "name": "example.com", "type": "TXT",
579            "content": "v=spf1 ~all", "ttl": 300
580        });
581        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
582        assert_eq!(rec.data["text"], "v=spf1 ~all");
583        assert_eq!(rec.data["splitText"], false);
584    }
585
586    #[test]
587    fn cname_record_normalization() {
588        let cf = json!({
589            "id": "abc", "name": "www.example.com", "type": "CNAME",
590            "content": "example.com", "ttl": 300, "proxied": false
591        });
592        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
593        assert_eq!(rec.data["cname"], "example.com");
594    }
595
596    #[test]
597    fn srv_record_normalization() {
598        let cf = json!({
599            "id": "abc", "name": "_sip._tcp.example.com", "type": "SRV",
600            "data": { "priority": 10, "weight": 20, "port": 5060, "target": "sip.example.com" },
601            "ttl": 300
602        });
603        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
604        assert_eq!(rec.record_type, "SRV");
605        assert_eq!(rec.data["priority"], 10);
606        assert_eq!(rec.data["weight"], 20);
607        assert_eq!(rec.data["port"], 5060);
608        assert_eq!(rec.data["target"], "sip.example.com");
609    }
610
611    #[test]
612    fn unknown_type_falls_back_to_value_field() {
613        let cf = json!({
614            "id": "abc", "name": "example.com", "type": "LOC",
615            "content": "51 30 0.000 N 0 7 0.000 W 0m", "ttl": 300
616        });
617        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
618        assert_eq!(rec.record_type, "LOC");
619        assert!(rec.data.get("value").is_some());
620    }
621
622    #[test]
623    fn aaaa_record_normalization() {
624        let cf = json!({
625            "id": "abc", "name": "www.example.com", "type": "AAAA",
626            "content": "2001:db8::1", "ttl": 300, "proxied": false
627        });
628        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
629        assert_eq!(rec.name, "www");
630        assert_eq!(rec.record_type, "AAAA");
631        assert_eq!(rec.data["ipAddress"], "2001:db8::1");
632    }
633
634    #[test]
635    fn dname_record_normalization() {
636        let cf = json!({
637            "id": "abc", "name": "example.com", "type": "DNAME",
638            "content": "other.example.com", "ttl": 300
639        });
640        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
641        assert_eq!(rec.record_type, "DNAME");
642        assert_eq!(rec.data["dname"], "other.example.com");
643    }
644
645    #[test]
646    fn sshfp_record_normalization() {
647        let cf = json!({
648            "id": "abc", "name": "example.com", "type": "SSHFP",
649            "content": "1 2 abcdef", "ttl": 300,
650            "data": { "algorithm": 1, "type": 2, "fingerprint": "abcdef" }
651        });
652        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
653        assert_eq!(rec.record_type, "SSHFP");
654        assert_eq!(rec.data["sshfpAlgorithm"], "RSA");
655        assert_eq!(rec.data["sshfpFingerprintType"], "SHA256");
656        assert_eq!(rec.data["sshfpFingerprint"], "abcdef");
657    }
658
659    #[test]
660    fn tlsa_record_normalization() {
661        let cf = json!({
662            "id": "abc", "name": "_443._tcp.example.com", "type": "TLSA",
663            "content": "3 1 1 deadbeef", "ttl": 300,
664            "data": { "usage": 3, "selector": 1, "matching_type": 1, "certificate": "deadbeef" }
665        });
666        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
667        assert_eq!(rec.record_type, "TLSA");
668        assert_eq!(rec.data["tlsaCertificateUsage"], "DANE-EE");
669        assert_eq!(rec.data["tlsaSelector"], "SPKI");
670        assert_eq!(rec.data["tlsaMatchingType"], "SHA2-256");
671        assert_eq!(rec.data["tlsaCertificateAssociationData"], "deadbeef");
672    }
673
674    #[test]
675    fn ds_record_normalization() {
676        let cf = json!({
677            "id": "abc", "name": "example.com", "type": "DS",
678            "content": "1234 13 2 abcdef", "ttl": 300,
679            "data": { "key_tag": 1234, "algorithm": 13, "digest_type": 2, "digest": "abcdef" }
680        });
681        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
682        assert_eq!(rec.record_type, "DS");
683        assert_eq!(rec.data["keyTag"], 1234);
684        assert_eq!(rec.data["algorithm"], "ECDSAP256SHA256");
685        assert_eq!(rec.data["digestType"], "SHA256");
686        assert_eq!(rec.data["digest"], "abcdef");
687    }
688
689    #[test]
690    fn https_record_normalization() {
691        let cf = json!({
692            "id": "abc", "name": "example.com", "type": "HTTPS",
693            "content": "1 . alpn=h2", "ttl": 300,
694            "data": { "priority": 1, "target": ".", "value": "alpn=h2" }
695        });
696        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
697        assert_eq!(rec.record_type, "HTTPS");
698        assert_eq!(rec.data["svcPriority"], 1);
699        assert_eq!(rec.data["svcTargetName"], ".");
700        assert_eq!(rec.data["svcParams"], "alpn=h2");
701    }
702
703    #[test]
704    fn naptr_record_normalization() {
705        let cf = json!({
706            "id": "abc", "name": "example.com", "type": "NAPTR",
707            "content": "100 10 U E2U+sip !^.*$! .", "ttl": 300,
708            "data": {
709                "order": 100, "preference": 10,
710                "flags": "U", "service": "E2U+sip",
711                "regexp": "!^.*$!", "replacement": "."
712            }
713        });
714        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
715        assert_eq!(rec.record_type, "NAPTR");
716        assert_eq!(rec.data["naptrOrder"], 100);
717        assert_eq!(rec.data["naptrServices"], "E2U+sip");
718        assert_eq!(rec.data["naptrFlags"], "U");
719    }
720
721    #[test]
722    fn uri_record_normalization() {
723        let cf = json!({
724            "id": "abc", "name": "example.com", "type": "URI",
725            "content": "10 1 https://example.com", "ttl": 300,
726            "data": { "priority": 10, "weight": 1, "content": "https://example.com" }
727        });
728        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
729        assert_eq!(rec.record_type, "URI");
730        assert_eq!(rec.data["uriPriority"], 10);
731        assert_eq!(rec.data["uriWeight"], 1);
732        assert_eq!(rec.data["uri"], "https://example.com");
733    }
734
735    #[test]
736    fn proxied_flag_preserved_in_data() {
737        let cf = json!({
738            "id": "abc", "name": "www.example.com", "type": "A",
739            "content": "1.2.3.4", "ttl": 1, "proxied": true
740        });
741        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
742        assert_eq!(rec.data["proxied"], true);
743    }
744
745    #[test]
746    fn record_id_preserved_in_data() {
747        let cf = json!({
748            "id": "record-id-xyz", "name": "www.example.com", "type": "A",
749            "content": "1.2.3.4", "ttl": 300, "proxied": false
750        });
751        let rec = cloudflare_record_to_zone_record(&cf, "example.com");
752        assert_eq!(rec.data["id"], "record-id-xyz");
753    }
754
755    // ── extract_relative_name ─────────────────────────────────────────────────
756
757    #[test]
758    fn subdomain_is_extracted() {
759        assert_eq!(
760            extract_relative_name("sub.example.com", "example.com"),
761            "sub"
762        );
763    }
764
765    #[test]
766    fn apex_returns_at() {
767        assert_eq!(extract_relative_name("example.com", "example.com"), "@");
768    }
769
770    #[test]
771    fn non_matching_fqdn_returned_as_is() {
772        assert_eq!(
773            extract_relative_name("other.net", "example.com"),
774            "other.net"
775        );
776    }
777
778    // ── record_data_to_cloudflare_body ────────────────────────────────────────
779
780    #[test]
781    fn a_record_body() {
782        let record = RecordData::A {
783            ip: "1.2.3.4".parse().unwrap(),
784        };
785        let body = record_data_to_cloudflare_body("www.example.com", 300, &record);
786        assert_eq!(body["type"], "A");
787        assert_eq!(body["content"], "1.2.3.4");
788        assert_eq!(body["ttl"], 300);
789        assert_eq!(body["proxied"], false);
790    }
791
792    #[test]
793    fn mx_record_body() {
794        let record = RecordData::Mx {
795            preference: 10,
796            exchange: "mail.example.com".into(),
797        };
798        let body = record_data_to_cloudflare_body("example.com", 300, &record);
799        assert_eq!(body["type"], "MX");
800        assert_eq!(body["content"], "mail.example.com");
801        assert_eq!(body["priority"], 10);
802    }
803
804    #[test]
805    fn aaaa_record_body() {
806        let record = RecordData::Aaaa {
807            ip: "2001:db8::1".parse().unwrap(),
808        };
809        let body = record_data_to_cloudflare_body("www.example.com", 300, &record);
810        assert_eq!(body["type"], "AAAA");
811        assert_eq!(body["content"], "2001:db8::1");
812        assert_eq!(body["ttl"], 300);
813        assert_eq!(body["proxied"], false);
814    }
815
816    #[test]
817    fn srv_record_body_uses_data_object() {
818        let record = RecordData::Srv {
819            priority: 10,
820            weight: 20,
821            port: 5060,
822            target: "sip.example.com".into(),
823        };
824        let body = record_data_to_cloudflare_body("_sip._tcp.example.com", 300, &record);
825        assert_eq!(body["type"], "SRV");
826        assert_eq!(body["data"]["priority"], 10);
827        assert_eq!(body["data"]["port"], 5060);
828    }
829
830    #[test]
831    fn dname_record_body() {
832        let record = RecordData::Dname {
833            dname: "other.example.com".into(),
834        };
835        let body = record_data_to_cloudflare_body("example.com", 300, &record);
836        assert_eq!(body["type"], "DNAME");
837        assert_eq!(body["content"], "other.example.com");
838    }
839
840    #[test]
841    fn sshfp_record_body() {
842        use crate::core::dns::records::{SshfpAlgorithm, SshfpFingerprintType};
843        let record = RecordData::Sshfp {
844            algorithm: SshfpAlgorithm::Rsa,
845            fingerprint_type: SshfpFingerprintType::Sha256,
846            fingerprint: "abcdef".into(),
847        };
848        let body = record_data_to_cloudflare_body("example.com", 300, &record);
849        assert_eq!(body["type"], "SSHFP");
850        assert_eq!(body["data"]["algorithm"], 1);
851        assert_eq!(body["data"]["type"], 2);
852        assert_eq!(body["data"]["fingerprint"], "abcdef");
853    }
854
855    #[test]
856    fn tlsa_record_body() {
857        use crate::core::dns::records::{TlsaCertUsage, TlsaMatchingType, TlsaSelector};
858        let record = RecordData::Tlsa {
859            cert_usage: TlsaCertUsage::DaneEe,
860            selector: TlsaSelector::Spki,
861            matching_type: TlsaMatchingType::Sha2_256,
862            cert_association_data: "deadbeef".into(),
863        };
864        let body = record_data_to_cloudflare_body("_443._tcp.example.com", 300, &record);
865        assert_eq!(body["type"], "TLSA");
866        assert_eq!(body["data"]["usage"], 3);
867        assert_eq!(body["data"]["selector"], 1);
868        assert_eq!(body["data"]["matching_type"], 1);
869        assert_eq!(body["data"]["certificate"], "deadbeef");
870    }
871
872    #[test]
873    fn ds_record_body() {
874        use crate::core::dns::records::{DigestType, DsAlgorithm};
875        let record = RecordData::Ds {
876            key_tag: 1234,
877            algorithm: DsAlgorithm::Ecdsap256sha256,
878            digest_type: DigestType::Sha256,
879            digest: "abcdef".into(),
880        };
881        let body = record_data_to_cloudflare_body("example.com", 300, &record);
882        assert_eq!(body["type"], "DS");
883        assert_eq!(body["data"]["key_tag"], 1234);
884        assert_eq!(body["data"]["algorithm"], 13);
885        assert_eq!(body["data"]["digest_type"], 2);
886        assert_eq!(body["data"]["digest"], "abcdef");
887    }
888
889    #[test]
890    fn https_record_body() {
891        let record = RecordData::Https {
892            svc_priority: 1,
893            svc_target_name: ".".into(),
894            svc_params: Some("alpn=h2".into()),
895            auto_ipv4_hint: false,
896            auto_ipv6_hint: false,
897        };
898        let body = record_data_to_cloudflare_body("example.com", 300, &record);
899        assert_eq!(body["type"], "HTTPS");
900        assert_eq!(body["data"]["priority"], 1);
901        assert_eq!(body["data"]["target"], ".");
902        assert_eq!(body["data"]["value"], "alpn=h2");
903    }
904
905    #[test]
906    fn naptr_record_body() {
907        let record = RecordData::Naptr {
908            order: 100,
909            preference: 10,
910            flags: "U".into(),
911            services: "E2U+sip".into(),
912            regexp: "!^.*$!".into(),
913            replacement: ".".into(),
914        };
915        let body = record_data_to_cloudflare_body("example.com", 300, &record);
916        assert_eq!(body["type"], "NAPTR");
917        assert_eq!(body["data"]["order"], 100);
918        assert_eq!(body["data"]["service"], "E2U+sip");
919        assert_eq!(body["data"]["flags"], "U");
920    }
921
922    #[test]
923    fn uri_record_body() {
924        let record = RecordData::Uri {
925            priority: 10,
926            weight: 1,
927            uri: "https://example.com".into(),
928        };
929        let body = record_data_to_cloudflare_body("example.com", 300, &record);
930        assert_eq!(body["type"], "URI");
931        assert_eq!(body["data"]["priority"], 10);
932        assert_eq!(body["data"]["weight"], 1);
933        assert_eq!(body["data"]["content"], "https://example.com");
934    }
935
936    #[test]
937    fn expected_content_extracts_value_for_simple_types() {
938        let params = vec![("type", "A".to_string()), ("ipAddress", "1.2.3.4".to_string())];
939        assert_eq!(expected_cloudflare_content("A", &params), Some("1.2.3.4"));
940
941        let params = vec![("type", "CNAME".to_string()), ("cname", "x.example.com".to_string())];
942        assert_eq!(
943            expected_cloudflare_content("CNAME", &params),
944            Some("x.example.com")
945        );
946
947        let params = vec![("type", "TXT".to_string()), ("text", "v=spf1".to_string())];
948        assert_eq!(expected_cloudflare_content("TXT", &params), Some("v=spf1"));
949    }
950
951    #[test]
952    fn expected_content_returns_none_for_structured_types() {
953        let params = vec![
954            ("type", "MX".to_string()),
955            ("preference", "10".to_string()),
956            ("exchange", "mail.example.com".to_string()),
957        ];
958        assert_eq!(expected_cloudflare_content("MX", &params), None);
959
960        let params = vec![("type", "SRV".to_string())];
961        assert_eq!(expected_cloudflare_content("SRV", &params), None);
962    }
963}