1use 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
34impl 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
55impl 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
76impl 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
126impl 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
153impl 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 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
244fn 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
267impl 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#[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 #[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 #[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 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 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 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 #[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 #[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", ¶ms), 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", ¶ms),
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", ¶ms), 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", ¶ms), None);
949
950 let params = vec![("type", "SRV".to_string())];
951 assert_eq!(expected_cloudflare_content("SRV", ¶ms), None);
952 }
953}