1use std::{future::Future, time::Duration};
7
8use hickory_resolver::{Resolver, net::runtime::TokioRuntimeProvider, proto::rr::RecordType};
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12use crate::{
13 control_plane::config::ValidationEndpointConfig,
14 core::dns::{
15 records::RecordData,
16 resolver::{ResolverTarget, build_resolver, classify_hickory_error},
17 responses::{AnyRecordData, ListRecordsResponse, ZoneRecord},
18 },
19};
20
21fn default_enabled() -> bool {
22 true
23}
24
25pub type DnsEndpointResolverResult<T> = std::result::Result<T, ValidationFailureKind>;
27
28pub trait DnsEndpointResolver {
33 fn query_endpoint<'a>(
35 &'a self,
36 endpoint: &'a ValidationEndpointConfig,
37 fqdn: &'a str,
38 record_type: &'a str,
39 timeout: Duration,
40 ) -> impl Future<Output = DnsEndpointResolverResult<Vec<ObservedRecord>>> + Send + 'a;
41}
42#[derive(Debug, Clone, Copy, Default)]
44pub struct HickoryDnsEndpointResolver;
45
46impl HickoryDnsEndpointResolver {
47 pub fn resolver_for_endpoint(
52 endpoint: &ValidationEndpointConfig,
53 timeout: Duration,
54 ) -> DnsEndpointResolverResult<Resolver<TokioRuntimeProvider>> {
55 let mut target = ResolverTarget::from_endpoint(endpoint);
56 target.timeout = timeout;
57 build_resolver(&target)
58 }
59}
60
61impl DnsEndpointResolver for HickoryDnsEndpointResolver {
62 fn query_endpoint<'a>(
63 &'a self,
64 endpoint: &'a ValidationEndpointConfig,
65 fqdn: &'a str,
66 record_type: &'a str,
67 timeout: Duration,
68 ) -> impl Future<Output = DnsEndpointResolverResult<Vec<ObservedRecord>>> + Send + 'a {
69 async move {
70 let rr_type = record_type
71 .parse::<RecordType>()
72 .map_err(|_| ValidationFailureKind::MalformedResponse)?;
73 let resolver = Self::resolver_for_endpoint(endpoint, timeout)?;
74
75 let lookup = tokio::time::timeout(timeout, resolver.lookup(fqdn, rr_type))
76 .await
77 .map_err(|_| ValidationFailureKind::Timeout)?
78 .map_err(|err| classify_hickory_error(endpoint.transport, &err.to_string()))?;
79
80 Ok(lookup
81 .answers()
82 .iter()
83 .map(|record| ObservedRecord {
84 name: record.name.to_string(),
85 record_type: record.record_type().to_string(),
86 ttl: Some(record.ttl),
87 values: vec![record.data.to_string()],
88 })
89 .collect())
90 }
91 }
92}
93
94#[must_use]
96pub fn endpoint_timeout(endpoint: &ValidationEndpointConfig) -> Duration {
97 Duration::from_millis(endpoint.timeout_ms.unwrap_or(5_000))
98}
99
100#[must_use]
102pub fn expected_records_from_response(
103 response: &ListRecordsResponse,
104) -> (Vec<ExpectedRecord>, Vec<SkippedRecord>) {
105 let mut expected = Vec::new();
106 let mut skipped = Vec::new();
107
108 for zone_records in &response.zones {
109 for record in &zone_records.records {
110 match expected_record_from_zone_record(&zone_records.zone.name, record) {
111 Ok(record) => expected.push(record),
112 Err(skip) => skipped.push(skip),
113 }
114 }
115 }
116
117 (expected, skipped)
118}
119
120#[must_use]
122pub fn compare_rrsets(
123 expected: &[ExpectedRecord],
124 observed: &[ObservedRecord],
125) -> Vec<RecordValidationResult> {
126 use std::collections::{BTreeMap, BTreeSet};
127
128 let expected_sets = expected.iter().fold(BTreeMap::new(), |mut acc, record| {
129 let key = normalized_rrset_key(&record.name, &record.record_type);
130 let values = normalize_values(&record.record_type, &record.values);
131 acc.entry(key).or_insert_with(BTreeSet::new).extend(values);
132 acc
133 });
134 let observed_sets = observed.iter().fold(BTreeMap::new(), |mut acc, record| {
135 let key = normalized_rrset_key(&record.name, &record.record_type);
136 let values = normalize_values(&record.record_type, &record.values);
137 acc.entry(key).or_insert_with(BTreeSet::new).extend(values);
138 acc
139 });
140
141 let mut results = Vec::new();
142 for ((name, record_type), expected_values) in &expected_sets {
143 let observed_values = observed_sets
144 .get(&(name.clone(), record_type.clone()))
145 .cloned()
146 .unwrap_or_default();
147
148 if observed_values.is_empty() {
149 results.push(mismatched_result(
150 name,
151 record_type,
152 expected_values,
153 &observed_values,
154 "missing",
155 ));
156 } else if expected_values == &observed_values {
157 results.push(RecordValidationResult {
158 name: name.clone(),
159 record_type: record_type.clone(),
160 status: ValidationStatus::Passed,
161 mismatch: None,
162 failure_kind: None,
163 skip_reason: None,
164 });
165 } else {
166 let mismatch_kind = if !expected_values.is_subset(&observed_values) {
167 "wrong_value"
168 } else {
169 "extra"
170 };
171 results.push(mismatched_result(
172 name,
173 record_type,
174 expected_values,
175 &observed_values,
176 mismatch_kind,
177 ));
178 }
179 }
180
181 for ((name, record_type), observed_values) in observed_sets {
182 if !expected_sets.contains_key(&(name.clone(), record_type.clone())) {
183 results.push(mismatched_result(
184 &name,
185 &record_type,
186 &BTreeSet::new(),
187 &observed_values,
188 "extra",
189 ));
190 }
191 }
192
193 results
194}
195
196fn expected_record_from_zone_record(
197 zone: &str,
198 record: &ZoneRecord,
199) -> std::result::Result<ExpectedRecord, SkippedRecord> {
200 let record_type = record.record_type.to_ascii_uppercase();
201 let name = normalize_domain_name(&fqdn_for_record(&record.name, zone));
202 let values = match record.parsed.as_ref() {
203 Some(AnyRecordData::Writable(data)) => values_from_record_data(data),
204 Some(AnyRecordData::ReadOnly(_)) | None => None,
205 };
206
207 match values {
208 Some(values) => Ok(ExpectedRecord {
209 name,
210 record_type,
211 values,
212 }),
213 None => Err(SkippedRecord {
214 name,
215 record_type,
216 reason: "unsupported_record_type".to_string(),
217 }),
218 }
219}
220
221fn values_from_record_data(record: &RecordData) -> Option<Vec<String>> {
222 match record {
223 RecordData::A { ip } => Some(vec![ip.to_string()]),
224 RecordData::Aaaa { ip } => Some(vec![ip.to_string()]),
225 RecordData::Cname { target } => Some(vec![target.clone()]),
226 RecordData::Txt { text, .. } => Some(vec![text.clone()]),
227 RecordData::Mx {
228 preference,
229 exchange,
230 } => Some(vec![format!("{preference} {exchange}")]),
231 RecordData::Ns { nameserver, .. } => Some(vec![nameserver.clone()]),
232 RecordData::Srv {
233 priority,
234 weight,
235 port,
236 target,
237 } => Some(vec![format!("{priority} {weight} {port} {target}")]),
238 RecordData::Caa { flags, tag, value } => Some(vec![format!("{flags} {tag} {value}")]),
239 _ => None,
240 }
241}
242
243fn mismatched_result(
244 name: &str,
245 record_type: &str,
246 expected: &std::collections::BTreeSet<String>,
247 observed: &std::collections::BTreeSet<String>,
248 mismatch_kind: &str,
249) -> RecordValidationResult {
250 RecordValidationResult {
251 name: name.to_string(),
252 record_type: record_type.to_string(),
253 status: ValidationStatus::Mismatched,
254 mismatch: Some(RecordMismatch {
255 name: name.to_string(),
256 record_type: record_type.to_string(),
257 expected: expected.iter().cloned().collect(),
258 observed: observed.iter().cloned().collect(),
259 mismatch_kind: mismatch_kind.to_string(),
260 }),
261 failure_kind: None,
262 skip_reason: None,
263 }
264}
265
266fn normalized_rrset_key(name: &str, record_type: &str) -> (String, String) {
267 (
268 normalize_domain_name(name),
269 record_type.trim().to_ascii_uppercase(),
270 )
271}
272
273fn normalize_values(record_type: &str, values: &[String]) -> std::collections::BTreeSet<String> {
274 values
275 .iter()
276 .map(|value| normalize_record_value(record_type, value))
277 .collect()
278}
279
280fn normalize_record_value(record_type: &str, value: &str) -> String {
281 let value = value.trim();
282 match record_type.to_ascii_uppercase().as_str() {
283 "CNAME" | "NS" => normalize_domain_name(value),
284 "MX" => normalize_priority_target(value),
285 "SRV" => normalize_srv(value),
286 "TXT" => normalize_txt(value),
287 "CAA" => normalize_caa(value),
288 _ => value.trim_end_matches('.').to_ascii_lowercase(),
289 }
290}
291
292fn normalize_domain_name(value: &str) -> String {
293 value.trim().trim_end_matches('.').to_ascii_lowercase()
294}
295
296fn normalize_priority_target(value: &str) -> String {
297 let mut parts = value.split_whitespace();
298 let preference = parts.next().unwrap_or_default();
299 let target = parts.next().unwrap_or_default();
300 format!("{} {}", preference, normalize_domain_name(target))
301}
302
303fn normalize_srv(value: &str) -> String {
304 let mut parts = value.split_whitespace();
305 let priority = parts.next().unwrap_or_default();
306 let weight = parts.next().unwrap_or_default();
307 let port = parts.next().unwrap_or_default();
308 let target = parts.next().unwrap_or_default();
309 format!(
310 "{} {} {} {}",
311 priority,
312 weight,
313 port,
314 normalize_domain_name(target)
315 )
316}
317
318fn normalize_txt(value: &str) -> String {
319 value
320 .trim()
321 .replace("\" \"", "")
322 .trim_matches('"')
323 .to_string()
324}
325
326fn normalize_caa(value: &str) -> String {
327 let mut parts = value.split_whitespace();
328 let flags = parts.next().unwrap_or_default();
329 let tag = parts.next().unwrap_or_default().to_ascii_lowercase();
330 let value = parts.collect::<Vec<_>>().join(" ");
331 format!("{flags} {tag} {value}")
332}
333
334fn fqdn_for_record(name: &str, zone: &str) -> String {
335 let name = name.trim_end_matches('.');
336 let zone = zone.trim_end_matches('.');
337 if name == "@" || name.eq_ignore_ascii_case(zone) {
338 zone.to_string()
339 } else if name
340 .to_ascii_lowercase()
341 .ends_with(&format!(".{}", zone.to_ascii_lowercase()))
342 {
343 name.to_string()
344 } else {
345 format!("{name}.{zone}")
346 }
347}
348
349#[cfg(test)]
351#[derive(Debug, Clone)]
352pub struct FakeDnsEndpointResolver {
353 result: DnsEndpointResolverResult<Vec<ObservedRecord>>,
354}
355
356#[cfg(test)]
357impl FakeDnsEndpointResolver {
358 pub fn with_records(records: Vec<ObservedRecord>) -> Self {
359 Self {
360 result: Ok(records),
361 }
362 }
363
364 pub fn with_failure(failure: ValidationFailureKind) -> Self {
365 Self {
366 result: Err(failure),
367 }
368 }
369}
370
371#[cfg(test)]
372impl DnsEndpointResolver for FakeDnsEndpointResolver {
373 fn query_endpoint(
374 &self,
375 _endpoint: &ValidationEndpointConfig,
376 _fqdn: &str,
377 _record_type: &str,
378 _timeout: Duration,
379 ) -> impl Future<Output = DnsEndpointResolverResult<Vec<ObservedRecord>>> + Send + '_ {
380 std::future::ready(self.result.clone())
381 }
382}
383
384#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
386#[serde(rename_all = "camelCase")]
387pub struct ValidationOptions {
388 #[serde(default = "default_enabled")]
389 pub enabled: bool,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub endpoint_filter: Option<Vec<String>>,
392}
393
394impl Default for ValidationOptions {
395 fn default() -> Self {
396 Self {
397 enabled: true,
398 endpoint_filter: None,
399 }
400 }
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
405#[serde(rename_all = "camelCase")]
406pub struct ValidationRequest {
407 pub zone: String,
408 #[serde(default, skip_serializing_if = "Option::is_none")]
409 pub domain: Option<String>,
410 #[serde(default)]
411 pub expected_records: Vec<ExpectedRecord>,
412 #[serde(default)]
413 pub options: ValidationOptions,
414}
415
416#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
418#[serde(rename_all = "camelCase")]
419pub struct ExpectedRecord {
420 pub name: String,
421 pub record_type: String,
422 pub values: Vec<String>,
423}
424
425#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
427#[serde(rename_all = "camelCase")]
428pub struct ObservedRecord {
429 pub name: String,
430 pub record_type: String,
431 #[serde(default, skip_serializing_if = "Option::is_none")]
432 pub ttl: Option<u32>,
433 pub values: Vec<String>,
434}
435
436#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
438#[serde(rename_all = "lowercase")]
439pub enum ValidationStatus {
440 Passed,
441 Mismatched,
442 Skipped,
443 Failed,
444}
445
446#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
448#[serde(rename_all = "snake_case")]
449pub enum ValidationFailureKind {
450 Timeout,
451 Nxdomain,
452 Servfail,
453 Refused,
454 TlsFailure,
455 DohHttpFailure,
456 MalformedResponse,
457 UnsupportedTransport,
458}
459
460#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
462#[serde(rename_all = "camelCase")]
463pub struct RecordMismatch {
464 pub name: String,
465 pub record_type: String,
466 pub expected: Vec<String>,
467 pub observed: Vec<String>,
468 pub mismatch_kind: String,
469}
470
471#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
473#[serde(rename_all = "camelCase")]
474pub struct SkippedRecord {
475 pub name: String,
476 pub record_type: String,
477 pub reason: String,
478}
479
480#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
482#[serde(rename_all = "camelCase")]
483pub struct RecordValidationResult {
484 pub name: String,
485 pub record_type: String,
486 pub status: ValidationStatus,
487 #[serde(default, skip_serializing_if = "Option::is_none")]
488 pub mismatch: Option<RecordMismatch>,
489 #[serde(default, skip_serializing_if = "Option::is_none")]
490 pub failure_kind: Option<ValidationFailureKind>,
491 #[serde(default, skip_serializing_if = "Option::is_none")]
492 pub skip_reason: Option<String>,
493}
494
495#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
497#[serde(rename_all = "camelCase")]
498pub struct EndpointValidationReport {
499 pub endpoint_name: String,
500 pub transport: String,
501 pub address: String,
502 pub status: ValidationStatus,
503 #[serde(default)]
504 pub results: Vec<RecordValidationResult>,
505 #[serde(default)]
506 pub mismatches: Vec<RecordMismatch>,
507 #[serde(default)]
508 pub skipped: Vec<SkippedRecord>,
509 #[serde(default)]
510 pub failures: Vec<ValidationFailureKind>,
511}
512
513#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
515#[serde(rename_all = "camelCase")]
516pub struct ValidationReport {
517 pub enabled: bool,
518 pub status: ValidationStatus,
519 #[serde(default, skip_serializing_if = "Option::is_none")]
520 pub zone: Option<String>,
521 #[serde(default, skip_serializing_if = "Option::is_none")]
522 pub domain: Option<String>,
523 #[serde(default, skip_serializing_if = "Option::is_none")]
525 pub phase: Option<String>,
526 #[serde(default)]
527 pub endpoints: Vec<EndpointValidationReport>,
528 #[serde(default)]
529 pub results: Vec<RecordValidationResult>,
530 #[serde(default)]
531 pub mismatches: Vec<RecordMismatch>,
532 #[serde(default)]
533 pub skipped: Vec<SkippedRecord>,
534 #[serde(default)]
535 pub failures: Vec<ValidationFailureKind>,
536}
537
538impl ValidationReport {
539 #[must_use]
541 pub fn disabled() -> Self {
542 Self {
543 enabled: false,
544 status: ValidationStatus::Skipped,
545 zone: None,
546 domain: None,
547 phase: None,
548 endpoints: Vec::new(),
549 results: Vec::new(),
550 mismatches: Vec::new(),
551 skipped: vec![SkippedRecord {
552 name: "*".to_string(),
553 record_type: "*".to_string(),
554 reason: "validation_disabled".to_string(),
555 }],
556 failures: Vec::new(),
557 }
558 }
559
560 #[must_use]
562 pub fn skipped_no_endpoints() -> Self {
563 Self::skipped("no_validation_endpoints_configured")
564 }
565
566 #[must_use]
568 pub fn skipped(reason: &str) -> Self {
569 Self {
570 enabled: true,
571 status: ValidationStatus::Skipped,
572 zone: None,
573 domain: None,
574 phase: None,
575 endpoints: Vec::new(),
576 results: Vec::new(),
577 mismatches: Vec::new(),
578 skipped: vec![SkippedRecord {
579 name: "*".to_string(),
580 record_type: "*".to_string(),
581 reason: reason.to_string(),
582 }],
583 failures: Vec::new(),
584 }
585 }
586
587 #[must_use]
589 pub const fn overall_status(&self) -> &ValidationStatus {
590 &self.status
591 }
592
593 #[must_use]
595 pub fn is_passed(&self) -> bool {
596 self.status == ValidationStatus::Passed
597 }
598}
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603 use crate::control_plane::config::ValidationTransport;
604 use crate::core::dns::responses::{ZoneInfo, ZoneRecords};
605 use rstest::{fixture, rstest};
606 use serde_json::{Value, json};
607 use std::net::{Ipv4Addr, Ipv6Addr};
608
609 #[fixture]
610 fn expected_record() -> ExpectedRecord {
611 ExpectedRecord {
612 name: "www.example.com".to_string(),
613 record_type: "A".to_string(),
614 values: vec!["192.0.2.10".to_string()],
615 }
616 }
617
618 #[fixture]
619 fn mismatch() -> RecordMismatch {
620 RecordMismatch {
621 name: "www.example.com".to_string(),
622 record_type: "A".to_string(),
623 expected: vec!["192.0.2.10".to_string()],
624 observed: vec!["192.0.2.11".to_string()],
625 mismatch_kind: "wrong_value".to_string(),
626 }
627 }
628
629 #[fixture]
630 fn mismatched_result(mismatch: RecordMismatch) -> RecordValidationResult {
631 RecordValidationResult {
632 name: mismatch.name.clone(),
633 record_type: mismatch.record_type.clone(),
634 status: ValidationStatus::Mismatched,
635 mismatch: Some(mismatch),
636 failure_kind: None,
637 skip_reason: None,
638 }
639 }
640
641 #[fixture]
642 fn endpoint_report(
643 mismatch: RecordMismatch,
644 mismatched_result: RecordValidationResult,
645 ) -> EndpointValidationReport {
646 EndpointValidationReport {
647 endpoint_name: "public-doh".to_string(),
648 transport: "doh".to_string(),
649 address: "https://dns.example/dns-query".to_string(),
650 status: ValidationStatus::Mismatched,
651 results: vec![mismatched_result],
652 mismatches: vec![mismatch],
653 skipped: vec![SkippedRecord {
654 name: "dnskey.example.com".to_string(),
655 record_type: "DNSKEY".to_string(),
656 reason: "unsupported record type".to_string(),
657 }],
658 failures: vec![ValidationFailureKind::DohHttpFailure],
659 }
660 }
661
662 fn validation_endpoint(transport: ValidationTransport) -> ValidationEndpointConfig {
663 ValidationEndpointConfig {
664 name: "test-endpoint".to_string(),
665 transport,
666 address: if matches!(transport, ValidationTransport::Doh) {
667 String::new()
668 } else {
669 "127.0.0.1".to_string()
670 },
671 port: None,
672 url: matches!(transport, ValidationTransport::Doh)
673 .then(|| "https://127.0.0.1/dns-query".to_string()),
674 tls_server_name: matches!(transport, ValidationTransport::Dot)
675 .then(|| "dns.example.test".to_string()),
676 enabled: true,
677 timeout_ms: Some(10),
678 }
679 }
680
681 #[fixture]
682 fn validation_report(
683 endpoint_report: EndpointValidationReport,
684 mismatch: RecordMismatch,
685 mismatched_result: RecordValidationResult,
686 ) -> ValidationReport {
687 ValidationReport {
688 enabled: true,
689 status: ValidationStatus::Mismatched,
690 zone: Some("example.com".to_string()),
691 domain: Some("www.example.com".to_string()),
692 phase: Some("transfer_pre".to_string()),
693 endpoints: vec![endpoint_report],
694 results: vec![mismatched_result],
695 mismatches: vec![mismatch],
696 skipped: vec![SkippedRecord {
697 name: "dnskey.example.com".to_string(),
698 record_type: "DNSKEY".to_string(),
699 reason: "unsupported record type".to_string(),
700 }],
701 failures: vec![ValidationFailureKind::DohHttpFailure],
702 }
703 }
704
705 fn zone_info() -> ZoneInfo {
706 ZoneInfo {
707 id: None,
708 name: "example.test".to_string(),
709 zone_type: "Primary".to_string(),
710 disabled: false,
711 dnssec_status: None,
712 }
713 }
714
715 fn zone_record(name: &str, ttl: u32, data: RecordData) -> ZoneRecord {
716 ZoneRecord {
717 name: name.to_string(),
718 record_type: data.type_name().to_string(),
719 ttl,
720 disabled: false,
721 comments: String::new(),
722 expiry_ttl: 0,
723 data: serde_json::to_value(&data).expect("record data serializes"),
724 parsed: Some(AnyRecordData::Writable(data)),
725 }
726 }
727
728 fn list_response(records: Vec<ZoneRecord>) -> ListRecordsResponse {
729 ListRecordsResponse {
730 zones: vec![ZoneRecords {
731 zone: zone_info(),
732 records,
733 }],
734 }
735 }
736
737 #[rstest]
738 fn validation_report_json_shape(validation_report: ValidationReport) {
739 let value = serde_json::to_value(validation_report).expect("report serializes to JSON");
740
741 assert_eq!(value["enabled"], json!(true));
742 assert_eq!(value["status"], json!("mismatched"));
743 assert_eq!(value["phase"], json!("transfer_pre"));
744 assert!(value["endpoints"].is_array());
745 assert!(value["results"].is_array());
746 assert!(value["mismatches"].is_array());
747 assert!(value["skipped"].is_array());
748 assert!(value["failures"].is_array());
749 assert_eq!(value["failures"][0], json!("doh_http_failure"));
750 assert_eq!(value["results"][0]["status"], json!("mismatched"));
751 assert_eq!(value["mismatches"][0]["mismatchKind"], json!("wrong_value"));
752 assert_eq!(value["endpoints"][0]["endpointName"], json!("public-doh"));
753 }
754
755 #[rstest]
756 fn validation_disabled_report_shape() {
757 let report = ValidationReport::disabled();
758 let value = serde_json::to_value(&report).expect("disabled report serializes to JSON");
759
760 assert!(!report.enabled);
761 assert_eq!(report.overall_status(), &ValidationStatus::Skipped);
762 assert_eq!(value["enabled"], json!(false));
763 assert_eq!(value["status"], json!("skipped"));
764 assert_eq!(value["endpoints"], json!([]));
765 assert_eq!(value["results"], json!([]));
766 assert_eq!(value["mismatches"], json!([]));
767 assert_eq!(value["skipped"][0]["reason"], json!("validation_disabled"));
768 assert_eq!(value["failures"], json!([]));
769 }
770
771 #[rstest]
772 fn skipped_no_endpoints_report_shape() {
773 let value = serde_json::to_value(ValidationReport::skipped_no_endpoints())
774 .expect("skipped report serializes to JSON");
775
776 assert_eq!(value["enabled"], json!(true));
777 assert_eq!(value["status"], json!("skipped"));
778 assert_eq!(
779 value["skipped"][0]["reason"],
780 json!("no_validation_endpoints_configured")
781 );
782 }
783
784 #[rstest]
785 fn validation_options_default_is_enabled() {
786 assert_eq!(ValidationOptions::default().enabled, true);
787
788 let parsed: ValidationOptions =
789 serde_json::from_value(json!({})).expect("empty validation options use defaults");
790
791 assert!(parsed.enabled);
792 assert_eq!(parsed.endpoint_filter, None);
793 }
794
795 #[rstest]
796 fn validation_request_defaults_options(expected_record: ExpectedRecord) {
797 let request: ValidationRequest = serde_json::from_value(json!({
798 "zone": "example.com",
799 "expectedRecords": [expected_record]
800 }))
801 .expect("request deserializes with default options");
802
803 assert!(request.options.enabled);
804 assert_eq!(request.domain, None);
805 assert_eq!(request.expected_records.len(), 1);
806 }
807
808 #[tokio::test]
809 async fn validation_resolver_plain_dns_fake() {
810 let endpoint = validation_endpoint(ValidationTransport::Dns);
811 let expected = vec![ObservedRecord {
812 name: "www.example.com".to_string(),
813 record_type: "A".to_string(),
814 ttl: None,
815 values: vec!["192.0.2.10".to_string()],
816 }];
817 let resolver = FakeDnsEndpointResolver::with_records(expected.clone());
818
819 let observed = resolver
820 .query_endpoint(
821 &endpoint,
822 "www.example.com",
823 "A",
824 endpoint_timeout(&endpoint),
825 )
826 .await
827 .expect("fake resolver returns deterministic records");
828
829 assert_eq!(observed, expected);
830 }
831
832 #[tokio::test]
833 async fn validation_resolver_doh_http_500_failure() {
834 let endpoint = validation_endpoint(ValidationTransport::Doh);
835 let resolver = FakeDnsEndpointResolver::with_failure(ValidationFailureKind::DohHttpFailure);
836
837 let failure = resolver
838 .query_endpoint(
839 &endpoint,
840 "www.example.com",
841 "A",
842 endpoint_timeout(&endpoint),
843 )
844 .await
845 .expect_err("fake resolver returns deterministic DoH failure");
846
847 assert_eq!(failure, ValidationFailureKind::DohHttpFailure);
848 }
849
850 #[tokio::test]
851 async fn validation_resolver_dot_tls_failure() {
852 let endpoint = validation_endpoint(ValidationTransport::Dot);
853 let resolver = FakeDnsEndpointResolver::with_failure(ValidationFailureKind::TlsFailure);
854
855 let failure = resolver
856 .query_endpoint(
857 &endpoint,
858 "www.example.com",
859 "A",
860 endpoint_timeout(&endpoint),
861 )
862 .await
863 .expect_err("fake resolver returns deterministic DoT failure");
864
865 assert_eq!(failure, ValidationFailureKind::TlsFailure);
866 }
867
868 #[tokio::test]
869 async fn validation_resolver_timeout_failure() {
870 let endpoint = validation_endpoint(ValidationTransport::Dns);
871 let resolver = FakeDnsEndpointResolver::with_failure(ValidationFailureKind::Timeout);
872
873 let failure = resolver
874 .query_endpoint(
875 &endpoint,
876 "www.example.com",
877 "A",
878 endpoint_timeout(&endpoint),
879 )
880 .await
881 .expect_err("fake resolver returns deterministic timeout");
882
883 assert_eq!(failure, ValidationFailureKind::Timeout);
884 }
885
886 #[rstest]
887 fn validation_compare_exact_match() {
888 let response = list_response(vec![
889 zone_record(
890 "@",
891 300,
892 RecordData::A {
893 ip: Ipv4Addr::new(192, 0, 2, 10),
894 },
895 ),
896 zone_record(
897 "@",
898 300,
899 RecordData::Aaaa {
900 ip: Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 0x0010),
901 },
902 ),
903 zone_record(
904 "www",
905 300,
906 RecordData::Cname {
907 target: "example.test.".to_string(),
908 },
909 ),
910 zone_record(
911 "@",
912 300,
913 RecordData::Mx {
914 preference: 10,
915 exchange: "mail.example.test.".to_string(),
916 },
917 ),
918 zone_record(
919 "@",
920 300,
921 RecordData::Txt {
922 text: "dnsync-validation-test".to_string(),
923 split_text: false,
924 },
925 ),
926 ]);
927 let (expected, skipped) = expected_records_from_response(&response);
928 let observed = expected
929 .iter()
930 .map(|record| ObservedRecord {
931 name: record.name.clone(),
932 record_type: record.record_type.clone(),
933 ttl: None,
934 values: record.values.clone(),
935 })
936 .collect::<Vec<_>>();
937
938 let results = compare_rrsets(&expected, &observed);
939
940 assert!(skipped.is_empty());
941 assert_eq!(results.len(), 5);
942 assert!(
943 results
944 .iter()
945 .all(|result| result.status == ValidationStatus::Passed)
946 );
947 }
948
949 #[rstest]
950 fn validation_compare_missing_extra_wrong_value() {
951 let expected = vec![
952 ExpectedRecord {
953 name: "example.test".to_string(),
954 record_type: "A".to_string(),
955 values: vec!["192.0.2.10".to_string()],
956 },
957 ExpectedRecord {
958 name: "www.example.test".to_string(),
959 record_type: "CNAME".to_string(),
960 values: vec!["example.test".to_string()],
961 },
962 ];
963 let observed = vec![
964 ObservedRecord {
965 name: "example.test".to_string(),
966 record_type: "A".to_string(),
967 ttl: None,
968 values: vec!["192.0.2.99".to_string()],
969 },
970 ObservedRecord {
971 name: "extra.example.test".to_string(),
972 record_type: "AAAA".to_string(),
973 ttl: None,
974 values: vec!["2001:db8::99".to_string()],
975 },
976 ];
977
978 let results = compare_rrsets(&expected, &observed);
979 let kinds = results
980 .iter()
981 .filter_map(|result| result.mismatch.as_ref())
982 .map(|mismatch| mismatch.mismatch_kind.as_str())
983 .collect::<Vec<_>>();
984
985 assert_eq!(results.len(), 3);
986 assert!(kinds.contains(&"wrong_value"));
987 assert!(kinds.contains(&"missing"));
988 assert!(kinds.contains(&"extra"));
989 }
990
991 #[rstest]
992 fn validation_skips_unsupported_types() {
993 let response = list_response(vec![zone_record(
994 "@",
995 300,
996 RecordData::Unknown {
997 rdata: "00ff".to_string(),
998 },
999 )]);
1000
1001 let (expected, skipped) = expected_records_from_response(&response);
1002
1003 assert!(expected.is_empty());
1004 assert_eq!(skipped.len(), 1);
1005 assert_eq!(skipped[0].record_type, "UNKNOWN");
1006 assert_eq!(skipped[0].reason, "unsupported_record_type");
1007 }
1008
1009 #[rstest]
1010 fn validation_ignores_ttl_differences() {
1011 let response = list_response(vec![zone_record(
1012 "@",
1013 30,
1014 RecordData::A {
1015 ip: Ipv4Addr::new(192, 0, 2, 10),
1016 },
1017 )]);
1018 let (expected, skipped) = expected_records_from_response(&response);
1019 let observed = vec![ObservedRecord {
1020 name: "example.test.".to_string(),
1021 record_type: "a".to_string(),
1022 ttl: Some(999),
1023 values: vec!["192.0.2.10".to_string()],
1024 }];
1025
1026 let results = compare_rrsets(&expected, &observed);
1027
1028 assert!(skipped.is_empty());
1029 assert_eq!(results[0].status, ValidationStatus::Passed);
1030 }
1031
1032 #[rstest]
1033 fn validation_normalizes_txt_mx_srv_cname_ns() {
1034 let response = list_response(vec![
1035 zone_record(
1036 "www",
1037 300,
1038 RecordData::Cname {
1039 target: "Example.TEST.".to_string(),
1040 },
1041 ),
1042 zone_record(
1043 "@",
1044 300,
1045 RecordData::Txt {
1046 text: "dnsync-validation-test".to_string(),
1047 split_text: true,
1048 },
1049 ),
1050 zone_record(
1051 "@",
1052 300,
1053 RecordData::Mx {
1054 preference: 10,
1055 exchange: "Mail.Example.Test.".to_string(),
1056 },
1057 ),
1058 zone_record(
1059 "@",
1060 300,
1061 RecordData::Ns {
1062 nameserver: "NS1.Example.Test.".to_string(),
1063 glue: None,
1064 },
1065 ),
1066 zone_record(
1067 "_sip._tcp",
1068 300,
1069 RecordData::Srv {
1070 priority: 10,
1071 weight: 20,
1072 port: 5060,
1073 target: "Sip.Example.Test.".to_string(),
1074 },
1075 ),
1076 ]);
1077 let (expected, skipped) = expected_records_from_response(&response);
1078 let observed = vec![
1079 ObservedRecord {
1080 name: "WWW.EXAMPLE.TEST.".to_string(),
1081 record_type: "cname".to_string(),
1082 ttl: None,
1083 values: vec!["example.test".to_string()],
1084 },
1085 ObservedRecord {
1086 name: "example.test".to_string(),
1087 record_type: "TXT".to_string(),
1088 ttl: None,
1089 values: vec!["\"dnsync-\" \"validation-test\"".to_string()],
1090 },
1091 ObservedRecord {
1092 name: "example.test".to_string(),
1093 record_type: "MX".to_string(),
1094 ttl: None,
1095 values: vec!["10 mail.example.test".to_string()],
1096 },
1097 ObservedRecord {
1098 name: "example.test".to_string(),
1099 record_type: "NS".to_string(),
1100 ttl: None,
1101 values: vec!["ns1.example.test".to_string()],
1102 },
1103 ObservedRecord {
1104 name: "_sip._tcp.example.test".to_string(),
1105 record_type: "SRV".to_string(),
1106 ttl: None,
1107 values: vec!["10 20 5060 sip.example.test".to_string()],
1108 },
1109 ];
1110
1111 let results = compare_rrsets(&expected, &observed);
1112
1113 assert!(skipped.is_empty());
1114 assert_eq!(results.len(), 5);
1115 assert!(
1116 results
1117 .iter()
1118 .all(|result| result.status == ValidationStatus::Passed)
1119 );
1120 }
1121
1122 #[rstest]
1123 #[case::passed(ValidationStatus::Passed, "passed")]
1124 #[case::mismatched(ValidationStatus::Mismatched, "mismatched")]
1125 #[case::skipped(ValidationStatus::Skipped, "skipped")]
1126 #[case::failed(ValidationStatus::Failed, "failed")]
1127 fn validation_status_serializes_lowercase(
1128 #[case] status: ValidationStatus,
1129 #[case] expected: &str,
1130 ) {
1131 assert_eq!(
1132 serde_json::to_value(status).expect("status serializes"),
1133 Value::String(expected.to_string())
1134 );
1135 }
1136
1137 #[rstest]
1138 #[case::timeout(ValidationFailureKind::Timeout, "timeout")]
1139 #[case::nxdomain(ValidationFailureKind::Nxdomain, "nxdomain")]
1140 #[case::servfail(ValidationFailureKind::Servfail, "servfail")]
1141 #[case::refused(ValidationFailureKind::Refused, "refused")]
1142 #[case::tls_failure(ValidationFailureKind::TlsFailure, "tls_failure")]
1143 #[case::doh_http_failure(ValidationFailureKind::DohHttpFailure, "doh_http_failure")]
1144 #[case::malformed_response(ValidationFailureKind::MalformedResponse, "malformed_response")]
1145 #[case::unsupported_transport(
1146 ValidationFailureKind::UnsupportedTransport,
1147 "unsupported_transport"
1148 )]
1149 fn validation_failure_kind_serializes_snake_case(
1150 #[case] failure_kind: ValidationFailureKind,
1151 #[case] expected: &str,
1152 ) {
1153 assert_eq!(
1154 serde_json::to_value(failure_kind).expect("failure kind serializes"),
1155 Value::String(expected.to_string())
1156 );
1157 }
1158}