One path with an EditScope on the write verbs. v1 had a second parallel tree at /api/calendar/events/series/* -- 1,165 lines mostly duplicating the non-series handlers, dispatching on string literals in 53 places where a typo was a runtime fallthrough. What a scoped edit means to the stored .ics lives in runway-core::series, pure and tested without a server, because that is the subtle part and v1 shipped it with no coverage at all. Editing one occurrence writes an override and no EXDATE: an EXDATE says the occurrence does not happen, an override says it happens differently, and writing both is contradictory. Deleting one writes the EXDATE and removes any override that named it. Splitting a series divides its bound rather than dropping it. Six weekly occurrences split at the third become two plus four, not two plus forever -- the count is what the person asked for and it should survive being cut. Overrides after the split move to the new series; moving a whole series shifts its overrides' RECURRENCE-IDs by the same amount instead of leaving them pointing at occurrences that no longer exist. Every write states a precondition. There is no unconditional path: an update without an ETag is refused, and a stale one is a conflict rather than a silent overwrite. UIDs are minted server-side, because a client-supplied one could collide with and replace an unrelated event. Reads use time-range and fan out across calendars concurrently. Zones that cannot be resolved are reported in the response instead of being rendered as though they were fine. Two tests found real bugs: sub-second timestamps cannot survive iCalendar's one-second resolution, and splitting at the first occurrence was dropping the recurrence rule and quietly turning a series into a single event.
597 lines
18 KiB
Rust
597 lines
18 KiB
Rust
//! The events API, end to end.
|
|
//!
|
|
//! Through the real router, against a real CalDAV server. This is where M3
|
|
//! through M7 meet: the client fetches, the recurrence engine expands, the auth
|
|
//! layer supplies the credential, and the series rules decide what a scoped
|
|
//! edit writes. Each of those is unit-tested on its own; these check that the
|
|
//! seams between them hold.
|
|
//!
|
|
//! Needs a server: `crates/runway-caldav/tests/baikal/run.sh`.
|
|
|
|
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{Method, Request, StatusCode, header};
|
|
use runway_caldav::{CalDavClient, Credentials};
|
|
use runway_server::auth::SESSION_COOKIE;
|
|
use runway_server::db::Database;
|
|
use runway_server::{AppState, Config, router};
|
|
use serde_json::{Value, json};
|
|
use tower::ServiceExt;
|
|
|
|
const TZ: &str = "America/Denver";
|
|
|
|
/// A signed-in client with a scratch calendar of its own.
|
|
struct Api {
|
|
state: AppState,
|
|
token: String,
|
|
calendar: String,
|
|
}
|
|
|
|
impl Api {
|
|
/// `None` when no CalDAV server is configured.
|
|
async fn start(name: &str) -> Option<Self> {
|
|
let (url, user, password) = server()?;
|
|
|
|
let db = Database::in_memory().await.unwrap();
|
|
let state = AppState::with_database(db, Config::for_tests());
|
|
let (_, token) = state
|
|
.auth
|
|
.login_with_caldav(&url, &user, &password, None)
|
|
.await
|
|
.expect("could not log in to the test server");
|
|
|
|
// A collection per test, so no test can see another's leftovers.
|
|
let calendar = format!("/dav.php/calendars/{user}/api-{name}/");
|
|
let admin = CalDavClient::new(&url, Credentials::new(&user, password)).unwrap();
|
|
let _ = admin.delete_calendar(&calendar).await;
|
|
admin
|
|
.create_calendar(&calendar, &format!("API test {name}"), None)
|
|
.await
|
|
.unwrap();
|
|
|
|
Some(Self {
|
|
state,
|
|
token,
|
|
calendar,
|
|
})
|
|
}
|
|
|
|
async fn cleanup(&self) {
|
|
let (url, user, password) = server().unwrap();
|
|
let admin = CalDavClient::new(&url, Credentials::new(&user, password)).unwrap();
|
|
let _ = admin.delete_calendar(&self.calendar).await;
|
|
}
|
|
|
|
async fn send(&self, method: Method, uri: &str, body: Option<Value>) -> Reply {
|
|
let mut builder = Request::builder()
|
|
.method(method)
|
|
.uri(uri)
|
|
.header(header::COOKIE, format!("{SESSION_COOKIE}={}", self.token));
|
|
if body.is_some() {
|
|
builder = builder.header(header::CONTENT_TYPE, "application/json");
|
|
}
|
|
let request = builder
|
|
.body(body.map_or_else(Body::empty, |value| Body::from(value.to_string())))
|
|
.unwrap();
|
|
|
|
let response = router(self.state.clone()).oneshot(request).await.unwrap();
|
|
let status = response.status();
|
|
let bytes = axum::body::to_bytes(response.into_body(), 1 << 22)
|
|
.await
|
|
.unwrap();
|
|
let text = String::from_utf8_lossy(&bytes).into_owned();
|
|
Reply {
|
|
status,
|
|
json: serde_json::from_str(&text).ok(),
|
|
text,
|
|
}
|
|
}
|
|
|
|
async fn events(&self, from: &str, to: &str) -> Vec<Value> {
|
|
let reply = self
|
|
.send(
|
|
Method::GET,
|
|
&format!("/api/events?from={from}&to={to}&tz={TZ}"),
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(reply.status, StatusCode::OK, "{}", reply.text);
|
|
reply.json.unwrap()["occurrences"]
|
|
.as_array()
|
|
.cloned()
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Local start times and titles in January, as the API reports them.
|
|
async fn january(&self) -> Vec<String> {
|
|
self.events("2026-01-01", "2026-03-01")
|
|
.await
|
|
.iter()
|
|
.map(|o| {
|
|
format!(
|
|
"{} {}",
|
|
o["start"]["local"].as_str().unwrap_or_default(),
|
|
o["event"]["summary"].as_str().unwrap_or("(untitled)"),
|
|
)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
async fn create(&self, event: Value) -> Value {
|
|
let reply = self
|
|
.send(
|
|
Method::POST,
|
|
"/api/events",
|
|
Some(json!({ "calendar_href": self.calendar, "event": event })),
|
|
)
|
|
.await;
|
|
assert_eq!(reply.status, StatusCode::OK, "{}", reply.text);
|
|
reply.json.unwrap()
|
|
}
|
|
}
|
|
|
|
struct Reply {
|
|
status: StatusCode,
|
|
json: Option<Value>,
|
|
text: String,
|
|
}
|
|
|
|
impl Reply {
|
|
fn code(&self) -> Option<&str> {
|
|
self.json.as_ref()?.get("code")?.as_str()
|
|
}
|
|
}
|
|
|
|
fn server() -> Option<(String, String, 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()?,
|
|
))
|
|
})();
|
|
assert!(
|
|
details.is_some() || !std::env::var("RUNWAY_REQUIRE_CALDAV").is_ok_and(|v| v == "1"),
|
|
"RUNWAY_REQUIRE_CALDAV is set but no server is configured",
|
|
);
|
|
details
|
|
}
|
|
|
|
fn zoned(day: u32, hour: u32) -> Value {
|
|
json!({
|
|
"kind": "zoned",
|
|
"local": format!("2026-01-{day:02}T{hour:02}:00:00"),
|
|
"tzid": TZ,
|
|
})
|
|
}
|
|
|
|
/// A weekly Monday 09:00 series, six occurrences.
|
|
fn weekly() -> Value {
|
|
json!({
|
|
"uid": "ignored",
|
|
"dtstamp": "2026-01-01T00:00:00Z",
|
|
"dtstart": zoned(5, 9),
|
|
"end": { "kind": "date_time", "dtend": zoned(5, 10) },
|
|
"summary": "Standup",
|
|
"rrule": "FREQ=WEEKLY;BYDAY=MO;COUNT=6",
|
|
})
|
|
}
|
|
|
|
/// The same occurrence moved to the afternoon.
|
|
fn moved(day: u32) -> Value {
|
|
json!({
|
|
"uid": "ignored",
|
|
"dtstamp": "2026-01-01T00:00:00Z",
|
|
"dtstart": zoned(day, 14),
|
|
"end": { "kind": "date_time", "dtend": zoned(day, 15) },
|
|
"summary": "Standup (moved)",
|
|
})
|
|
}
|
|
|
|
macro_rules! api_test {
|
|
($name:ident, |$api:ident| $body:block) => {
|
|
#[tokio::test]
|
|
async fn $name() {
|
|
let Some($api) = Api::start(stringify!($name)).await else {
|
|
eprintln!("SKIPPED: no CalDAV server configured");
|
|
return;
|
|
};
|
|
$body
|
|
$api.cleanup().await;
|
|
}
|
|
};
|
|
}
|
|
|
|
// ------------------------------------------------------------------- reading --
|
|
|
|
api_test!(an_event_written_through_the_api_comes_back_from_it, |api| {
|
|
api.create(json!({
|
|
"uid": "ignored",
|
|
"dtstamp": "2026-01-01T00:00:00Z",
|
|
"dtstart": zoned(5, 9),
|
|
"end": { "kind": "date_time", "dtend": zoned(5, 10) },
|
|
"summary": "Dentist",
|
|
}))
|
|
.await;
|
|
|
|
let events = api.events("2026-01-01", "2026-02-01").await;
|
|
|
|
assert_eq!(events.len(), 1);
|
|
assert_eq!(events[0]["event"]["summary"], json!("Dentist"));
|
|
assert_eq!(
|
|
events[0]["start"]["tzid"],
|
|
json!(TZ),
|
|
"the zone survives the whole round trip, which is what the phone reads",
|
|
);
|
|
assert!(
|
|
events[0]["etag"].is_string(),
|
|
"every occurrence carries the ETag needed to edit it safely",
|
|
);
|
|
assert!(events[0]["href"].is_string());
|
|
});
|
|
|
|
api_test!(a_series_is_expanded_into_its_occurrences, |api| {
|
|
api.create(weekly()).await;
|
|
|
|
assert_eq!(
|
|
api.january().await,
|
|
vec![
|
|
"2026-01-05T09:00:00 Standup",
|
|
"2026-01-12T09:00:00 Standup",
|
|
"2026-01-19T09:00:00 Standup",
|
|
"2026-01-26T09:00:00 Standup",
|
|
"2026-02-02T09:00:00 Standup",
|
|
"2026-02-09T09:00:00 Standup",
|
|
],
|
|
"the client receives discrete occurrences; v1 shipped 650 lines of \
|
|
hand-rolled expansion into the browser instead",
|
|
);
|
|
});
|
|
|
|
api_test!(a_range_returns_only_what_falls_inside_it, |api| {
|
|
api.create(weekly()).await;
|
|
|
|
let january = api.events("2026-01-05", "2026-01-13").await;
|
|
|
|
assert_eq!(january.len(), 2, "the 5th and the 12th");
|
|
});
|
|
|
|
api_test!(a_backwards_range_is_refused, |api| {
|
|
let reply = api
|
|
.send(
|
|
Method::GET,
|
|
"/api/events?from=2026-02-01&to=2026-01-01",
|
|
None,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(reply.status, StatusCode::BAD_REQUEST);
|
|
assert_eq!(reply.code(), Some("bad_request"));
|
|
});
|
|
|
|
api_test!(an_unknown_time_zone_is_refused_rather_than_guessed, |api| {
|
|
let reply = api
|
|
.send(
|
|
Method::GET,
|
|
"/api/events?from=2026-01-01&to=2026-02-01&tz=Mars/Olympus_Mons",
|
|
None,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(reply.status, StatusCode::BAD_REQUEST);
|
|
});
|
|
|
|
// ------------------------------------------------------------------- writing --
|
|
|
|
api_test!(creating_an_event_mints_its_uid_on_the_server, |api| {
|
|
let created = api
|
|
.create(json!({
|
|
"uid": "a-uid-the-client-chose",
|
|
"dtstamp": "2026-01-01T00:00:00Z",
|
|
"dtstart": zoned(5, 9),
|
|
"summary": "Whatever",
|
|
}))
|
|
.await;
|
|
|
|
let href = created["href"].as_str().unwrap();
|
|
assert!(
|
|
!href.contains("a-uid-the-client-chose"),
|
|
"a client-supplied UID could collide with, and overwrite, an unrelated \
|
|
event: {href}",
|
|
);
|
|
assert!(created["etag"].is_string());
|
|
});
|
|
|
|
api_test!(editing_the_whole_series_changes_every_occurrence, |api| {
|
|
api.create(weekly()).await;
|
|
let first = api.events("2026-01-01", "2026-03-01").await[0].clone();
|
|
|
|
let mut renamed = weekly();
|
|
renamed["summary"] = json!("Standup (renamed)");
|
|
let reply = api
|
|
.send(
|
|
Method::PUT,
|
|
"/api/events",
|
|
Some(json!({
|
|
"calendar_href": api.calendar,
|
|
"href": first["href"],
|
|
"etag": first["etag"],
|
|
"scope": "entire_series",
|
|
"event": renamed,
|
|
})),
|
|
)
|
|
.await;
|
|
assert_eq!(reply.status, StatusCode::OK, "{}", reply.text);
|
|
|
|
let after = api.january().await;
|
|
assert_eq!(after.len(), 6);
|
|
assert!(after.iter().all(|s| s.contains("renamed")), "{after:?}");
|
|
});
|
|
|
|
api_test!(editing_one_occurrence_leaves_the_others_alone, |api| {
|
|
api.create(weekly()).await;
|
|
let occurrences = api.events("2026-01-01", "2026-03-01").await;
|
|
let second = occurrences[1].clone();
|
|
|
|
let reply = api
|
|
.send(
|
|
Method::PUT,
|
|
"/api/events",
|
|
Some(json!({
|
|
"calendar_href": api.calendar,
|
|
"href": second["href"],
|
|
"etag": second["etag"],
|
|
"scope": "this_only",
|
|
"recurrence_id": second["recurrence_id"],
|
|
"event": moved(12),
|
|
})),
|
|
)
|
|
.await;
|
|
assert_eq!(reply.status, StatusCode::OK, "{}", reply.text);
|
|
|
|
assert_eq!(
|
|
api.january().await,
|
|
vec![
|
|
"2026-01-05T09:00:00 Standup",
|
|
"2026-01-12T14:00:00 Standup (moved)",
|
|
"2026-01-19T09:00:00 Standup",
|
|
"2026-01-26T09:00:00 Standup",
|
|
"2026-02-02T09:00:00 Standup",
|
|
"2026-02-09T09:00:00 Standup",
|
|
],
|
|
"six occurrences with one moved -- not seven, which is what reading the \
|
|
master and its override as unrelated events would produce",
|
|
);
|
|
});
|
|
|
|
api_test!(
|
|
this_and_future_splits_the_series_into_two_resources,
|
|
|api| {
|
|
api.create(weekly()).await;
|
|
let occurrences = api.events("2026-01-01", "2026-03-01").await;
|
|
let third = occurrences[2].clone();
|
|
|
|
let reply = api
|
|
.send(
|
|
Method::PUT,
|
|
"/api/events",
|
|
Some(json!({
|
|
"calendar_href": api.calendar,
|
|
"href": third["href"],
|
|
"etag": third["etag"],
|
|
"scope": "this_and_future",
|
|
"recurrence_id": third["recurrence_id"],
|
|
"event": moved(19),
|
|
})),
|
|
)
|
|
.await;
|
|
assert_eq!(reply.status, StatusCode::OK, "{}", reply.text);
|
|
|
|
let body = reply.json.unwrap();
|
|
let new_href = body["new_series_href"].as_str().expect("a second resource");
|
|
assert_ne!(new_href, body["href"].as_str().unwrap());
|
|
|
|
assert_eq!(
|
|
api.january().await,
|
|
vec![
|
|
"2026-01-05T09:00:00 Standup",
|
|
"2026-01-12T09:00:00 Standup",
|
|
"2026-01-19T14:00:00 Standup (moved)",
|
|
"2026-01-26T14:00:00 Standup (moved)",
|
|
"2026-02-02T14:00:00 Standup (moved)",
|
|
"2026-02-09T14:00:00 Standup (moved)",
|
|
],
|
|
"the old series stops at the split and the new one takes over -- six in \
|
|
total across both halves, as originally asked for, and nothing \
|
|
appearing twice on the 19th",
|
|
);
|
|
}
|
|
);
|
|
|
|
// ------------------------------------------------------------------ deleting --
|
|
|
|
api_test!(deleting_one_occurrence_leaves_the_rest, |api| {
|
|
api.create(weekly()).await;
|
|
let occurrences = api.events("2026-01-01", "2026-03-01").await;
|
|
let second = occurrences[1].clone();
|
|
|
|
let reply = api
|
|
.send(
|
|
Method::DELETE,
|
|
"/api/events",
|
|
Some(json!({
|
|
"calendar_href": api.calendar,
|
|
"href": second["href"],
|
|
"etag": second["etag"],
|
|
"scope": "this_only",
|
|
"recurrence_id": second["recurrence_id"],
|
|
})),
|
|
)
|
|
.await;
|
|
assert_eq!(reply.status, StatusCode::OK, "{}", reply.text);
|
|
assert_eq!(
|
|
reply.json.unwrap()["removed"],
|
|
json!(false),
|
|
"the resource stays; only one occurrence went",
|
|
);
|
|
|
|
let after = api.january().await;
|
|
assert_eq!(after.len(), 5);
|
|
assert!(!after.iter().any(|s| s.contains("2026-01-12")), "{after:?}");
|
|
});
|
|
|
|
api_test!(deleting_the_entire_series_removes_the_resource, |api| {
|
|
api.create(weekly()).await;
|
|
let first = api.events("2026-01-01", "2026-03-01").await[0].clone();
|
|
|
|
let reply = api
|
|
.send(
|
|
Method::DELETE,
|
|
"/api/events",
|
|
Some(json!({
|
|
"calendar_href": api.calendar,
|
|
"href": first["href"],
|
|
"etag": first["etag"],
|
|
"scope": "entire_series",
|
|
})),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(reply.status, StatusCode::OK, "{}", reply.text);
|
|
assert_eq!(reply.json.unwrap()["removed"], json!(true));
|
|
assert!(api.january().await.is_empty());
|
|
});
|
|
|
|
api_test!(deleting_this_and_future_truncates_the_series, |api| {
|
|
api.create(weekly()).await;
|
|
let occurrences = api.events("2026-01-01", "2026-03-01").await;
|
|
let third = occurrences[2].clone();
|
|
|
|
api.send(
|
|
Method::DELETE,
|
|
"/api/events",
|
|
Some(json!({
|
|
"calendar_href": api.calendar,
|
|
"href": third["href"],
|
|
"etag": third["etag"],
|
|
"scope": "this_and_future",
|
|
"recurrence_id": third["recurrence_id"],
|
|
})),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
api.january().await,
|
|
vec!["2026-01-05T09:00:00 Standup", "2026-01-12T09:00:00 Standup"],
|
|
);
|
|
});
|
|
|
|
// ----------------------------------------------------------------- conflicts --
|
|
|
|
api_test!(a_stale_etag_is_refused_rather_than_overwriting, |api| {
|
|
api.create(weekly()).await;
|
|
let stale = api.events("2026-01-01", "2026-03-01").await[0].clone();
|
|
|
|
// Somebody else saves first.
|
|
let mut renamed = weekly();
|
|
renamed["summary"] = json!("Someone else's edit");
|
|
let first = api
|
|
.send(
|
|
Method::PUT,
|
|
"/api/events",
|
|
Some(json!({
|
|
"calendar_href": api.calendar,
|
|
"href": stale["href"],
|
|
"etag": stale["etag"],
|
|
"scope": "entire_series",
|
|
"event": renamed,
|
|
})),
|
|
)
|
|
.await;
|
|
assert_eq!(first.status, StatusCode::OK, "{}", first.text);
|
|
|
|
// Then the edit built on the older read arrives.
|
|
let mut mine = weekly();
|
|
mine["summary"] = json!("My edit");
|
|
let second = api
|
|
.send(
|
|
Method::PUT,
|
|
"/api/events",
|
|
Some(json!({
|
|
"calendar_href": api.calendar,
|
|
"href": stale["href"],
|
|
"etag": stale["etag"],
|
|
"scope": "entire_series",
|
|
"event": mine,
|
|
})),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(second.status, StatusCode::CONFLICT, "{}", second.text);
|
|
assert_eq!(second.code(), Some("conflict"));
|
|
assert!(
|
|
api.january().await[0].contains("Someone else's edit"),
|
|
"and the refused write must not have landed",
|
|
);
|
|
});
|
|
|
|
api_test!(an_edit_without_an_etag_is_refused, |api| {
|
|
api.create(weekly()).await;
|
|
let first = api.events("2026-01-01", "2026-03-01").await[0].clone();
|
|
|
|
let reply = api
|
|
.send(
|
|
Method::PUT,
|
|
"/api/events",
|
|
Some(json!({
|
|
"calendar_href": api.calendar,
|
|
"href": first["href"],
|
|
"scope": "entire_series",
|
|
"event": weekly(),
|
|
})),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
reply.status,
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"every write states a precondition; there is no unconditional path \
|
|
through this API: {}",
|
|
reply.text,
|
|
);
|
|
});
|
|
|
|
api_test!(a_scoped_edit_without_a_recurrence_id_is_refused, |api| {
|
|
api.create(weekly()).await;
|
|
let first = api.events("2026-01-01", "2026-03-01").await[0].clone();
|
|
|
|
let reply = api
|
|
.send(
|
|
Method::PUT,
|
|
"/api/events",
|
|
Some(json!({
|
|
"calendar_href": api.calendar,
|
|
"href": first["href"],
|
|
"etag": first["etag"],
|
|
"scope": "this_only",
|
|
"event": moved(12),
|
|
})),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(reply.status, StatusCode::BAD_REQUEST);
|
|
assert!(reply.text.contains("RECURRENCE-ID"), "{}", reply.text);
|
|
});
|
|
|
|
// ------------------------------------------------------------------- access --
|
|
|
|
api_test!(the_events_api_needs_a_session, |api| {
|
|
let request = Request::get("/api/events?from=2026-01-01&to=2026-02-01")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = router(api.state.clone()).oneshot(request).await.unwrap();
|
|
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
});
|