1use 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
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) => {
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
246fn 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
269impl 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#[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 #[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 #[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 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 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 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 #[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 #[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 #[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", ¶ms), 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", ¶ms),
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", ¶ms), 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", ¶ms), None);
959
960 let params = vec![("type", "SRV".to_string())];
961 assert_eq!(expected_cloudflare_content("SRV", ¶ms), None);
962 }
963}