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.
475 lines
16 KiB
Rust
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;
|
|
}
|