diff --git a/rust/src/dns/add.rs b/rust/src/dns/add.rs index f694225..b9b3949 100644 --- a/rust/src/dns/add.rs +++ b/rust/src/dns/add.rs @@ -9,7 +9,8 @@ use crate::scopes::DOMAINS_DNS_UPDATE; use super::conflicts::{WriteErrorContext, describe_write_error}; use super::records::{ - RecordOptions, RecordWriteArgs, v3_records, validate_caa_fields, verify_with_list_action, + RecordOptions, RecordWriteArgs, v3_records, validate_caa_fields, validate_svcb_fields, + validate_tlsa_fields, verify_with_list_action, }; // `dns add` creates one v3 record per `--data` value; it reports each outcome @@ -126,6 +127,10 @@ pub(super) fn command() -> RuntimeCommandSpec { let data = args.data; validate_caa_fields(&record_type, &opts) .map_err(crate::error::GddyError::validation)?; + validate_tlsa_fields(&record_type, &opts) + .map_err(crate::error::GddyError::validation)?; + validate_svcb_fields(&record_type, &opts) + .map_err(crate::error::GddyError::validation)?; let records = v3_records(&name, &record_type, &data, &opts); let debug = !ctx.middleware.debug.is_empty(); diff --git a/rust/src/dns/conflicts.rs b/rust/src/dns/conflicts.rs index daa7357..fa6754f 100644 --- a/rust/src/dns/conflicts.rs +++ b/rust/src/dns/conflicts.rs @@ -7,7 +7,7 @@ use crate::domain::{api_error, format_api_error}; use domains_client::types; -use super::records::fetch_records; +use super::records::{fetch_records, record_value}; /// A v3 validation-error body's `details[].issue` codes. Deliberately not /// `domain::common`'s `ApiErrorBody` — that type is about rendering a friendly @@ -106,14 +106,14 @@ pub(super) fn describe_duplicate_record( "`{name}` already has a CNAME record (→ `{}`), which can't coexist with \ {desired_type} records — DNS only allows one or the other at a given \ name.{remediation}", - conflicts[0].data.as_deref().unwrap_or("(no data)"), + record_value(conflicts[0]).unwrap_or("(no data)"), ) }; } if at_name .iter() - .any(|r| r.type_.as_str() == desired_type && r.data.as_deref() == Some(desired_data)) + .any(|r| r.type_.as_str() == desired_type && record_value(r) == Some(desired_data)) { return format!("a {desired_type} record with this exact value already exists at {name}."); } @@ -228,6 +228,16 @@ mod tests { } } + /// A TLSA record as `v3_record` actually builds one: its value lives in + /// `certificate_data`, not `data`. + fn tlsa_record(cert: &str) -> types::DnsRecord { + types::DnsRecord { + certificate_data: Some(cert.to_string()), + data: None, + ..dns_record("TLSA", "") + } + } + #[test] fn duplicate_record_issue_detects_the_v3_issue_code() { // The exact body the reporting user got back from a `dns set` CNAME conflict. @@ -329,4 +339,18 @@ mod tests { "{msg}" ); } + + #[test] + fn describe_duplicate_record_recognizes_tlsa_exact_duplicates_via_certificate_data() { + // TLSA's value lives in `certificate_data`, not `data` — the exact- + // duplicate check must read through `record_value` to see it. + let cert = "d2abde240d7cd3ee6b4b28c54df034b97983a1d16e8a410e4561cb106618e971"; + let exact = vec![tlsa_record(cert)]; + let msg = describe_duplicate_record("TLSA", cert, "example.com", "www", &exact, true); + assert!(msg.contains("exact value already exists"), "{msg}"); + + let different = vec![tlsa_record("00")]; + let msg = describe_duplicate_record("TLSA", cert, "example.com", "www", &different, true); + assert!(msg.contains("already has conflicting TLSA data"), "{msg}"); + } } diff --git a/rust/src/dns/delete.rs b/rust/src/dns/delete.rs index 10b5705..42caee0 100644 --- a/rust/src/dns/delete.rs +++ b/rust/src/dns/delete.rs @@ -9,7 +9,7 @@ use crate::scopes::DOMAINS_DNS_UPDATE; use domains_client::types; -use super::records::{fetch_records, parse_write_type_arg, verify_with_list_action}; +use super::records::{fetch_records, parse_write_type_arg, record_value, verify_with_list_action}; output_schema!(DnsDeleteResult { "domain": "string"; @@ -85,7 +85,7 @@ fn dry_run_delete_preview( } else { "would fail (missing recordId)" }; - json!({"recordId": rec.record_id, "data": rec.data, "status": status}) + json!({"recordId": rec.record_id, "data": record_value(rec), "status": status}) }) .collect(); @@ -117,7 +117,7 @@ struct DeleteArgs { #[arg(value_name = "DOMAIN")] domain: String, - /// Record type (A, AAAA, ALIAS, CAA, CNAME, MX, SRV, TXT). + /// Record type (A, AAAA, ALIAS, CAA, CNAME, HTTPS, MX, SRV, SVCB, TLSA, TXT). #[arg(long = "type", value_name = "TYPE", value_parser = parse_write_type_arg)] record_type: String, @@ -217,7 +217,7 @@ pub(super) fn command() -> RuntimeCommandSpec { } }, }; - outcomes.push((rec.data.as_deref().unwrap_or("(no data)").to_string(), err)); + outcomes.push((record_value(rec).unwrap_or("(no data)").to_string(), err)); } summarize_delete_outcomes(&domain, &record_type, &name, &outcomes) @@ -296,6 +296,17 @@ mod tests { } } + /// A TLSA record as `v3_record` actually builds one: its value lives in + /// `certificate_data`, not `data`. + fn test_tlsa_record(record_id: &str, cert: &str) -> types::DnsRecord { + types::DnsRecord { + certificate_data: Some(cert.to_owned()), + data: None, + type_: types::DnsRecordType("TLSA".to_owned()), + ..test_record(record_id, "") + } + } + #[test] fn dry_run_delete_preview_lists_every_matched_record_without_deleting() { let existing = vec![test_record("r1", "1.2.3.4"), test_record("r2", "5.6.7.8")]; @@ -309,6 +320,18 @@ mod tests { assert_eq!(records[0]["status"], "would delete"); } + /// TLSA's value lives in `certificate_data`, not `data` — the preview + /// must read through `record_value` to show it rather than reporting + /// null. + #[test] + fn dry_run_delete_preview_shows_tlsa_certificate_data_as_the_value() { + let cert = "d2abde240d7cd3ee6b4b28c54df034b97983a1d16e8a410e4561cb106618e971"; + let existing = vec![test_tlsa_record("r1", cert)]; + let preview = dry_run_delete_preview("example.com", "TLSA", "www", &existing); + let records = preview["records"].as_array().expect("records array"); + assert_eq!(records[0]["data"], cert); + } + /// A record without a recordId can't actually be deleted (the real /// handler reports it as a failure) — the preview must not claim it as /// a delete either. diff --git a/rust/src/dns/list.rs b/rust/src/dns/list.rs index b480e62..ebf1f75 100644 --- a/rust/src/dns/list.rs +++ b/rust/src/dns/list.rs @@ -18,8 +18,8 @@ struct ListArgs { #[arg(value_name = "DOMAIN")] domain: String, - /// Only records of this type (A, AAAA, ALIAS, CAA, CNAME, MX, NS, SOA, - /// SRV, TXT). + /// Only records of this type (A, AAAA, ALIAS, CAA, CNAME, HTTPS, MX, NS, + /// SOA, SRV, SVCB, TLSA, TXT). #[arg(long = "type", value_name = "TYPE", value_parser = parse_list_type_arg)] record_type: Option, diff --git a/rust/src/dns/records.rs b/rust/src/dns/records.rs index bd9cf0b..0fce9ee 100644 --- a/rust/src/dns/records.rs +++ b/rust/src/dns/records.rs @@ -13,12 +13,13 @@ use domains_client::types; /// NS and SOA are registry-managed / read-only, so they're excluded. v3's /// `DNSRecordType` is otherwise an open string; this list is the CLI's guardrail /// against typos and includes `CAA` and GoDaddy's `ALIAS` extension. -pub(super) const WRITABLE_TYPES: &[&str] = - &["A", "AAAA", "ALIAS", "CAA", "CNAME", "MX", "SRV", "TXT"]; +pub(super) const WRITABLE_TYPES: &[&str] = &[ + "A", "AAAA", "ALIAS", "CAA", "CNAME", "HTTPS", "MX", "SRV", "SVCB", "TLSA", "TXT", +]; /// Record types accepted by the `list` filter — the writable set plus the /// read-only NS/SOA, which are listable even though they can't be modified. pub(super) const LISTABLE_TYPES: &[&str] = &[ - "A", "AAAA", "ALIAS", "CAA", "CNAME", "MX", "NS", "SOA", "SRV", "TXT", + "A", "AAAA", "ALIAS", "CAA", "CNAME", "HTTPS", "MX", "NS", "SOA", "SRV", "SVCB", "TLSA", "TXT", ]; /// Default TTL (seconds) for `dns add`/`set` when `--ttl` is omitted (v3 requires a ttl). pub(super) const DEFAULT_TTL: i64 = 3600; @@ -64,7 +65,8 @@ pub(super) fn parse_list_type_arg(raw: &str) -> Result { } /// Optional record fields shared by `add` and `set`, including the CAA-only -/// `flag`/`tag`. +/// `flag`/`tag`, the TLSA-only `usage`/`selector`/`matching_type`, and the +/// HTTPS/SVCB-only `parameters` (SvcParams). pub(super) struct RecordOptions { pub(super) ttl: Option, pub(super) priority: Option, @@ -74,6 +76,10 @@ pub(super) struct RecordOptions { pub(super) service: Option, pub(super) flag: Option, pub(super) tag: Option, + pub(super) usage: Option, + pub(super) selector: Option, + pub(super) matching_type: Option, + pub(super) parameters: Option, } impl RecordOptions { @@ -87,6 +93,10 @@ impl RecordOptions { service: args.service.clone(), flag: args.flag, tag: args.tag.clone(), + usage: args.usage, + selector: args.selector, + matching_type: args.matching_type, + parameters: args.parameters.clone(), } } } @@ -99,7 +109,7 @@ pub(super) struct RecordWriteArgs { #[arg(value_name = "DOMAIN")] pub(super) domain: String, - /// Record type (A, AAAA, ALIAS, CAA, CNAME, MX, SRV, TXT). + /// Record type (A, AAAA, ALIAS, CAA, CNAME, HTTPS, MX, SRV, SVCB, TLSA, TXT). #[arg(long = "type", value_name = "TYPE", value_parser = parse_write_type_arg)] pub(super) record_type: String, @@ -107,7 +117,9 @@ pub(super) struct RecordWriteArgs { #[arg(long, value_name = "NAME")] pub(super) name: String, - /// Record value (repeatable for multiple records on the same name). + /// Record value (repeatable for multiple records on the same name). For + /// TLSA, this is the hex-encoded certificate association data; use + /// `--usage`/`--selector`/`--matching-type` for the rest. #[arg(long, value_name = "VALUE", required = true)] pub(super) data: Vec, @@ -115,11 +127,12 @@ pub(super) struct RecordWriteArgs { #[arg(long, value_name = "SECONDS", value_parser = clap::value_parser!(i64).range(1..))] pub(super) ttl: Option, - /// Record priority (MX and SRV only). + /// Record priority (MX, SRV, HTTPS, and SVCB only). For HTTPS/SVCB, 0 + /// means AliasMode. #[arg(long, value_name = "N", value_parser = clap::value_parser!(i64).range(0..=65535))] pub(super) priority: Option, - /// Service port (SRV only). + /// Service port (SRV and TLSA only). #[arg(long, value_name = "PORT", value_parser = clap::value_parser!(i64).range(1..=65535))] pub(super) port: Option, @@ -127,7 +140,7 @@ pub(super) struct RecordWriteArgs { #[arg(long, value_name = "N", value_parser = clap::value_parser!(i64).range(0..=65535))] pub(super) weight: Option, - /// Service protocol (SRV only). + /// Service protocol, e.g. _tcp (SRV and TLSA only). #[arg(long, value_name = "PROTO")] pub(super) protocol: Option, @@ -145,6 +158,26 @@ pub(super) struct RecordWriteArgs { /// CAA property tag, e.g. issue/issuewild/iodef (CAA only; required for CAA). #[arg(long, value_name = "TAG", required_if_eq("record_type", "CAA"))] pub(super) tag: Option, + + /// TLSA certificate usage, 0-3 (RFC 6698 §2.1.1; TLSA only; required for + /// TLSA). 0 PKIX-TA, 1 PKIX-EE, 2 DANE-TA, 3 DANE-EE. + #[arg(long = "usage", value_name = "N", value_parser = clap::value_parser!(i64).range(0..=3), required_if_eq("record_type", "TLSA"))] + pub(super) usage: Option, + + /// TLSA selector, 0-1 (RFC 6698 §2.1.2; TLSA only; required for TLSA). 0 + /// full certificate, 1 SubjectPublicKeyInfo. + #[arg(long = "selector", value_name = "N", value_parser = clap::value_parser!(i64).range(0..=1), required_if_eq("record_type", "TLSA"))] + pub(super) selector: Option, + + /// TLSA matching type, 0-2 (RFC 6698 §2.1.3; TLSA only; required for + /// TLSA). 0 exact match, 1 SHA-256, 2 SHA-512. + #[arg(long = "matching-type", value_name = "N", value_parser = clap::value_parser!(i64).range(0..=2), required_if_eq("record_type", "TLSA"))] + pub(super) matching_type: Option, + + /// SvcParams for HTTPS/SVCB records (RFC 9460), e.g. "alpn=h2,h3 + /// port=8443" (HTTPS/SVCB only). + #[arg(long, value_name = "PARAMS")] + pub(super) parameters: Option, } /// Validate the CAA-specific fields against the record type. A CAA record needs a @@ -168,9 +201,39 @@ pub(super) fn validate_caa_fields(record_type: &str, opts: &RecordOptions) -> Re Ok(()) } +/// Validate the TLSA-specific fields against the record type. `--usage`/ +/// `--selector`/`--matching-type` being present when `record_type` is TLSA is +/// already enforced by clap (`required_if_eq`); this only guards the reverse — +/// they're meaningless for other types. Pure so it's unit-testable and runs +/// before any network call. +pub(super) fn validate_tlsa_fields(record_type: &str, opts: &RecordOptions) -> Result<(), String> { + if record_type != "TLSA" + && (opts.usage.is_some() || opts.selector.is_some() || opts.matching_type.is_some()) + { + return Err(format!( + "--usage/--selector/--matching-type are only valid for TLSA records, not {record_type}" + )); + } + Ok(()) +} + +/// Validate the HTTPS/SVCB-specific `--parameters` (SvcParams) field against +/// the record type — meaningless for anything else. Pure so it's +/// unit-testable and runs before any network call. +pub(super) fn validate_svcb_fields(record_type: &str, opts: &RecordOptions) -> Result<(), String> { + if opts.parameters.is_some() && !matches!(record_type, "HTTPS" | "SVCB") { + return Err(format!( + "--parameters is only valid for HTTPS/SVCB records, not {record_type}" + )); + } + Ok(()) +} + /// Build one v3 `DnsRecord` from a `--data` value + shared options. `ttl` defaults /// to [`DEFAULT_TTL`] (v3 requires it). SRV/MX numerics convert into v3's `u16` -/// and the CAA `flag` into `u8` (clap already bounds both ranges). +/// and the CAA `flag`/TLSA `usage`/`selector`/`matchingType` into `u8` (clap +/// already bounds all four ranges). TLSA doesn't use `data` — v3 wants its +/// value under `certificateData` instead — so `data`'s value moves there. pub(super) fn v3_record( name: &str, ty: &str, @@ -178,20 +241,26 @@ pub(super) fn v3_record( opts: &RecordOptions, ) -> types::DnsRecord { let to_u16 = |v: Option| v.and_then(|n| u16::try_from(n).ok()); + let to_u8 = |v: Option| v.and_then(|n| u8::try_from(n).ok()); + let is_tlsa = ty == "TLSA"; types::DnsRecord { - // TLSA-only fields — the CLI's writable types (WRITABLE_TYPES) never - // include TLSA, so these are always absent for records this builds. - certificate_data: None, - matching_type: None, - selector: None, - usage: None, - data: Some(data.to_owned()), + certificate_data: is_tlsa.then(|| data.to_owned()), + matching_type: is_tlsa + .then(|| to_u8(opts.matching_type)) + .flatten() + .map(types::TlsaMatchingType), + selector: is_tlsa + .then(|| to_u8(opts.selector)) + .flatten() + .map(types::TlsaSelector), + usage: is_tlsa + .then(|| to_u8(opts.usage)) + .flatten() + .map(types::TlsaUsage), + data: (!is_tlsa).then(|| data.to_owned()), flag: opts.flag.and_then(|n| u8::try_from(n).ok()), name: name.to_owned(), - // SvcParams for HTTPS/SVCB records (RFC 9460) — no CLI flag sets - // these yet, so every record is built without them, same as before - // this field existed. - parameters: None, + parameters: opts.parameters.clone(), port: to_u16(opts.port), priority: to_u16(opts.priority), protocol: opts.protocol.clone(), @@ -214,6 +283,15 @@ pub(super) fn v3_records( data.iter().map(|d| v3_record(name, ty, d, opts)).collect() } +/// The user-facing "value" of a fetched v3 `DnsRecord`: `data` for every type +/// except TLSA, which carries its value in `certificateData` instead (see +/// [`v3_record`]'s TLSA branch). Conflict diagnosis, delete/set reporting, and +/// exact-duplicate detection all need "the value" regardless of which wire +/// field holds it, so they read through this rather than `data` directly. +pub(super) fn record_value(rec: &types::DnsRecord) -> Option<&str> { + rec.data.as_deref().or(rec.certificate_data.as_deref()) +} + /// List every v3 DNS record for a zone matching the optional `type`/`name` /// filters, paging through the collection (v3 list is paginated). Shared by /// `list`, `set`, and `delete` — the latter two need the matching records' ids. @@ -289,6 +367,10 @@ mod tests { service: None, flag: None, tag: None, + usage: None, + selector: None, + matching_type: None, + parameters: None, } } @@ -297,6 +379,9 @@ mod tests { assert_eq!(parse_write_type_arg("aaaa").expect("valid"), "AAAA"); assert_eq!(parse_write_type_arg("caa").expect("valid"), "CAA"); assert_eq!(parse_write_type_arg("Alias").expect("valid"), "ALIAS"); + assert_eq!(parse_write_type_arg("https").expect("valid"), "HTTPS"); + assert_eq!(parse_write_type_arg("svcb").expect("valid"), "SVCB"); + assert_eq!(parse_write_type_arg("tlsa").expect("valid"), "TLSA"); // NS/SOA are registry-managed / read-only → rejected with a clear reason. for ty in ["NS", "soa"] { let err = parse_write_type_arg(ty).expect_err("read-only"); @@ -371,4 +456,61 @@ mod tests { // A non-CAA type with no CAA fields → ok. assert!(validate_caa_fields("A", &opts()).is_ok()); } + + #[test] + fn v3_record_carries_https_svcb_and_tlsa_fields() { + let mut https_opts = opts(); + https_opts.priority = Some(1); + https_opts.parameters = Some("alpn=h2,h3".to_string()); + let https = v3_record("@", "HTTPS", ".", &https_opts); + assert_eq!(https.priority, Some(1)); + assert_eq!(https.parameters.as_deref(), Some("alpn=h2,h3")); + assert_eq!(https.data.as_deref(), Some(".")); + assert_eq!(https.certificate_data, None); + + let mut tlsa_opts = opts(); + tlsa_opts.usage = Some(3); + tlsa_opts.selector = Some(1); + tlsa_opts.matching_type = Some(1); + tlsa_opts.protocol = Some("_tcp".to_string()); + tlsa_opts.port = Some(443); + let cert = "d2abde240d7cd3ee6b4b28c54df034b97983a1d16e8a410e4561cb106618e971"; + let tlsa = v3_record("www", "TLSA", cert, &tlsa_opts); + // TLSA doesn't use `data` — the value moves to `certificateData`. + assert_eq!(tlsa.data, None); + assert_eq!(tlsa.certificate_data.as_deref(), Some(cert)); + assert_eq!(tlsa.usage.map(|u| u.0), Some(3)); + assert_eq!(tlsa.selector.map(|s| s.0), Some(1)); + assert_eq!(tlsa.matching_type.map(|m| m.0), Some(1)); + assert_eq!(tlsa.protocol.as_deref(), Some("_tcp")); + assert_eq!(tlsa.port, Some(443)); + } + + #[test] + fn tlsa_fields_are_required_for_tlsa_and_rejected_otherwise() { + // --usage/--selector/--matching-type on a non-TLSA type → rejected. + let mut a = opts(); + a.usage = Some(3); + let err = validate_tlsa_fields("A", &a).expect_err("TLSA fields are TLSA-only"); + assert!(err.contains("only valid for TLSA"), "got: {err}"); + // TLSA with the fields set → ok. + let mut tlsa = opts(); + tlsa.usage = Some(3); + tlsa.selector = Some(1); + tlsa.matching_type = Some(1); + assert!(validate_tlsa_fields("TLSA", &tlsa).is_ok()); + // A non-TLSA type with no TLSA fields → ok. + assert!(validate_tlsa_fields("A", &opts()).is_ok()); + } + + #[test] + fn svcb_parameters_are_rejected_for_other_types() { + let mut a = opts(); + a.parameters = Some("alpn=h2".to_string()); + let err = validate_svcb_fields("A", &a).expect_err("parameters are HTTPS/SVCB-only"); + assert!(err.contains("only valid for HTTPS/SVCB"), "got: {err}"); + assert!(validate_svcb_fields("HTTPS", &a).is_ok()); + assert!(validate_svcb_fields("SVCB", &a).is_ok()); + assert!(validate_svcb_fields("A", &opts()).is_ok()); + } } diff --git a/rust/src/dns/set/mod.rs b/rust/src/dns/set/mod.rs index 17a5799..2a8d831 100644 --- a/rust/src/dns/set/mod.rs +++ b/rust/src/dns/set/mod.rs @@ -7,7 +7,10 @@ use crate::output_schema::output_schema; use crate::scopes::DOMAINS_DNS_UPDATE; use super::records::verify_with_list_action; -use super::records::{RecordOptions, RecordWriteArgs, fetch_records, validate_caa_fields}; +use super::records::{ + RecordOptions, RecordWriteArgs, fetch_records, record_value, validate_caa_fields, + validate_svcb_fields, validate_tlsa_fields, +}; mod outcome; mod plan; @@ -100,6 +103,10 @@ pub(super) fn command() -> RuntimeCommandSpec { let replace_conflicting = args.replace_conflicting_types; validate_caa_fields(&record_type, &opts) .map_err(crate::error::GddyError::validation)?; + validate_tlsa_fields(&record_type, &opts) + .map_err(crate::error::GddyError::validation)?; + validate_svcb_fields(&record_type, &opts) + .map_err(crate::error::GddyError::validation)?; let debug = !ctx.middleware.debug.is_empty(); let client = make_client(&ctx).await?; @@ -175,7 +182,7 @@ pub(super) fn command() -> RuntimeCommandSpec { &req, &record_id, &old_detail, - old_record.and_then(|r| r.data.as_deref()), + old_record.and_then(record_value), ) .await, ); diff --git a/rust/src/dns/set/outcome.rs b/rust/src/dns/set/outcome.rs index aac5214..31bc99d 100644 --- a/rust/src/dns/set/outcome.rs +++ b/rust/src/dns/set/outcome.rs @@ -5,6 +5,7 @@ use domains_client::types; use serde_json::{Value, json}; +use super::super::records::record_value; use super::plan::SetAction; /// Outcome of one applied `set` reconcile action, for reporting. @@ -125,7 +126,7 @@ pub(super) fn record_label( existing .iter() .find(|r| r.record_id.as_deref() == Some(record_id)) - .map(|r| format!("{record_type} {}", r.data.as_deref().unwrap_or("(no data)"))) + .map(|r| format!("{record_type} {}", record_value(r).unwrap_or("(no data)"))) .unwrap_or_else(|| record_id.to_string()) } @@ -134,6 +135,40 @@ mod tests { use super::*; use crate::dns::set::plan::plan_set; + fn tlsa_record(record_id: &str, cert: &str) -> types::DnsRecord { + types::DnsRecord { + certificate_data: Some(cert.to_owned()), + matching_type: None, + selector: None, + usage: None, + data: None, + flag: None, + name: "www".to_owned(), + parameters: None, + port: None, + priority: None, + protocol: None, + record_id: Some(record_id.to_owned()), + service: None, + tag: None, + ttl: 3600, + type_: types::DnsRecordType("TLSA".to_owned()), + weight: None, + } + } + + /// TLSA's value lives in `certificate_data`, not `data` — the label must + /// read through `record_value` to show it rather than "(no data)". + #[test] + fn record_label_shows_tlsa_certificate_data_as_the_value() { + let cert = "d2abde240d7cd3ee6b4b28c54df034b97983a1d16e8a410e4561cb106618e971"; + let existing = vec![tlsa_record("r1", cert)]; + assert_eq!( + record_label(&existing, "TLSA", "r1"), + format!("TLSA {cert}") + ); + } + #[test] fn summarize_set_reports_counts_and_flags_partial_failure() { let all_ok = vec![ diff --git a/rust/src/dns/set/write.rs b/rust/src/dns/set/write.rs index 13d417d..b2a3156 100644 --- a/rust/src/dns/set/write.rs +++ b/rust/src/dns/set/write.rs @@ -7,7 +7,7 @@ use domains_client::types; use crate::dns::conflicts::{ conflicting_records, conflicting_records_at, describe_duplicate_record, duplicate_record_issue, }; -use crate::dns::records::{RecordOptions, v3_record}; +use crate::dns::records::{RecordOptions, record_value, v3_record}; use crate::domain::{api_error, format_api_error}; use super::outcome::SetOutcome; @@ -41,7 +41,7 @@ async fn delete_conflicts( let detail = format!( "{} {}", rec.type_.as_str(), - rec.data.as_deref().unwrap_or("(no data)") + record_value(rec).unwrap_or("(no data)") ); let Some(record_id) = rec.record_id.as_deref() else { outcomes.push(SetOutcome::new( @@ -266,6 +266,10 @@ mod tests { service: None, flag: None, tag: None, + usage: None, + selector: None, + matching_type: None, + parameters: None, } } @@ -302,6 +306,93 @@ mod tests { assert!(outcomes[0].error.is_none(), "{:?}", outcomes[0].error); } + #[tokio::test] + async fn write_with_conflict_handling_sends_https_parameters_in_request_body() { + let server = MockServer::start_async().await; + let create = server + .mock_async(|when, then| { + when.method(POST) + .path("/v3/domains/zones/example.com/dns-records") + .json_body(json!({ + "data": ".", + "name": "@", + "parameters": "alpn=h2,h3", + "priority": 1, + "ttl": 3600, + "type": "HTTPS" + })); + then.status(201) + .json_body(json!({ "type": "HTTPS", "name": "@", "data": ".", "ttl": 3600 })); + }) + .await; + + let mut opts = write_opts(); + opts.priority = Some(1); + opts.parameters = Some("alpn=h2,h3".to_string()); + let req = WriteRequest { + domain: "example.com", + name: "@", + record_type: "HTTPS", + value: ".", + opts: &opts, + replace_conflicting: false, + debug: false, + }; + let outcomes = write_with_conflict_handling(&client_for(&server), &req).await; + + create.assert_async().await; + assert_eq!(outcomes.len(), 1); + assert_eq!(outcomes[0].kind, "created"); + assert!(outcomes[0].error.is_none(), "{:?}", outcomes[0].error); + } + + #[tokio::test] + async fn write_with_conflict_handling_sends_tlsa_fields_in_request_body() { + let server = MockServer::start_async().await; + let cert = "d2abde240d7cd3ee6b4b28c54df034b97983a1d16e8a410e4561cb106618e971"; + let create = server + .mock_async(|when, then| { + when.method(POST) + .path("/v3/domains/zones/example.com/dns-records") + .json_body(json!({ + "certificateData": cert, + "matchingType": 1, + "name": "www", + "port": 443, + "protocol": "_tcp", + "selector": 1, + "ttl": 3600, + "type": "TLSA", + "usage": 3 + })); + then.status(201) + .json_body(json!({ "type": "TLSA", "name": "www", "ttl": 3600 })); + }) + .await; + + let mut opts = write_opts(); + opts.usage = Some(3); + opts.selector = Some(1); + opts.matching_type = Some(1); + opts.protocol = Some("_tcp".to_string()); + opts.port = Some(443); + let req = WriteRequest { + domain: "example.com", + name: "www", + record_type: "TLSA", + value: cert, + opts: &opts, + replace_conflicting: false, + debug: false, + }; + let outcomes = write_with_conflict_handling(&client_for(&server), &req).await; + + create.assert_async().await; + assert_eq!(outcomes.len(), 1); + assert_eq!(outcomes[0].kind, "created"); + assert!(outcomes[0].error.is_none(), "{:?}", outcomes[0].error); + } + #[tokio::test] async fn write_with_conflict_handling_conflict_without_flag_reports_message_and_skips_delete() { let server = MockServer::start_async().await;