#[cfg(test)]
mod tests {
use crate::{
DnsRecord, DnsRecordType, Error, MXRecord, providers::tencentcloud::TencentCloudProvider,
};
use mockito::{Matcher, Mock, ServerGuard};
use std::time::Duration;
fn setup_provider(endpoint: &str) -> TencentCloudProvider {
TencentCloudProvider::new(
"test_secret_id",
"test_secret_key",
None::<&str>,
None::<&str>,
Some(Duration::from_secs(5)),
)
.unwrap()
.with_endpoint(endpoint)
}
const DOMAIN_LIST_BODY: &str = r#"{
"Response": {
"DomainList": [
{"DomainId": 12345, "Name": "example.com", "Punycode": "example.com"}
],
"DomainCountInfo": {"AllTotal": 1}
}
}"#;
const EMPTY_OK_BODY: &str = r#"{"Response":{"RequestId":"x"}}"#;
fn mock_domain_list(server: &mut ServerGuard) -> Mock {
server
.mock("POST", "/")
.match_header("X-TC-Action", "DescribeDomainList")
.with_status(200)
.with_body(DOMAIN_LIST_BODY)
.expect_at_least(1)
.create()
}
fn mock_describe_records(server: &mut ServerGuard, body: &str) -> Mock {
server
.mock("POST", "/")
.match_header("X-TC-Action", "DescribeRecordList")
.with_status(200)
.with_body(body)
.create()
}
fn mock_create(server: &mut ServerGuard, partial_body: serde_json::Value) -> Mock {
server
.mock("POST", "/")
.match_header("X-TC-Action", "CreateRecord")
.match_body(Matcher::PartialJson(partial_body))
.with_status(200)
.with_body(EMPTY_OK_BODY)
.create()
}
fn mock_delete(server: &mut ServerGuard, record_id: u64) -> Mock {
server
.mock("POST", "/")
.match_header("X-TC-Action", "DeleteRecord")
.match_body(Matcher::PartialJson(serde_json::json!({
"Domain": "example.com",
"RecordId": record_id,
})))
.with_status(200)
.with_body(EMPTY_OK_BODY)
.create()
}
#[tokio::test]
async fn set_rrset_creates_when_owner_is_empty() {
let mut server = mockito::Server::new_async().await;
let domain = mock_domain_list(&mut server);
let list = mock_describe_records(&mut server, r#"{"Response":{"RecordList":[]}}"#);
let create_1 = mock_create(
&mut server,
serde_json::json!({
"Domain": "example.com",
"SubDomain": "host",
"RecordType": "A",
"RecordLineId": "0",
"Value": "1.1.1.1",
"TTL": 300,
}),
);
let create_2 = mock_create(
&mut server,
serde_json::json!({
"Domain": "example.com",
"SubDomain": "host",
"RecordType": "A",
"RecordLineId": "0",
"Value": "2.2.2.2",
"TTL": 300,
}),
);
let provider = setup_provider(server.url().as_str());
let result = provider
.set_rrset(
"host.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 failed: {result:?}");
domain.assert();
list.assert();
create_1.assert();
create_2.assert();
}
#[tokio::test]
async fn set_rrset_is_noop_when_already_matches() {
let mut server = mockito::Server::new_async().await;
let domain = mock_domain_list(&mut server);
let list = mock_describe_records(
&mut server,
r#"{"Response":{"RecordList":[
{"RecordId":111,"Name":"host","Type":"A","Value":"1.1.1.1"}
]}}"#,
);
let provider = setup_provider(server.url().as_str());
let result = provider
.set_rrset(
"host.example.com",
DnsRecordType::A,
300,
vec![DnsRecord::A("1.1.1.1".parse().unwrap())],
"example.com",
)
.await;
assert!(result.is_ok(), "set_rrset failed: {result:?}");
domain.assert();
list.assert();
}
#[tokio::test]
async fn set_rrset_deletes_extras_and_creates_missing() {
let mut server = mockito::Server::new_async().await;
let domain = mock_domain_list(&mut server);
let list = mock_describe_records(
&mut server,
r#"{"Response":{"RecordList":[
{"RecordId":111,"Name":"host","Type":"A","Value":"1.1.1.1"},
{"RecordId":222,"Name":"host","Type":"A","Value":"9.9.9.9"}
]}}"#,
);
let delete_stale = mock_delete(&mut server, 222);
let create_new = mock_create(
&mut server,
serde_json::json!({
"Domain": "example.com",
"SubDomain": "host",
"RecordType": "A",
"RecordLineId": "0",
"Value": "8.8.8.8",
"TTL": 300,
}),
);
let provider = setup_provider(server.url().as_str());
let result = provider
.set_rrset(
"host.example.com",
DnsRecordType::A,
300,
vec![
DnsRecord::A("1.1.1.1".parse().unwrap()),
DnsRecord::A("8.8.8.8".parse().unwrap()),
],
"example.com",
)
.await;
assert!(result.is_ok(), "set_rrset failed: {result:?}");
domain.assert();
list.assert();
delete_stale.assert();
create_new.assert();
}
#[tokio::test]
async fn set_rrset_empty_vec_deletes_all_of_type() {
let mut server = mockito::Server::new_async().await;
let domain = mock_domain_list(&mut server);
let list = mock_describe_records(
&mut server,
r#"{"Response":{"RecordList":[
{"RecordId":111,"Name":"host","Type":"A","Value":"1.1.1.1"},
{"RecordId":222,"Name":"host","Type":"A","Value":"2.2.2.2"}
]}}"#,
);
let delete_1 = mock_delete(&mut server, 111);
let delete_2 = mock_delete(&mut server, 222);
let provider = setup_provider(server.url().as_str());
let result = provider
.set_rrset(
"host.example.com",
DnsRecordType::A,
300,
vec![],
"example.com",
)
.await;
assert!(result.is_ok(), "set_rrset failed: {result:?}");
domain.assert();
list.assert();
delete_1.assert();
delete_2.assert();
}
#[tokio::test]
async fn add_to_rrset_empty_vec_is_noop() {
let server = mockito::Server::new_async().await;
let provider = setup_provider(server.url().as_str());
let result = provider
.add_to_rrset(
"host.example.com",
DnsRecordType::A,
300,
vec![],
"example.com",
)
.await;
assert!(result.is_ok(), "add_to_rrset failed: {result:?}");
}
#[tokio::test]
async fn add_to_rrset_skips_existing_values() {
let mut server = mockito::Server::new_async().await;
let domain = mock_domain_list(&mut server);
let list = mock_describe_records(
&mut server,
r#"{"Response":{"RecordList":[
{"RecordId":111,"Name":"host","Type":"A","Value":"1.1.1.1"}
]}}"#,
);
let create_new = mock_create(
&mut server,
serde_json::json!({
"Domain": "example.com",
"SubDomain": "host",
"RecordType": "A",
"RecordLineId": "0",
"Value": "2.2.2.2",
"TTL": 300,
}),
);
let provider = setup_provider(server.url().as_str());
let result = provider
.add_to_rrset(
"host.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(), "add_to_rrset failed: {result:?}");
domain.assert();
list.assert();
create_new.assert();
}
#[tokio::test]
async fn remove_from_rrset_empty_vec_is_noop() {
let server = mockito::Server::new_async().await;
let provider = setup_provider(server.url().as_str());
let result = provider
.remove_from_rrset("host.example.com", DnsRecordType::A, vec![], "example.com")
.await;
assert!(result.is_ok(), "remove_from_rrset failed: {result:?}");
}
#[tokio::test]
async fn remove_from_rrset_deletes_only_matching() {
let mut server = mockito::Server::new_async().await;
let domain = mock_domain_list(&mut server);
let list = mock_describe_records(
&mut server,
r#"{"Response":{"RecordList":[
{"RecordId":111,"Name":"host","Type":"A","Value":"1.1.1.1"},
{"RecordId":222,"Name":"host","Type":"A","Value":"2.2.2.2"}
]}}"#,
);
let delete_match = mock_delete(&mut server, 222);
let provider = setup_provider(server.url().as_str());
let result = provider
.remove_from_rrset(
"host.example.com",
DnsRecordType::A,
vec![
DnsRecord::A("2.2.2.2".parse().unwrap()),
DnsRecord::A("9.9.9.9".parse().unwrap()),
],
"example.com",
)
.await;
assert!(result.is_ok(), "remove_from_rrset failed: {result:?}");
domain.assert();
list.assert();
delete_match.assert();
}
#[tokio::test]
async fn list_rrset_returns_decoded_records() {
let mut server = mockito::Server::new_async().await;
let domain = mock_domain_list(&mut server);
let list = mock_describe_records(
&mut server,
r#"{"Response":{"RecordList":[
{"RecordId":111,"Name":"mail","Type":"MX","Value":"mx1.example.com.","MX":10},
{"RecordId":222,"Name":"mail","Type":"MX","Value":"mx2.example.com.","MX":20}
]}}"#,
);
let provider = setup_provider(server.url().as_str());
let result = provider
.list_rrset("mail.example.com", DnsRecordType::MX, "example.com")
.await;
assert!(result.is_ok(), "list_rrset failed: {result:?}");
let records = result.unwrap();
assert_eq!(records.len(), 2);
assert!(records.contains(&DnsRecord::MX(MXRecord {
exchange: "mx1.example.com".to_string(),
priority: 10,
})));
assert!(records.contains(&DnsRecord::MX(MXRecord {
exchange: "mx2.example.com".to_string(),
priority: 20,
})));
domain.assert();
list.assert();
}
#[tokio::test]
async fn list_rrset_empty_on_no_data_error() {
let mut server = mockito::Server::new_async().await;
let domain = mock_domain_list(&mut server);
let list = server
.mock("POST", "/")
.match_header("X-TC-Action", "DescribeRecordList")
.with_status(200)
.with_body(
r#"{"Response":{"Error":{"Code":"ResourceNotFound.NoDataOfRecord","Message":"no data"}}}"#,
)
.create();
let provider = setup_provider(server.url().as_str());
let result = provider
.list_rrset("missing.example.com", DnsRecordType::A, "example.com")
.await;
assert!(result.is_ok(), "list_rrset failed: {result:?}");
assert!(result.unwrap().is_empty());
domain.assert();
list.assert();
}
#[tokio::test]
async fn set_rrset_rejects_type_mismatch() {
let server = mockito::Server::new_async().await;
let provider = setup_provider(server.url().as_str());
let result = provider
.set_rrset(
"host.example.com",
DnsRecordType::A,
300,
vec![DnsRecord::AAAA("2001:db8::1".parse().unwrap())],
"example.com",
)
.await;
assert!(matches!(result, Err(Error::Api(msg)) if msg.contains("mismatch")));
}
#[tokio::test]
async fn set_rrset_rejects_tlsa() {
let server = mockito::Server::new_async().await;
let provider = setup_provider(server.url().as_str());
let result = provider
.set_rrset(
"_443._tcp.example.com",
DnsRecordType::TLSA,
300,
vec![],
"example.com",
)
.await;
assert!(matches!(result, Err(Error::Unsupported(msg)) if msg.contains("TLSA")));
}
#[tokio::test]
async fn set_rrset_only_touches_target_type() {
let mut server = mockito::Server::new_async().await;
let domain = mock_domain_list(&mut server);
let list = server
.mock("POST", "/")
.match_header("X-TC-Action", "DescribeRecordList")
.match_body(Matcher::PartialJson(serde_json::json!({
"RecordType": "A",
})))
.with_status(200)
.with_body(
r#"{"Response":{"RecordList":[
{"RecordId":111,"Name":"host","Type":"A","Value":"1.1.1.1"}
]}}"#,
)
.create();
let delete_a = mock_delete(&mut server, 111);
let provider = setup_provider(server.url().as_str());
let result = provider
.set_rrset(
"host.example.com",
DnsRecordType::A,
300,
vec![],
"example.com",
)
.await;
assert!(result.is_ok(), "set_rrset failed: {result:?}");
domain.assert();
list.assert();
delete_a.assert();
}
#[tokio::test]
async fn set_rrset_idempotent_on_repeat() {
let mut server = mockito::Server::new_async().await;
let domain = mock_domain_list(&mut server);
let list = server
.mock("POST", "/")
.match_header("X-TC-Action", "DescribeRecordList")
.with_status(200)
.with_body(
r#"{"Response":{"RecordList":[
{"RecordId":111,"Name":"host","Type":"TXT","Value":"hello"}
]}}"#,
)
.expect(2)
.create();
let provider = setup_provider(server.url().as_str());
for _ in 0..2 {
let result = provider
.set_rrset(
"host.example.com",
DnsRecordType::TXT,
300,
vec![DnsRecord::TXT("hello".to_string())],
"example.com",
)
.await;
assert!(result.is_ok(), "set_rrset failed: {result:?}");
}
domain.assert();
list.assert();
}
#[tokio::test]
#[ignore = "Requires TENCENTCLOUD_SECRET_ID, TENCENTCLOUD_SECRET_KEY, and an origin/fqdn"]
async fn integration_test() {
let secret_id = std::env::var("TENCENTCLOUD_SECRET_ID").unwrap_or_default();
let secret_key = std::env::var("TENCENTCLOUD_SECRET_KEY").unwrap_or_default();
let origin = std::env::var("TENCENTCLOUD_ORIGIN").unwrap_or_default();
let fqdn = std::env::var("TENCENTCLOUD_FQDN").unwrap_or_default();
assert!(!secret_id.is_empty());
assert!(!secret_key.is_empty());
assert!(!origin.is_empty());
assert!(!fqdn.is_empty());
let provider = TencentCloudProvider::new(
secret_id,
secret_key,
None::<&str>,
None::<&str>,
Some(Duration::from_secs(30)),
)
.unwrap();
let set_result = provider
.set_rrset(
&fqdn,
DnsRecordType::A,
600,
vec![DnsRecord::A("1.1.1.1".parse().unwrap())],
&origin,
)
.await;
assert!(set_result.is_ok(), "set failed: {set_result:?}");
let add_result = provider
.add_to_rrset(
&fqdn,
DnsRecordType::A,
600,
vec![DnsRecord::A("8.8.8.8".parse().unwrap())],
&origin,
)
.await;
assert!(add_result.is_ok(), "add failed: {add_result:?}");
let listed = provider.list_rrset(&fqdn, DnsRecordType::A, &origin).await;
assert!(listed.is_ok(), "list failed: {listed:?}");
let remove_result = provider
.remove_from_rrset(
&fqdn,
DnsRecordType::A,
vec![DnsRecord::A("8.8.8.8".parse().unwrap())],
&origin,
)
.await;
assert!(remove_result.is_ok(), "remove failed: {remove_result:?}");
let delete_result = provider
.set_rrset(&fqdn, DnsRecordType::A, 600, vec![], &origin)
.await;
assert!(delete_result.is_ok(), "delete failed: {delete_result:?}");
}
}