Files
runway/crates/runway-caldav/tests/live.rs
T
connor f8e4a497fa Add the CalDAV client and a CLI to drive it
Discovery is the three PROPFINDs RFC 4791 describes rather than a walk
through likely URLs. Queries use time-range, which v1 never did -- it
fetched whole calendars and filtered in the browser on every view change.
Every write states a precondition, so a stale ETag produces a Conflict a
caller can act on instead of silently destroying somebody's edit.

XML goes through quick-xml with namespace resolution. v1 matched prefixes
with six regexes tried in sequence and recompiled inside the loop; there
is a fixture here that is the same document under different prefixes, and
it parses identically.

Protocol parsing is split from transport so it can be tested against
responses recorded from a real Baikal -- including the second propstat
carrying 404s, which is what makes "this calendar has no colour" different
from "this calendar has an empty colour".

Live tests run against a real server, never a mock. tests/baikal/run.sh
starts a container, walks Baikal's install wizard, and runs them; each
test builds and destroys its own collection, so pointing it at a real
server touches nothing that was already there. They cover discovery,
round-trip, stale-ETag conflict, duplicate create, delete, time-range
filtering, a series returned whole with its override, and writing every
synthetic golden fixture to the server and reading it back.

libdav was evaluated first, as planned. Not adopted: its HttpClient trait
is defined over hyper::body::Incoming, so using it means replacing reqwest
everywhere, plus a DNS resolver for service discovery we do not do and a
second XML parser. Its precondition design is where Precondition's shape
comes from. Reasons are recorded in the crate docs.
2026-08-26 15:32:08 -04:00

475 lines
16 KiB
Rust

//! End-to-end tests against a real CalDAV server.
//!
//! Not mocks. A mock encodes what we already believe the protocol does, which
//! is precisely the belief worth checking — the previous iteration's
//! integration suite duplicated the router instead of importing it, and rotted
//! until it no longer compiled.
//!
//! These are skipped unless a server is configured, so `cargo test` works
//! offline. To run them:
//!
//! ```sh
//! crates/runway-caldav/tests/baikal/run.sh
//! ```
//!
//! which starts a throwaway Baikal in a container, installs it, and sets the
//! three variables below. Point them at any RFC-compliant server to test
//! against that instead. **Every test creates its own calendar collection and
//! deletes it afterwards**, so nothing touches data that was already there.
#![allow(clippy::unwrap_used, clippy::expect_used)]
use chrono::{TimeZone, Utc};
use runway_caldav::{CalDavClient, CalDavError, Credentials, Precondition, href_for};
use runway_core::ical;
use runway_core::model::{CalendarDateTime, TzId, VCalendar, VEvent};
/// A client, or `None` when no server is configured.
fn client() -> Option<(CalDavClient, String)> {
let url = std::env::var("RUNWAY_CALDAV_URL").ok()?;
let user = std::env::var("RUNWAY_CALDAV_USER").ok()?;
let password = std::env::var("RUNWAY_CALDAV_PASSWORD").ok()?;
let client = CalDavClient::new(&url, Credentials::new(&user, password))
.expect("the configured CalDAV URL is not valid");
Some((client, user))
}
/// Runs a test body against a scratch calendar, removing it afterwards.
///
/// The calendar is created and destroyed per test so the tests cannot see each
/// other's leftovers, and so a failure never leaves rubbish behind on a real
/// server.
async fn with_calendar<F, Fut>(name: &str, body: F)
where
F: FnOnce(CalDavClient, String) -> Fut,
Fut: Future<Output = ()>,
{
let Some((client, user)) = client() else {
eprintln!(
"SKIPPED: no CalDAV server configured. Run \
crates/runway-caldav/tests/baikal/run.sh to run these against a container."
);
return;
};
let href = format!("/dav.php/calendars/{user}/{name}/");
// A previous run that died mid-test would leave this behind.
let _ = client.delete_calendar(&href).await;
client
.create_calendar(&href, &format!("Runway test {name}"), Some("#336699"))
.await
.expect("could not create the scratch calendar");
body(client.clone(), href.clone()).await;
client
.delete_calendar(&href)
.await
.expect("could not remove the scratch calendar");
}
fn event(uid: &str, summary: &str, hour: u32) -> VEvent {
let start = CalendarDateTime::Zoned {
local: chrono::NaiveDate::from_ymd_opt(2026, 3, 10)
.unwrap()
.and_hms_opt(hour, 0, 0)
.unwrap(),
tzid: TzId::new("America/Denver").unwrap(),
};
VEvent::with_uid(uid, start)
.titled(summary)
.lasting(runway_core::model::IcalDuration::hours(1).unwrap())
}
fn calendar_of(event: VEvent) -> VCalendar {
VCalendar::with_events(vec![event])
}
// --------------------------------------------------------------- discovery --
#[tokio::test]
async fn discovery_finds_the_scratch_calendar() {
with_calendar("discovery", |client, href| async move {
let principal = client.current_user_principal().await.unwrap();
assert!(principal.contains("principals"), "got {principal}");
let home = client.calendar_home(&principal).await.unwrap();
let calendars = client.calendars_in(&home).await.unwrap();
let found = calendars
.iter()
.find(|c| c.href.trim_end_matches('/') == href.trim_end_matches('/'))
.expect("the calendar just created was not listed");
assert_eq!(found.display_name.as_deref(), Some("Runway test discovery"));
assert!(found.supports_events());
assert!(
!calendars.iter().any(|c| c.href.contains("outbox")),
"scheduling collections must not appear as calendars",
);
})
.await;
}
// ------------------------------------------------------------------- writes --
#[tokio::test]
async fn an_event_survives_a_round_trip_through_the_server() {
with_calendar("roundtrip", |client, calendar| async move {
let uid = "runway-roundtrip@test";
let href = href_for(&calendar, uid);
let mut original = event(uid, "Round trip", 9);
original.description = Some("Two lines\nand a comma, kept".to_owned());
original.categories = vec!["Work".to_owned(), "Personal".to_owned()];
client
.put_object(&href, &calendar_of(original.clone()), &Precondition::New)
.await
.unwrap();
let fetched = client.get_object(&calendar, &href).await.unwrap();
let stored = &fetched.events()[0];
assert_eq!(stored.uid, original.uid);
assert_eq!(stored.summary, original.summary);
assert_eq!(stored.description, original.description);
assert_eq!(
stored.categories,
vec!["Work", "Personal"],
"the separator must survive the server, not come back as one \
category called \"Work,Personal\"",
);
assert_eq!(
stored.dtstart.tzid().map(TzId::as_str),
Some("America/Denver"),
"the zone has to make it to the server -- this is what the phone \
reads when it decides when to ring",
);
})
.await;
}
#[tokio::test]
async fn a_stale_etag_is_refused_rather_than_overwriting() {
with_calendar("conflict", |client, calendar| async move {
let uid = "runway-conflict@test";
let href = href_for(&calendar, uid);
client
.put_object(
&href,
&calendar_of(event(uid, "First", 9)),
&Precondition::New,
)
.await
.unwrap();
// Read it, then let somebody else write.
let first = client.get_object(&calendar, &href).await.unwrap();
let stale = first.etag.clone().expect("Baikal returns an ETag");
client
.put_object(
&href,
&calendar_of(event(uid, "Someone else's edit", 10)),
&Precondition::Force,
)
.await
.unwrap();
// Now try to save the edit built on the stale read.
let result = client
.put_object(
&href,
&calendar_of(event(uid, "My edit", 11)),
&Precondition::Unchanged(stale),
)
.await;
assert!(
matches!(result, Err(CalDavError::Conflict { .. })),
"expected a conflict, got {result:?} -- without this the second \
person to hit save silently destroys the first person's change",
);
let current = client.get_object(&calendar, &href).await.unwrap();
assert_eq!(
current.events()[0].summary.as_deref(),
Some("Someone else's edit"),
"and the refused write must not have landed",
);
})
.await;
}
#[tokio::test]
async fn creating_the_same_resource_twice_is_refused() {
with_calendar("create-twice", |client, calendar| async move {
let uid = "runway-exists@test";
let href = href_for(&calendar, uid);
client
.put_object(
&href,
&calendar_of(event(uid, "First", 9)),
&Precondition::New,
)
.await
.unwrap();
let result = client
.put_object(
&href,
&calendar_of(event(uid, "Second", 9)),
&Precondition::New,
)
.await;
assert!(
result.is_err(),
"If-None-Match: * must stop a create from clobbering an existing \
resource, got {result:?}",
);
})
.await;
}
#[tokio::test]
async fn an_event_can_be_deleted() {
with_calendar("delete", |client, calendar| async move {
let uid = "runway-delete@test";
let href = href_for(&calendar, uid);
client
.put_object(
&href,
&calendar_of(event(uid, "Doomed", 9)),
&Precondition::New,
)
.await
.unwrap();
client
.delete_object(&href, &Precondition::Force)
.await
.unwrap();
assert!(
matches!(
client.get_object(&calendar, &href).await,
Err(CalDavError::NotFound { .. })
),
"a deleted resource should be reported as gone, not as an error \
with no name",
);
})
.await;
}
// -------------------------------------------------------------- time ranges --
#[tokio::test]
async fn a_time_range_query_returns_only_what_it_should() {
with_calendar("time-range", |client, calendar| async move {
for (uid, summary, day) in [
("in-window@test", "Inside", 10),
("out-of-window@test", "Outside", 25),
] {
let start = CalendarDateTime::Zoned {
local: chrono::NaiveDate::from_ymd_opt(2026, 3, day)
.unwrap()
.and_hms_opt(9, 0, 0)
.unwrap(),
tzid: TzId::new("America/Denver").unwrap(),
};
let event = VEvent::with_uid(uid, start)
.titled(summary)
.lasting(runway_core::model::IcalDuration::hours(1).unwrap());
client
.put_object(
&href_for(&calendar, uid),
&calendar_of(event),
&Precondition::New,
)
.await
.unwrap();
}
let found = client
.events_in_range(
&calendar,
Utc.with_ymd_and_hms(2026, 3, 9, 0, 0, 0).unwrap(),
Utc.with_ymd_and_hms(2026, 3, 12, 0, 0, 0).unwrap(),
)
.await
.unwrap();
let summaries: Vec<&str> = found
.iter()
.filter_map(|o| o.events().first())
.filter_map(|e| e.summary.as_deref())
.collect();
assert_eq!(
summaries,
vec!["Inside"],
"the server filters by time-range; the last iteration fetched every \
event in the calendar on every view change and filtered in the \
browser",
);
assert_eq!(
client.all_events(&calendar).await.unwrap().len(),
2,
"and both are really there",
);
})
.await;
}
#[tokio::test]
async fn a_recurring_series_is_returned_whole() {
with_calendar("series", |client, calendar| async move {
let uid = "runway-series@test";
let mut master = event(uid, "Weekly", 9);
master.rrule = Some("FREQ=WEEKLY;BYDAY=TU;COUNT=6".to_owned());
let mut moved = event(uid, "Weekly (moved)", 14);
moved.dtstart = CalendarDateTime::Zoned {
local: chrono::NaiveDate::from_ymd_opt(2026, 3, 17)
.unwrap()
.and_hms_opt(14, 0, 0)
.unwrap(),
tzid: TzId::new("America/Denver").unwrap(),
};
moved.recurrence_id = Some(CalendarDateTime::Zoned {
local: chrono::NaiveDate::from_ymd_opt(2026, 3, 17)
.unwrap()
.and_hms_opt(9, 0, 0)
.unwrap(),
tzid: TzId::new("America/Denver").unwrap(),
});
let mut resource = VCalendar::with_events(vec![master, moved]);
resource.timezones.clear();
client
.put_object(&href_for(&calendar, uid), &resource, &Precondition::New)
.await
.unwrap();
let found = client
.events_in_range(
&calendar,
Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap(),
Utc.with_ymd_and_hms(2026, 5, 1, 0, 0, 0).unwrap(),
)
.await
.unwrap();
assert_eq!(
found.len(),
1,
"one UID is one resource, however many VEVENTs"
);
let object = &found[0];
assert_eq!(
object.events().len(),
2,
"the master and its override come back together; reading them as \
two unrelated events is what made a correct calendar look like it \
was full of duplicates",
);
assert!(object.master().is_some());
assert_eq!(object.overrides().count(), 1);
assert!(object.has_consistent_uid());
})
.await;
}
// -------------------------------------------------------------------- sync --
#[tokio::test]
async fn etags_can_be_listed_without_the_bodies() {
with_calendar("etags", |client, calendar| async move {
for uid in ["one@test", "two@test"] {
client
.put_object(
&href_for(&calendar, uid),
&calendar_of(event(uid, "Something", 9)),
&Precondition::New,
)
.await
.unwrap();
}
let etags = client.etags(&calendar).await.unwrap();
assert_eq!(etags.len(), 2, "the collection itself must not be listed");
assert!(etags.iter().all(|(_, etag)| !etag.is_empty()));
let hrefs: Vec<String> = etags.iter().map(|(href, _)| href.clone()).collect();
let fetched = client.multiget(&calendar, &hrefs).await.unwrap();
assert_eq!(fetched.len(), 2, "and a multiget brings back exactly those");
})
.await;
}
// --------------------------------------------------------------- rejections --
#[tokio::test]
async fn bad_credentials_are_reported_as_such() {
let Some((_, user)) = client() else {
return;
};
let url = std::env::var("RUNWAY_CALDAV_URL").unwrap();
let wrong = CalDavClient::new(&url, Credentials::new(&user, "not-the-password")).unwrap();
assert!(
matches!(
wrong.current_user_principal().await,
Err(CalDavError::Unauthorized)
),
"a rejected password has to be distinguishable from a server being down",
);
}
#[tokio::test]
async fn the_golden_corpus_can_be_written_to_a_real_server() {
// The strongest statement available about the iCalendar writer: what it
// produces is accepted by a real CalDAV server, not merely by our own
// parser. Every fixture goes up and comes back.
with_calendar("corpus", |client, calendar| async move {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../runway-core/tests/golden/synthetic");
let mut checked = 0;
for entry in std::fs::read_dir(&dir).unwrap().filter_map(Result::ok) {
let path = entry.path();
if path.extension().is_none_or(|e| e != "ics") {
continue;
}
let source = ical::parse(&std::fs::read_to_string(&path).unwrap()).unwrap();
let Some(uid) = source.events.first().map(|e| e.uid.clone()) else {
continue;
};
let href = href_for(&calendar, &uid);
client
.put_object(&href, &source, &Precondition::New)
.await
.unwrap_or_else(|e| panic!("{} was rejected by the server: {e}", path.display()));
let returned = client.get_object(&calendar, &href).await.unwrap();
assert_eq!(
returned.calendar.events.len(),
source.events.len(),
"{} lost events on the server",
path.display(),
);
checked += 1;
}
assert!(checked >= 6, "only {checked} fixtures were exercised");
})
.await;
}