#[cfg(test)]
mod tests {
use crate::{
DnsRecord, DnsRecordType, Error, MXRecord, TLSARecord, TlsaCertUsage, TlsaMatching,
TlsaSelector, providers::hetzner::HetznerProvider,
};
use mockito::Matcher;
use serde_json::json;
use std::time::Duration;
fn setup_provider(endpoint: String) -> HetznerProvider {
HetznerProvider::new("test_token", Some(Duration::from_secs(1)))
.unwrap()
.with_endpoint(endpoint)
}
fn ok_action() -> &'static str {
r#"{"action":{"id":1,"status":"success"}}"#
}
#[tokio::test]
async fn test_set_rrset_calls_set_records_then_change_ttl() {
let mut server = mockito::Server::new_async().await;
let set_records = server
.mock(
"POST",
"/zones/example.com/rrsets/www/A/actions/set_records",
)
.match_header("authorization", "Bearer test_token")
.match_body(Matcher::Json(json!({
"records": [{"value": "1.1.1.1"}, {"value": "2.2.2.2"}],
})))
.with_status(200)
.with_body(ok_action())
.create();
let change_ttl = server
.mock("POST", "/zones/example.com/rrsets/www/A/actions/change_ttl")
.match_body(Matcher::Json(json!({"ttl": 300})))
.with_status(200)
.with_body(ok_action())
.create();
let provider = setup_provider(server.url());
let result = provider
.set_rrset(
"www.example.com",
DnsRecordType::A,
300,
vec![
DnsRecord::A("1.1.1.1".parse().unwrap()),
DnsRecord::A("2.2.2.2".parse().unwrap()),
],
"example.com",
)
.await;
assert!(result.is_ok(), "set_rrset returned: {result:?}");
set_records.assert();
change_ttl.assert();
}
#[tokio::test]
async fn test_set_rrset_creates_when_set_records_returns_404() {
let mut server = mockito::Server::new_async().await;
let set_records = server
.mock(
"POST",
"/zones/example.com/rrsets/www/A/actions/set_records",
)
.with_status(404)
.with_body(r#"{"error":{"code":"not_found","message":"rrset not found"}}"#)
.create();
let create = server
.mock("POST", "/zones/example.com/rrsets")
.match_body(Matcher::Json(json!({
"name": "www",
"type": "A",
"ttl": 300,
"records": [{"value": "1.1.1.1"}],
})))
.with_status(201)
.with_body(r#"{"rrset":{}}"#)
.create();
let provider = setup_provider(server.url());
let result = provider
.set_rrset(
"www.example.com",
DnsRecordType::A,
300,
vec![DnsRecord::A("1.1.1.1".parse().unwrap())],
"example.com",
)
.await;
assert!(result.is_ok(), "set_rrset returned: {result:?}");
set_records.assert();
create.assert();
}
#[tokio::test]
async fn test_set_rrset_empty_deletes_only_this_type() {
let mut server = mockito::Server::new_async().await;
let delete = server
.mock("DELETE", "/zones/example.com/rrsets/www/A")
.with_status(204)
.create();
let provider = setup_provider(server.url());
let result = provider
.set_rrset(
"www.example.com",
DnsRecordType::A,
300,
vec![],
"example.com",
)
.await;
assert!(result.is_ok(), "set_rrset returned: {result:?}");
delete.assert();
}
#[tokio::test]
async fn test_set_rrset_empty_treats_404_as_success() {
let mut server = mockito::Server::new_async().await;
let delete = server
.mock("DELETE", "/zones/example.com/rrsets/www/A")
.with_status(404)
.with_body(r#"{"error":{"code":"not_found","message":"rrset not found"}}"#)
.create();
let provider = setup_provider(server.url());
let result = provider
.set_rrset(
"www.example.com",
DnsRecordType::A,
300,
vec![],
"example.com",
)
.await;
assert!(result.is_ok(), "set_rrset returned: {result:?}");
delete.assert();
}
#[tokio::test]
async fn test_set_rrset_type_mismatch_returns_error() {
let server = mockito::Server::new_async().await;
let provider = setup_provider(server.url());
let result = provider
.set_rrset(
"www.example.com",
DnsRecordType::A,
300,
vec![DnsRecord::TXT("foo".to_string())],
"example.com",
)
.await;
assert!(
matches!(result, Err(Error::Api(ref msg)) if msg.contains("RRSet record type mismatch")),
"expected mismatch error, got {result:?}"
);
}
#[tokio::test]
async fn test_set_rrset_rejects_tlsa() {
let server = mockito::Server::new_async().await;
let provider = setup_provider(server.url());
let result = provider
.set_rrset(
"_25._tcp.mx.example.com",
DnsRecordType::TLSA,
300,
vec![DnsRecord::TLSA(TLSARecord {
cert_usage: TlsaCertUsage::DaneEe,
selector: TlsaSelector::Spki,
matching: TlsaMatching::Sha256,
cert_data: vec![0x00],
})],
"example.com",
)
.await;
assert!(
matches!(result, Err(Error::Unsupported(ref msg)) if msg.contains("TLSA")),
"expected TLSA rejection, got {result:?}"
);
}
#[tokio::test]
async fn test_add_to_rrset_posts_add_records() {
let mut server = mockito::Server::new_async().await;
let add = server
.mock(
"POST",
"/zones/example.com/rrsets/www/A/actions/add_records",
)
.match_header("authorization", "Bearer test_token")
.match_body(Matcher::Json(json!({
"records": [{"value": "3.3.3.3"}],
"ttl": 600,
})))
.with_status(200)
.with_body(ok_action())
.create();
let provider = setup_provider(server.url());
let result = provider
.add_to_rrset(
"www.example.com",
DnsRecordType::A,
600,
vec![DnsRecord::A("3.3.3.3".parse().unwrap())],
"example.com",
)
.await;
assert!(result.is_ok(), "add_to_rrset returned: {result:?}");
add.assert();
}
#[tokio::test]
async fn test_add_to_rrset_creates_when_add_records_returns_404() {
let mut server = mockito::Server::new_async().await;
let add = server
.mock(
"POST",
"/zones/example.com/rrsets/www/A/actions/add_records",
)
.with_status(404)
.with_body(r#"{"error":{"code":"not_found","message":"rrset not found"}}"#)
.create();
let create = server
.mock("POST", "/zones/example.com/rrsets")
.match_body(Matcher::Json(json!({
"name": "www",
"type": "A",
"ttl": 600,
"records": [{"value": "3.3.3.3"}],
})))
.with_status(201)
.with_body(r#"{"rrset":{}}"#)
.create();
let provider = setup_provider(server.url());
let result = provider
.add_to_rrset(
"www.example.com",
DnsRecordType::A,
600,
vec![DnsRecord::A("3.3.3.3".parse().unwrap())],
"example.com",
)
.await;
assert!(result.is_ok(), "add_to_rrset returned: {result:?}");
add.assert();
create.assert();
}
#[tokio::test]
async fn test_add_to_rrset_empty_is_noop() {
let server = mockito::Server::new_async().await;
let provider = setup_provider(server.url());
let result = provider
.add_to_rrset(
"www.example.com",
DnsRecordType::A,
600,
vec![],
"example.com",
)
.await;
assert!(result.is_ok(), "add_to_rrset returned: {result:?}");
}
#[tokio::test]
async fn test_remove_from_rrset_posts_remove_records() {
let mut server = mockito::Server::new_async().await;
let remove = server
.mock(
"POST",
"/zones/example.com/rrsets/www/A/actions/remove_records",
)
.match_header("authorization", "Bearer test_token")
.match_body(Matcher::Json(json!({
"records": [{"value": "1.1.1.1"}],
})))
.with_status(200)
.with_body(ok_action())
.create();
let provider = setup_provider(server.url());
let result = provider
.remove_from_rrset(
"www.example.com",
DnsRecordType::A,
vec![DnsRecord::A("1.1.1.1".parse().unwrap())],
"example.com",
)
.await;
assert!(result.is_ok(), "remove_from_rrset returned: {result:?}");
remove.assert();
}
#[tokio::test]
async fn test_remove_from_rrset_treats_404_as_success() {
let mut server = mockito::Server::new_async().await;
let remove = server
.mock(
"POST",
"/zones/example.com/rrsets/www/A/actions/remove_records",
)
.with_status(404)
.with_body(r#"{"error":{"code":"not_found","message":"rrset not found"}}"#)
.create();
let provider = setup_provider(server.url());
let result = provider
.remove_from_rrset(
"www.example.com",
DnsRecordType::A,
vec![DnsRecord::A("1.1.1.1".parse().unwrap())],
"example.com",
)
.await;
assert!(result.is_ok(), "remove_from_rrset returned: {result:?}");
remove.assert();
}
#[tokio::test]
async fn test_remove_from_rrset_empty_is_noop() {
let server = mockito::Server::new_async().await;
let provider = setup_provider(server.url());
let result = provider
.remove_from_rrset("www.example.com", DnsRecordType::A, vec![], "example.com")
.await;
assert!(result.is_ok(), "remove_from_rrset returned: {result:?}");
}
#[tokio::test]
async fn test_remove_from_rrset_rejects_tlsa() {
let server = mockito::Server::new_async().await;
let provider = setup_provider(server.url());
let result = provider
.remove_from_rrset(
"_25._tcp.mx.example.com",
DnsRecordType::TLSA,
vec![DnsRecord::TLSA(TLSARecord {
cert_usage: TlsaCertUsage::DaneEe,
selector: TlsaSelector::Spki,
matching: TlsaMatching::Sha256,
cert_data: vec![0x00],
})],
"example.com",
)
.await;
assert!(
matches!(result, Err(Error::Unsupported(ref msg)) if msg.contains("TLSA")),
"expected TLSA rejection, got {result:?}"
);
}
#[tokio::test]
async fn test_list_rrset_returns_records() {
let mut server = mockito::Server::new_async().await;
let list = server
.mock("GET", "/zones/example.com/rrsets")
.match_query(Matcher::AllOf(vec![
Matcher::UrlEncoded("name".into(), "www".into()),
Matcher::UrlEncoded("type".into(), "A".into()),
Matcher::UrlEncoded("per_page".into(), "50".into()),
]))
.match_header("authorization", "Bearer test_token")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"rrsets":[{"id":"www/A","name":"www","type":"A","ttl":300,"records":[{"value":"1.1.1.1"},{"value":"2.2.2.2"}]}],"meta":{}}"#,
)
.create();
let provider = setup_provider(server.url());
let result = provider
.list_rrset("www.example.com", DnsRecordType::A, "example.com")
.await;
assert!(result.is_ok(), "list_rrset returned: {result:?}");
let records = result.unwrap();
assert_eq!(records.len(), 2);
assert!(records.contains(&DnsRecord::A("1.1.1.1".parse().unwrap())));
assert!(records.contains(&DnsRecord::A("2.2.2.2".parse().unwrap())));
list.assert();
}
#[tokio::test]
async fn test_list_rrset_returns_empty_on_404() {
let mut server = mockito::Server::new_async().await;
let list = server
.mock("GET", "/zones/example.com/rrsets")
.match_query(Matcher::AllOf(vec![
Matcher::UrlEncoded("name".into(), "missing".into()),
Matcher::UrlEncoded("type".into(), "A".into()),
Matcher::UrlEncoded("per_page".into(), "50".into()),
]))
.with_status(404)
.with_body(r#"{"error":{"code":"not_found","message":"zone not found"}}"#)
.create();
let provider = setup_provider(server.url());
let result = provider
.list_rrset("missing.example.com", DnsRecordType::A, "example.com")
.await;
assert!(result.is_ok(), "list_rrset returned: {result:?}");
assert!(result.unwrap().is_empty());
list.assert();
}
#[tokio::test]
async fn test_list_rrset_returns_empty_when_no_rrsets() {
let mut server = mockito::Server::new_async().await;
let list = server
.mock("GET", "/zones/example.com/rrsets")
.match_query(Matcher::AllOf(vec![
Matcher::UrlEncoded("name".into(), "www".into()),
Matcher::UrlEncoded("type".into(), "A".into()),
Matcher::UrlEncoded("per_page".into(), "50".into()),
]))
.with_status(200)
.with_body(r#"{"rrsets":[],"meta":{}}"#)
.create();
let provider = setup_provider(server.url());
let result = provider
.list_rrset("www.example.com", DnsRecordType::A, "example.com")
.await;
assert!(result.is_ok(), "list_rrset returned: {result:?}");
assert!(result.unwrap().is_empty());
list.assert();
}
#[tokio::test]
async fn test_list_rrset_parses_mx() {
let mut server = mockito::Server::new_async().await;
let list = server
.mock("GET", "/zones/example.com/rrsets")
.match_query(Matcher::AllOf(vec![
Matcher::UrlEncoded("name".into(), "@".into()),
Matcher::UrlEncoded("type".into(), "MX".into()),
Matcher::UrlEncoded("per_page".into(), "50".into()),
]))
.with_status(200)
.with_body(
r#"{"rrsets":[{"id":"@/MX","name":"@","type":"MX","ttl":300,"records":[{"value":"10 mail.example.com."}]}],"meta":{}}"#,
)
.create();
let provider = setup_provider(server.url());
let result = provider
.list_rrset("example.com", DnsRecordType::MX, "example.com")
.await;
assert!(result.is_ok(), "list_rrset returned: {result:?}");
let records = result.unwrap();
assert_eq!(
records,
vec![DnsRecord::MX(MXRecord {
priority: 10,
exchange: "mail.example.com".to_string(),
})]
);
list.assert();
}
#[tokio::test]
async fn test_list_rrset_parses_txt_with_quotes() {
let mut server = mockito::Server::new_async().await;
let list = server
.mock("GET", "/zones/example.com/rrsets")
.match_query(Matcher::AllOf(vec![
Matcher::UrlEncoded("name".into(), "_acme-challenge".into()),
Matcher::UrlEncoded("type".into(), "TXT".into()),
Matcher::UrlEncoded("per_page".into(), "50".into()),
]))
.with_status(200)
.with_body(
r#"{"rrsets":[{"id":"_acme-challenge/TXT","name":"_acme-challenge","type":"TXT","ttl":300,"records":[{"value":"\"hello world\""}]}],"meta":{}}"#,
)
.create();
let provider = setup_provider(server.url());
let result = provider
.list_rrset(
"_acme-challenge.example.com",
DnsRecordType::TXT,
"example.com",
)
.await;
assert!(result.is_ok(), "list_rrset returned: {result:?}");
assert_eq!(
result.unwrap(),
vec![DnsRecord::TXT("hello world".to_string())]
);
list.assert();
}
#[tokio::test]
async fn test_retry_on_429() {
let mut server = mockito::Server::new_async().await;
let rate_limited = server
.mock(
"POST",
"/zones/example.com/rrsets/www/A/actions/set_records",
)
.with_status(429)
.with_header("retry-after", "0")
.with_body(r#"{"error":{"code":"rate_limit_exceeded"}}"#)
.expect(1)
.create();
let success = server
.mock(
"POST",
"/zones/example.com/rrsets/www/A/actions/set_records",
)
.with_status(200)
.with_body(ok_action())
.expect(1)
.create();
let change_ttl = server
.mock("POST", "/zones/example.com/rrsets/www/A/actions/change_ttl")
.with_status(200)
.with_body(ok_action())
.create();
let provider = setup_provider(server.url());
let result = provider
.set_rrset(
"www.example.com",
DnsRecordType::A,
300,
vec![DnsRecord::A("1.1.1.1".parse().unwrap())],
"example.com",
)
.await;
assert!(result.is_ok(), "set_rrset returned: {result:?}");
rate_limited.assert();
success.assert();
change_ttl.assert();
}
}