#![allow(clippy::expect_used)]
use std::sync::OnceLock;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use desec::api::domains::{Domain, NewDomain};
use desec::api::rrsets::{BulkPatch, BulkPut, NewRrset, RrsetPatch};
use desec::api::tokens::{NewTokenPolicy, TokenPolicyPatch, TokenUpdate};
use desec::dyndns::{DynDnsClient, IpUpdate};
#[cfg(feature = "probes")]
use desec::probes::{Expect, PROBES, Probe};
use desec::{Client, DjangoDuration, RecordType, Result, Subname};
use tokio::runtime::Runtime;
use tokio::sync::{Mutex, OnceCell};
const SCRATCH_PREFIX: &str = "desec-rs-test";
fn runtime() -> &'static Runtime {
static RUNTIME: OnceLock<Runtime> = OnceLock::new();
RUNTIME.get_or_init(|| {
Runtime::new().expect("a tokio runtime can be built")
})
}
fn live<F: Future<Output = ()>>(body: F) {
runtime().block_on(body);
}
fn env(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|value| !value.is_empty())
}
fn parent_zone() -> String {
env("DESEC_TEST_PARENT").unwrap_or_else(|| "desec-rs-test.shine.town".to_owned())
}
fn dyndns_parent_zone() -> String {
env("DESEC_TEST_DYNDNS_PARENT").unwrap_or_else(|| "dedyn.io".to_owned())
}
fn domain_write_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
async fn create_domain(new: &NewDomain) -> Result<Domain> {
let _guard = domain_write_lock().lock().await;
client().await.domains().create(new).await
}
async fn delete_domain(name: &str) -> Result<()> {
let _guard = domain_write_lock().lock().await;
client().await.domains().delete(name).await
}
async fn client() -> &'static Client {
static CLIENT: OnceCell<Client> = OnceCell::const_new();
CLIENT
.get_or_init(|| async {
let _ = tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_test_writer()
.try_init();
let token = env("DESEC_TOKEN").unwrap_or_else(|| {
panic!(
"DESEC_TOKEN is not set.\n\
These tests talk to the real API, so they need a token from a test \
account with perm_create_domain, perm_delete_domain and \
perm_manage_tokens.\n\
Run them with: DESEC_TOKEN=… just live-test"
)
});
Client::builder()
.token(token)
.max_rate_limit_wait(Duration::from_secs(180))
.timeout(Duration::from_secs(30))
.build()
.expect("client configuration is valid")
})
.await
}
struct Scratch {
name: String,
minimum_ttl: u32,
}
fn scratch_name(label: &str, parent: &str) -> String {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock is after the epoch")
.as_nanos();
format!("{SCRATCH_PREFIX}-{label}-{stamp:x}.{parent}")
}
const SWEEP_GRACE: Duration = Duration::from_secs(3600);
fn scratch_age(name: &str, now: Duration) -> Option<Duration> {
let (first, _parent) = name.split_once('.')?;
let (_label, stamp) = first
.strip_prefix(SCRATCH_PREFIX)?
.strip_prefix('-')?
.rsplit_once('-')?;
let minted = Duration::from_nanos(u64::try_from(u128::from_str_radix(stamp, 16).ok()?).ok()?);
Some(now.saturating_sub(minted))
}
#[test]
fn the_sweep_recognises_only_its_own_leftovers() {
let now = Duration::from_secs(10_000);
let age = |name: &str| scratch_age(name, now);
let minted = scratch_name("probes", "example.test");
assert!(age(&minted).is_some(), "{minted}");
assert_eq!(age("desec-rs-test.shine.town"), None);
assert_eq!(age("desec-rs-test"), None);
assert_eq!(age("example.com"), None);
assert_eq!(age("desec-rs-testing.example.com"), None);
assert_eq!(age("edns-webhook-test-probes-1.example.com"), None);
assert_eq!(age("desec-rs-test-probes-zzz.example.com"), None);
assert_eq!(
age("desec-rs-test-probes-1.example.com"),
Some(now - Duration::from_nanos(1))
);
assert_eq!(
scratch_age("desec-rs-test-probes-1.example.com", Duration::ZERO),
Some(Duration::ZERO)
);
}
impl Scratch {
async fn create(label: &str) -> Self {
Self::create_under(label, &parent_zone()).await
}
async fn create_under(label: &str, parent: &str) -> Self {
sweep_leftovers().await;
let name = scratch_name(label, parent);
let domain = create_domain(&NewDomain::new(&name))
.await
.unwrap_or_else(|err| panic!("could not create scratch domain {name}: {err}"));
assert_eq!(domain.name, name);
Self {
name,
minimum_ttl: domain.minimum_ttl,
}
}
async fn destroy(self) {
delete_domain(&self.name)
.await
.unwrap_or_else(|err| panic!("could not delete scratch domain {}: {err}", self.name));
}
}
async fn sweep_leftovers() {
static SWEPT: OnceCell<()> = OnceCell::const_new();
SWEPT
.get_or_init(|| async {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock is after the epoch");
let client = client().await;
let stale: Vec<_> = client
.domains()
.list()
.all()
.await
.expect("could not list domains")
.into_iter()
.filter(|domain| {
scratch_age(&domain.name, now).is_some_and(|age| age > SWEEP_GRACE)
})
.map(|domain| domain.name)
.collect();
for name in stale {
eprintln!("sweeping leftover scratch domain {name}");
if let Err(err) = delete_domain(&name).await {
eprintln!(" could not delete {name}: {err}");
}
}
})
.await;
}
fn sub(label: &str) -> Subname {
label.parse().expect("test subname is valid")
}
#[test]
#[ignore = "talks to the real API; run with `just live-test`"]
fn account_information_deserializes() {
live(async {
let account = client().await.account().get().await.expect("read account");
assert!(account.email.contains('@'), "{:?}", account.email);
assert!(
account.domains_under_management.is_some(),
"domains_under_management was absent: {account:?}"
);
assert!(account.limit_domains.is_some(), "{account:?}");
});
}
#[test]
#[ignore = "talks to the real API; run with `just live-test`"]
fn domain_lifecycle() {
live(async {
let client = client().await;
let scratch = Scratch::create("domain").await;
let fetched = client.domains().get(&scratch.name).await.expect("get");
assert!(!fetched.keys.is_empty(), "no DNSSEC keys: {fetched:?}");
assert!(
fetched.keys.iter().any(|key| key.managed),
"no managed key: {:?}",
fetched.keys
);
assert!(fetched.minimum_ttl > 0);
let owner = client
.domains()
.owner_of(&format!("_acme-challenge.{}", scratch.name))
.await
.expect("owns_qname query");
assert_eq!(
owner.map(|domain| domain.name).as_deref(),
Some(scratch.name.as_str())
);
let zonefile = client
.domains()
.zonefile(&scratch.name)
.await
.expect("export");
assert!(zonefile.contains(&scratch.name), "{zonefile}");
assert!(zonefile.contains("SOA"), "{zonefile}");
scratch.destroy().await;
});
}
#[test]
#[ignore = "talks to the real API; run with `just live-test`"]
fn a_deleted_domain_is_gone() {
live(async {
let client = client().await;
let scratch = Scratch::create("gone").await;
let name = scratch.name.clone();
scratch.destroy().await;
assert!(
client
.domains()
.try_get(&name)
.await
.expect("query")
.is_none(),
"domain still readable after deletion"
);
delete_domain(&name).await.expect("second delete");
});
}
#[test]
#[ignore = "talks to the real API; run with `just live-test`"]
fn a_zonefile_can_be_imported_on_creation() {
live(async {
let client = client().await;
sweep_leftovers().await;
let name = scratch_name("import", &parent_zone());
let zonefile = format!("www.{name}. 3600 IN A 127.0.0.1\n");
let domain = create_domain(&NewDomain::new(&name).zonefile(zonefile))
.await
.expect("create with zonefile");
let imported = client
.rrsets(&domain.name)
.get(&sub("www"), &RecordType::A)
.await
.expect("imported record");
assert_eq!(imported.records, ["127.0.0.1"]);
delete_domain(&name).await.expect("cleanup");
});
}
#[test]
#[ignore = "talks to the real API; run with `just live-test`"]
fn rrset_lifecycle() {
live(async {
let client = client().await;
let scratch = Scratch::create("rrset").await;
let rrsets = client.rrsets(&scratch.name);
let ttl = scratch.minimum_ttl;
let created = rrsets
.create(&NewRrset::new(
sub("www"),
RecordType::A,
ttl,
["127.0.0.1"],
))
.await
.expect("create");
assert_eq!(created.records, ["127.0.0.1"]);
assert_eq!(created.name, format!("www.{}.", scratch.name));
let filtered = rrsets
.list()
.subname(&sub("www"))
.record_type(&RecordType::A)
.all()
.await
.expect("filtered list");
assert_eq!(filtered.len(), 1, "{filtered:?}");
let doubled = ttl * 2;
let patched = rrsets
.patch(&sub("www"), &RecordType::A, &RrsetPatch::new().ttl(doubled))
.await
.expect("ttl-only patch");
assert_eq!(patched.ttl, doubled);
assert_eq!(patched.records, ["127.0.0.1"], "records were disturbed");
let replaced = rrsets
.replace(&sub("www"), &RecordType::A, ttl, ["10.0.0.1", "10.0.0.2"])
.await
.expect("put");
let mut records = replaced.records.clone();
records.sort();
assert_eq!(records, ["10.0.0.1", "10.0.0.2"]);
rrsets
.delete(&sub("www"), &RecordType::A)
.await
.expect("delete");
assert!(
rrsets
.try_get(&sub("www"), &RecordType::A)
.await
.expect("query")
.is_none()
);
scratch.destroy().await;
});
}
#[test]
#[ignore = "talks to the real API; run with `just live-test`"]
fn an_apex_rrset_survives_a_read_modify_write() {
live(async {
let client = client().await;
let scratch = Scratch::create("apex").await;
let rrsets = client.rrsets(&scratch.name);
let ttl = scratch.minimum_ttl;
rrsets
.create(&NewRrset::at_apex(RecordType::TXT, ttl, [r#""one""#]))
.await
.expect("create at apex");
let read = rrsets
.get(&Subname::apex(), &RecordType::TXT)
.await
.expect("read apex");
assert!(
read.subname.is_apex(),
"apex came back as {:?}",
read.subname
);
assert_eq!(read.name, format!("{}.", scratch.name));
let patched = rrsets
.patch(
&read.subname,
&read.record_type,
&RrsetPatch::new().records([r#""two""#]),
)
.await
.expect("patch at apex after reading it back");
assert_eq!(patched.records, [r#""two""#]);
rrsets
.delete(&read.subname, &RecordType::TXT)
.await
.expect("delete at apex");
scratch.destroy().await;
});
}
#[test]
#[ignore = "talks to the real API; run with `just live-test`"]
fn bulk_rrset_operations() {
live(async {
let client = client().await;
let scratch = Scratch::create("bulk").await;
let rrsets = client.rrsets(&scratch.name);
let ttl = scratch.minimum_ttl;
let created = rrsets
.create_bulk(&[
NewRrset::new(sub("a"), RecordType::A, ttl, ["127.0.0.1"]),
NewRrset::new(sub("b"), RecordType::TXT, ttl, [r#""b""#]),
])
.await
.expect("bulk create");
assert_eq!(created.len(), 2);
rrsets
.patch_bulk(&[
BulkPatch::new(sub("a"), RecordType::A).ttl(ttl * 2),
BulkPatch::new(sub("b"), RecordType::TXT).records([r#""changed""#]),
BulkPatch::new(sub("c"), RecordType::AAAA)
.ttl(ttl)
.records(["2001:db8::1"]),
])
.await
.expect("bulk patch");
let all = rrsets.list().all().await.expect("list");
let a = all
.iter()
.find(|rrset| rrset.subname == sub("a") && rrset.record_type == RecordType::A)
.expect("a still present");
assert_eq!(a.ttl, ttl * 2);
assert_eq!(
a.records,
["127.0.0.1"],
"a TTL-only bulk patch changed records"
);
rrsets
.replace_bulk(&[BulkPut::new(sub("a"), RecordType::A, ttl, ["192.0.2.1"])])
.await
.expect("bulk put");
rrsets
.delete_bulk([
(sub("a"), RecordType::A),
(sub("b"), RecordType::TXT),
(sub("c"), RecordType::AAAA),
])
.await
.expect("bulk delete");
let remaining = rrsets.list().all().await.expect("list");
for (subname, record_type) in [
(sub("a"), RecordType::A),
(sub("b"), RecordType::TXT),
(sub("c"), RecordType::AAAA),
] {
assert!(
!remaining
.iter()
.any(|rrset| rrset.subname == subname && rrset.record_type == record_type),
"{subname}/{record_type} survived the bulk delete"
);
}
scratch.destroy().await;
});
}
#[cfg(feature = "probes")]
fn render(records: &[String]) -> String {
match records {
[one] => one.clone(),
many => format!("{many:?}"),
}
}
#[cfg(feature = "probes")]
async fn submit_probes(
rrsets: &desec::api::rrsets::RrsetsApi<'_>,
ttl: u32,
) -> (Vec<&'static Probe>, Vec<(&'static Probe, String)>) {
fn as_rrset(probe: &Probe, ttl: u32) -> NewRrset {
NewRrset::new(
probe.subname.parse().expect("probe subname is valid"),
probe
.record_type
.parse()
.expect("probe record type is valid"),
ttl,
[probe.wire],
)
}
let mut pending: Vec<&'static Probe> = PROBES.iter().collect();
let mut rejected: Vec<(&'static Probe, String)> = Vec::new();
for round in 1..=3 {
let batch: Vec<NewRrset> = pending.iter().map(|p| as_rrset(p, ttl)).collect();
let Err(err) = rrsets.create_bulk(&batch).await else {
return (pending, rejected);
};
let items = err.api_error().and_then(desec::ApiError::bulk_items);
let Some(items) = items.filter(|items| items.len() == pending.len()) else {
eprintln!("round {round}: bulk rejection is not positional ({err}); one at a time");
break;
};
let mut survivors = Vec::new();
for (probe, item) in pending.iter().zip(items) {
let messages = item.messages();
if messages.is_empty() {
survivors.push(*probe);
} else {
rejected.push((*probe, messages.join("; ")));
}
}
if survivors.len() == pending.len() {
eprintln!("round {round}: rejected, but no item was blamed ({err}); one at a time");
break;
}
pending = survivors;
}
let mut accepted = Vec::new();
for probe in pending {
match rrsets.create_bulk(&[as_rrset(probe, ttl)]).await {
Ok(_) => accepted.push(probe),
Err(err) => rejected.push((probe, err.to_string())),
}
}
(accepted, rejected)
}
#[cfg(feature = "probes")]
#[test]
#[ignore = "talks to the real API; run with `just live-test`"]
fn record_values_come_back_in_the_form_desec_chose() {
live(async {
let client = client().await;
let scratch = Scratch::create("probes").await;
let (accepted, refused) = {
let rrsets = client.rrsets(&scratch.name);
let (accepted, refused) = submit_probes(&rrsets, scratch.minimum_ttl).await;
let stored = rrsets.list().all().await.expect("could not list rrsets");
let mut observed = Vec::new();
for probe in accepted {
let subname: Subname = probe.subname.parse().expect("probe subname is valid");
let record_type: RecordType =
probe.record_type.parse().expect("probe type is valid");
let found = stored
.iter()
.find(|rr| rr.subname == subname && rr.record_type == record_type)
.map(|rr| render(&rr.records));
observed.push((probe, found));
}
(observed, refused)
};
scratch.destroy().await;
let mut unknown = Vec::new();
let mut failures: Vec<String> = Vec::new();
for (probe, stored) in &accepted {
let Some(stored) = stored else {
failures.push(format!("{}: accepted, but absent from the zone", probe.id));
continue;
};
match &probe.expect {
Expect::Unknown => unknown.push((probe, stored.clone())),
Expect::Verbatim if stored == probe.wire => {}
Expect::Verbatim => failures.push(format!(
"{}: expected the value back unchanged\n sent {}\n stored {stored}",
probe.id, probe.wire
)),
Expect::Canonical(want) if stored == want => {}
Expect::Canonical(want) => failures.push(format!(
"{}: deSEC no longer canonicalizes this the same way\n \
sent {}\n expected {want}\n stored {stored}",
probe.id, probe.wire
)),
Expect::Rejected(want) => failures.push(format!(
"{}: expected a refusal ({want}), but it stored {stored}",
probe.id
)),
}
}
for (probe, message) in &refused {
match &probe.expect {
Expect::Unknown => unknown.push((probe, format!("REFUSED: {message}"))),
Expect::Rejected(want) if message.contains(want) => {}
Expect::Rejected(want) => failures.push(format!(
"{}: refused for a different reason\n expected {want}\n got {message}",
probe.id
)),
_ => failures.push(format!("{}: unexpectedly refused: {message}", probe.id)),
}
}
if !unknown.is_empty() {
println!(
"\n{} probe(s) have no recorded expectation yet.",
unknown.len()
);
println!("Promote each in crates/desec/src/probes.rs, then commit.\n");
println!("| id | sent | stored |");
println!("| --- | --- | --- |");
for (probe, stored) in &unknown {
println!("| `{}` | `{}` | `{stored}` |", probe.id, probe.wire);
}
println!();
}
assert!(
failures.is_empty(),
"deSEC's canonicalization no longer matches what src/probes.rs records:\n\n{}\n",
failures.join("\n\n")
);
});
}
#[test]
#[ignore = "talks to the real API; run with `just live-test`"]
fn validation_errors_keep_their_field_names() {
live(async {
let client = client().await;
let scratch = Scratch::create("errors").await;
let rrsets = client.rrsets(&scratch.name);
let err = rrsets
.create(&NewRrset::new(sub("low"), RecordType::A, 1, ["127.0.0.1"]))
.await
.expect_err("a TTL under the minimum is rejected");
assert!(err.is_validation(), "{err:?}");
let body = err.api_error().expect("an error document");
assert!(
body.field("ttl").is_some(),
"expected a ttl field error, got {body}"
);
let err = rrsets
.create(&NewRrset::at_apex(
RecordType::CNAME,
scratch.minimum_ttl,
["example.com."],
))
.await
.expect_err("a CNAME at the apex is rejected");
assert!(err.is_validation(), "{err:?}");
assert!(
!err.api_error().expect("document").messages().is_empty(),
"no messages in {err:?}"
);
scratch.destroy().await;
});
}
#[test]
#[ignore = "talks to the real API; run with `just live-test`"]
fn an_unknown_domain_is_not_found() {
live(async {
let absent = scratch_name("absent", &parent_zone());
let err = client()
.await
.domains()
.get(&absent)
.await
.expect_err("an unowned domain is not readable");
assert!(err.is_not_found(), "{err:?}");
});
}
#[test]
#[ignore = "talks to the real API; run with `just live-test`"]
fn token_lifecycle() {
live(async {
let client = client().await;
let created = client
.tokens()
.create(
&TokenUpdate::new()
.name("desec-rs live test")
.perm_create_domain(true)
.perm_delete_domain(true)
.max_age(DjangoDuration::hours(1)),
)
.await
.expect("create token");
assert!(created.perm_create_domain, "{created:?}");
assert!(created.perm_delete_domain, "{created:?}");
assert_eq!(created.max_age, Some(DjangoDuration::hours(1)));
assert!(created.token.is_some(), "the secret is disclosed once");
assert_eq!(created.mfa, None, "an API token has a null mfa");
let fetched = client.tokens().get(created.id).await.expect("get token");
assert!(
fetched.token.is_none(),
"the secret must not be re-disclosed"
);
let cleared = client
.tokens()
.patch(
created.id,
&TokenUpdate::new().clear_max_age().perm_delete_domain(false),
)
.await
.expect("patch token");
assert_eq!(cleared.max_age, None, "max_age was not cleared");
assert!(
!cleared.perm_delete_domain,
"a false permission was dropped"
);
assert!(
cleared.perm_create_domain,
"an omitted field was not preserved"
);
let listed = client.tokens().list().all().await.expect("list tokens");
assert!(listed.iter().any(|token| token.id == created.id));
client
.tokens()
.delete(created.id)
.await
.expect("delete token");
assert!(
client
.tokens()
.try_get(created.id)
.await
.expect("query")
.is_none()
);
});
}
#[test]
#[ignore = "talks to the real API; run with `just live-test`"]
fn token_policies_can_revoke_write_permission() {
live(async {
let client = client().await;
let scratch = Scratch::create("policy").await;
let token = client
.tokens()
.create(&TokenUpdate::new().name("desec-rs live policy test"))
.await
.expect("create token");
let policies = client.tokens().policies(token.id);
let default = policies
.create(&NewTokenPolicy::default_policy(false))
.await
.expect("create default policy");
assert!(default.is_default(), "{default:?}");
let scoped = policies
.create(&NewTokenPolicy::for_domain(&scratch.name, true).record_type(RecordType::TXT))
.await
.expect("create scoped policy");
assert!(scoped.perm_write);
assert_eq!(scoped.domain.as_deref(), Some(scratch.name.as_str()));
let revoked = policies
.patch(scoped.id, &TokenPolicyPatch::new().perm_write(false))
.await
.expect("revoke write");
assert!(!revoked.perm_write, "write permission was not revoked");
let renamed = policies
.patch(scoped.id, &TokenPolicyPatch::new().any_record_type())
.await
.expect("widen the selector");
assert!(!renamed.perm_write, "an untouched permission changed");
assert_eq!(renamed.record_type, None);
let listed = policies.list().await.expect("list policies");
assert_eq!(listed.len(), 2, "{listed:?}");
policies.delete(scoped.id).await.expect("delete scoped");
policies.delete(default.id).await.expect("delete default");
client
.tokens()
.delete(token.id)
.await
.expect("delete token");
scratch.destroy().await;
});
}
#[test]
#[ignore = "talks to the real API; run with `just live-test`"]
fn a_dyndns_update_sets_the_address_records() {
live(async {
let token = env("DESEC_TOKEN").expect("DESEC_TOKEN is set");
let scratch = Scratch::create_under("dyndns", &dyndns_parent_zone()).await;
let dyndns = DynDnsClient::builder()
.token(token)
.max_rate_limit_wait(Duration::from_secs(180))
.build()
.expect("dyndns client configuration is valid");
let body = dyndns
.update(&scratch.name)
.ipv4(IpUpdate::set(["192.0.2.4"]))
.ipv6(IpUpdate::Remove)
.send_body()
.await
.expect("dyndns update");
assert_eq!(body.trim(), "good", "unexpected dynDNS response: {body}");
let a = client()
.await
.rrsets(&scratch.name)
.get(&Subname::apex(), &RecordType::A)
.await
.expect("the A record the update wrote");
assert_eq!(a.records, ["192.0.2.4"]);
scratch.destroy().await;
});
}