Files
runway/crates/runway-caldav/tests/live.rs
T
connor c7e22f4431 Add authentication
Proving who somebody is and starting a session for them are separate
operations. login_with_caldav does the first by asking the CalDAV server
whether the credentials work; begin_session does the second and knows
nothing about how the question was answered. OIDC arrives as a second way
to reach begin_session, not as a second scheme threaded through everything
-- which is what v1 had, with a JWT for most of the app and a separate
SQLite session_token used only by the preferences API.

The token lives in an HttpOnly cookie and nowhere else. v1 kept a JWT and
the CalDAV password in localStorage, readable by any script on the origin,
and re-sent the password in a header on every request. Here the password
never leaves the server: it is encrypted with XChaCha20-Poly1305 and
caldav_for is the only path back, handing out a client rather than a
credential.

Failed decryption is an error, not a subtly wrong password -- the AEAD tag
is checked, so a tampered row surfaces here instead of as a mysterious
CalDAV rejection later. A wrong password and an unreachable server stay
distinct, because telling somebody their password is wrong when the server
is down sends them to reset one that was fine.

Errors carry a stable code alongside their message, so a client can branch
on them. Internal ones say nothing about the inside of the server; the
detail goes to the log.

Tests go through router(), the same function main calls -- v1's suite
rebuilt the route table and tested a copy until it stopped compiling.
Skipping is now loud: a skipped test reports "ok", so run.sh sets
RUNWAY_REQUIRE_CALDAV=1 and not running becomes a failure.
2026-08-26 16:27:59 -04:00

492 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.
///
/// A skipped test still reports "ok", which is the kind of quiet no-op this
/// project exists to stop shipping. `RUNWAY_REQUIRE_CALDAV=1` -- which
/// `run.sh` sets -- turns "no server" into a failure, so in the one place
/// these are meant to run, not running is loud.
fn client() -> Option<(CalDavClient, String)> {
let details = (|| {
Some((
std::env::var("RUNWAY_CALDAV_URL").ok()?,
std::env::var("RUNWAY_CALDAV_USER").ok()?,
std::env::var("RUNWAY_CALDAV_PASSWORD").ok()?,
))
})();
let required = std::env::var("RUNWAY_REQUIRE_CALDAV").is_ok_and(|v| v == "1");
assert!(
details.is_some() || !required,
"RUNWAY_REQUIRE_CALDAV is set but no server is configured",
);
let (url, user, password) = details?;
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;
}