Skip to main content

dnslib/core/dns/
validation.rs

1//! Stable domain types for DNS endpoint validation reports.
2//!
3//! This module contains stable serializable data shapes and resolver endpoint
4//! abstractions. Record comparison logic lives in later validation layers.
5
6use 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
25/// Result type returned by endpoint resolvers.
26pub type DnsEndpointResolverResult<T> = std::result::Result<T, ValidationFailureKind>;
27
28/// Async DNS endpoint resolver abstraction used by validation code.
29///
30/// Implementations query one configured endpoint for one FQDN and record type.
31/// Tests can implement this trait without opening network sockets.
32pub trait DnsEndpointResolver {
33    /// Query a validation endpoint for records visible at that endpoint.
34    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/// Production resolver backed by Hickory's async Tokio resolver.
43#[derive(Debug, Clone, Copy, Default)]
44pub struct HickoryDnsEndpointResolver;
45
46impl HickoryDnsEndpointResolver {
47    /// Build a production Hickory resolver for one validation endpoint.
48    ///
49    /// Delegates to [`build_resolver`] via a [`ResolverTarget`] derived
50    /// from the legacy endpoint shape; behaviour is unchanged.
51    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/// Return the configured endpoint timeout, defaulting to five seconds.
95#[must_use]
96pub fn endpoint_timeout(endpoint: &ValidationEndpointConfig) -> Duration {
97    Duration::from_millis(endpoint.timeout_ms.unwrap_or(5_000))
98}
99
100/// Convert provider/API records into validation expected RRsets.
101#[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/// Compare normalized expected and observed RRsets, ignoring TTL.
121#[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/// Deterministic resolver helper for unit tests.
350#[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/// Options that control whether and where validation runs.
385#[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/// Validation input for a record list, import, export, or transfer phase.
404#[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/// A DNS record expected to be visible at a validation endpoint.
417#[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/// A DNS record observed from a validation endpoint.
426#[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/// Stable validation status values used at report, endpoint, and record level.
437#[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/// Stable categories for endpoint-level validation failures.
447#[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/// A difference between expected and observed DNS record values.
461#[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/// A record that validation intentionally skipped.
472#[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/// Validation result for one expected record.
481#[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/// Validation results collected from one configured endpoint.
496#[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/// Stable validation report shape for record lists and transfer pre/post checks.
514#[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    /// Optional report phase, such as `record_list`, `transfer_pre`, or `transfer_post`.
524    #[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    /// Build a report for validation explicitly disabled by caller options.
540    #[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    /// Build a report for enabled validation with no configured endpoints.
561    #[must_use]
562    pub fn skipped_no_endpoints() -> Self {
563        Self::skipped("no_validation_endpoints_configured")
564    }
565
566    /// Build a report for enabled validation skipped for a specific reason.
567    #[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    /// Return the aggregate report status.
588    #[must_use]
589    pub const fn overall_status(&self) -> &ValidationStatus {
590        &self.status
591    }
592
593    /// Whether validation completed without mismatches, failures, or skips.
594    #[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}