CalCalist/tests/caldav.rs
randogoth 622a71dcd6 Sync WebCal and CalDAV directly; add a skip_past cutoff
WebCal is calcalist's own (src/webcal.rs), with Calendar::split_by_uid to turn
one feed document into one file per UID. Shared crate::http and crate::secret
came out of google/ so every leg uses them.

CalDAV follows, behind a per-endpoint 'native' flag so it can run beside
pimsync one collection at a time: src/caldav/client.rs speaks PROPFIND,
sync-collection, calendar-multiget and conditional PUT/DELETE, and
src/caldav/mod.rs is the pull/push pair. Radicale answers an initial
sync-collection with no members, so a cursor is only stored alongside a
complete read. doctor reports what a server advertises.

skip_past (src/horizon.rs) takes a date, not a flag: a rolling 'before now'
would retire events from the aggregate as they aged, deleting yesterday's
events on every cycle. Pinned to a date, what is mirrored stays mirrored. On
the real configuration it leaves 2600 of 3282 events behind. The filter sits
above the prior-links lookup in mirror_source, which is what makes it safe:
from there DeleteSource is unreachable, so excluding an event can never be
read as the user having deleted its mirror.

pimsync: CWD pinned so vstorage's relative unlink cannot reach the user's
files; the dry-run workspace is removed by Drop rather than by a line every
'?' stepped over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 17:41:32 +03:00

752 lines
23 KiB
Rust

//! End-to-end tests against a real CalDAV server and a real iCal feed.
//!
//! The unit tests establish that the reconciler decides correctly. These
//! establish that the decisions survive the round trip through pimsync and a
//! server that rewrites what it stores — which is where every bug found by hand
//! during M1 actually lived.
//!
//! Radicale and pimsync both come from devbox, so `devbox run check` has them.
//! Outside that shell the tests report what is missing and pass, rather than
//! failing for a reason that has nothing to do with the code.
mod support;
use support::{Calcalist, Feed, Radicale};
/// An event with guests, an organiser and an alarm — the combination the
/// scheduling rules are about.
const MEETING: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n\
BEGIN:VEVENT\r\nUID:meeting@work\r\nDTSTAMP:20260101T000000Z\r\n\
DTSTART:20260910T090000Z\r\nDTEND:20260910T100000Z\r\nSUMMARY:Planning\r\n\
ORGANIZER;CN=Chair:mailto:chair@example.com\r\n\
ATTENDEE;CN=Guest;PARTSTAT=ACCEPTED:mailto:guest@example.com\r\n\
BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\nDESCRIPTION:Soon\r\n\
END:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
/// An event added to a source after a cycle has already stored a cursor.
const LATER: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n\
BEGIN:VEVENT\r\nUID:later@work\r\nDTSTAMP:20260101T000000Z\r\n\
DTSTART:20260915T090000Z\r\nDTEND:20260915T100000Z\r\n\
SUMMARY:Retrospective\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
/// An event written straight into the aggregate, belonging to no source.
const HAND_WRITTEN: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//phone//EN\r\n\
BEGIN:VEVENT\r\nUID:hand-written@phone\r\nDTSTAMP:20260101T000000Z\r\n\
DTSTART:20260911T140000Z\r\nDTEND:20260911T150000Z\r\nSUMMARY:Dentist\r\n\
END:VEVENT\r\nEND:VCALENDAR\r\n";
/// The same event, asking to be left where it was written.
const KEPT_LOCAL: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//phone//EN\r\n\
BEGIN:VEVENT\r\nUID:kept@phone\r\nDTSTAMP:20260101T000000Z\r\n\
DTSTART:20260912T140000Z\r\nDTEND:20260912T150000Z\r\nSUMMARY:Haircut\r\n\
DESCRIPTION:Around the corner\\n\\n@local\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
const HOLIDAY_FEED: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//feed//EN\r\n\
BEGIN:VEVENT\r\nUID:newyear@feed\r\nDTSTAMP:20260101T000000Z\r\n\
DTSTART;VALUE=DATE:20260101\r\nDTEND;VALUE=DATE:20260102\r\n\
SUMMARY:New Year\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
struct Fixture {
_root: tempfile::TempDir,
server: Radicale,
_feed: Feed,
calcalist: Calcalist,
}
/// A CalDAV source, a read-only feed, and a CalDAV target to aggregate into.
fn fixture() -> Option<Fixture> {
let missing = support::missing_binaries();
if !missing.is_empty() {
eprintln!(
"skipped: {} not on PATH; run under devbox",
missing.join(", ")
);
return None;
}
let root = tempfile::tempdir().expect("temp");
let server = Radicale::start(root.path());
server.create_calendar("work");
server.create_calendar("unified");
server.put_event("work", "meeting.ics", MEETING);
let feed = Feed::start(HOLIDAY_FEED.to_string());
let config = format!(
r#"
version = 1
[[endpoint]]
id = "work"
type = "caldav"
url = "{work}"
username = "{user}"
secret_command = "printf password"
[[endpoint]]
id = "published"
type = "caldav"
url = "{unified}"
username = "{user}"
secret_command = "printf password"
[[endpoint]]
id = "holidays"
type = "webcal"
url = "{feed}"
[[aggregate]]
id = "everything"
target = "published"
sources = ["work", "holidays"]
default_sink = "work"
"#,
work = server.url("work"),
unified = server.url("unified"),
user = support::USER,
feed = feed.url(),
);
let calcalist = Calcalist::new(root.path(), &config);
Some(Fixture {
_root: root,
server,
_feed: feed,
calcalist,
})
}
/// Both sources reach the target, and running again changes nothing.
///
/// Idempotence is the property that matters most: a cycle that is not a no-op
/// against unchanged input would rewrite every event on every run, and each
/// rewrite is a chance for the server to hand back something slightly different.
#[test]
fn a_cycle_converges_and_the_next_one_does_nothing() {
let Some(fixture) = fixture() else { return };
let first = fixture.calcalist.run(&["sync"]);
assert!(
first.succeeded(),
"first sync failed\n{}\n{}",
first.stdout,
first.stderr
);
let published = fixture.server.stored("unified");
assert_eq!(
published.len(),
2,
"both sources should arrive: {published:?}"
);
let all = published.join("\n");
assert!(all.contains("Planning"), "{all}");
assert!(all.contains("New Year"), "{all}");
// Provenance travels with each mirror, so the aggregate knows where its
// events came from without consulting the state file.
assert!(all.contains("X-CALCALIST-SOURCE:work"), "{all}");
assert!(all.contains("X-CALCALIST-SOURCE:holidays"), "{all}");
let second = fixture.calcalist.run(&["sync"]);
assert!(
second.succeeded(),
"second sync failed\n{}\n{}",
second.stdout,
second.stderr
);
assert!(
second.stdout.contains("everything: 0 mirrored"),
"the second cycle should be a no-op:\n{}",
second.stdout
);
assert_eq!(
fixture.server.stored("unified"),
published,
"the second cycle rewrote the target"
);
}
/// The scheduling rule, checked against what actually reached the server.
///
/// A CalDAV server has no portable way to be told not to send invitations, so
/// inertness is structural: the mirror carries the guest list as data and not as
/// live scheduling properties. The alarm is the deliberate exception — stripping
/// it would destroy every reminder in the one calendar the user subscribes to.
///
/// Radicale implements no scheduling of its own, so this asserts on the bytes
/// stored rather than on a mail sink: with nothing to send mail, a quiet SMTP
/// port would prove nothing about the transform.
#[test]
fn a_mirror_carries_no_live_scheduling_properties() {
let Some(fixture) = fixture() else { return };
let run = fixture.calcalist.run(&["sync"]);
assert!(
run.succeeded(),
"sync failed\n{}\n{}",
run.stdout,
run.stderr
);
let mirror = fixture
.server
.stored("unified")
.into_iter()
.find(|item| item.contains("Planning"))
.expect("the meeting should have been mirrored");
for property in ["ATTENDEE;", "ATTENDEE:", "ORGANIZER;", "ORGANIZER:"] {
assert!(
!mirror
.lines()
.any(|line| line.trim_start().starts_with(property)),
"a live {property} reached the aggregate:\n{mirror}"
);
}
// The guests are still there, as something nothing will act on.
assert!(
mirror.contains("X-CALCALIST-ATTENDEES"),
"the guest list was lost:\n{mirror}"
);
assert!(
mirror.contains("guest@example.com"),
"the guest list was lost:\n{mirror}"
);
assert!(
mirror.contains("BEGIN:VALARM") && mirror.contains("TRIGGER:-PT15M"),
"the alarm did not survive:\n{mirror}"
);
// The source keeps its scheduling properties: only the aggregate is inert.
let source = fixture
.server
.stored("work")
.into_iter()
.find(|item| item.contains("Planning"))
.expect("the meeting should still be in its source");
assert!(source.contains("ATTENDEE"), "{source}");
assert!(source.contains("ORGANIZER"), "{source}");
}
/// A dry run must reach the servers to be worth anything, and change nothing.
#[test]
fn a_dry_run_reports_without_touching_anything() {
let Some(fixture) = fixture() else { return };
let dry = fixture.calcalist.run(&["sync", "--dry-run"]);
assert!(
dry.succeeded(),
"dry run failed\n{}\n{}",
dry.stdout,
dry.stderr
);
assert!(
dry.stdout.contains("everything: 2 mirrored"),
"the dry run should have seen both sources:\n{}",
dry.stdout
);
assert!(
fixture.server.stored("unified").is_empty(),
"the dry run published events"
);
// And the real cycle that follows is not confused by it.
let real = fixture.calcalist.run(&["sync"]);
assert!(
real.succeeded(),
"sync after a dry run failed\n{}\n{}",
real.stdout,
real.stderr
);
assert_eq!(fixture.server.stored("unified").len(), 2);
}
/// Removing an endpoint used to leave its events sitting in the state directory
/// with nothing managing them. They are now reported, and removed on request.
#[test]
fn a_retired_endpoints_mirror_is_reported_and_then_removed() {
let Some(fixture) = fixture() else { return };
assert!(fixture.calcalist.run(&["sync"]).succeeded());
let mirror = fixture.calcalist.state.join("calcalist/vdir/holidays");
assert!(mirror.is_dir(), "the feed should have been mirrored");
// The feed is dropped from the configuration, as a user would drop it.
std::fs::write(
&fixture.calcalist.config,
fixture.calcalist.config_without_feed(),
)
.expect("rewrite config");
let listed = fixture.calcalist.run(&["prune"]);
assert!(listed.succeeded(), "{}", listed.stderr);
assert!(
listed.stdout.contains("would remove") && listed.stdout.contains("holidays"),
"prune should say what it found:\n{}",
listed.stdout
);
assert!(mirror.is_dir(), "listing must not delete anything");
let removed = fixture.calcalist.run(&["prune", "--force"]);
assert!(removed.succeeded(), "{}", removed.stderr);
assert!(!mirror.exists(), "the orphaned mirror should be gone");
}
/// An aggregate with no `default_sink` leaves events created in it alone.
///
/// This is how a target calendar keeps events of its own: with nowhere
/// configured to file a new event, calcalist refuses to guess rather than
/// picking a source, so the event simply stays where it was written. Worth
/// pinning, because "left alone" has to hold on every subsequent cycle too —
/// an event that survived the first one and was swept up by the second would
/// be worse than never having worked.
#[test]
fn an_event_created_in_a_sinkless_aggregate_stays_where_it_is() {
let Some(fixture) = fixture() else { return };
// The fixture's aggregate has a default_sink; take it away.
let sinkless = std::fs::read_to_string(&fixture.calcalist.config)
.expect("read config")
.replace("default_sink = \"work\"\n", "");
std::fs::write(&fixture.calcalist.config, sinkless).expect("rewrite config");
assert!(fixture.calcalist.run(&["sync"]).succeeded());
// Now add an event by hand, as a calendar app would.
fixture
.server
.put_event("unified", "dentist.ics", HAND_WRITTEN);
for cycle in 1..=3 {
let run = fixture.calcalist.run(&["sync"]);
assert!(
run.succeeded(),
"cycle {cycle} failed\n{}\n{}",
run.stdout,
run.stderr
);
let published = fixture.server.stored("unified");
assert!(
published.iter().any(|item| item.contains("Dentist")),
"cycle {cycle}: the hand-written event was removed:\n{published:?}"
);
// It never reaches a source: there is nowhere it was told to go.
assert!(
!fixture
.server
.stored("work")
.iter()
.any(|item| item.contains("Dentist")),
"cycle {cycle}: the hand-written event leaked into the source"
);
}
}
/// `@local` opts one event out of a default sink that would otherwise take it.
///
/// The marker has to survive, unlike every other one: it is stripped markers
/// that have done their job on arrival, whereas this event never leaves, so the
/// next cycle has to be able to reach the same decision.
#[test]
fn the_local_marker_keeps_an_event_out_of_a_configured_sink() {
let Some(fixture) = fixture() else { return };
assert!(fixture.calcalist.run(&["sync"]).succeeded());
fixture
.server
.put_event("unified", "haircut.ics", KEPT_LOCAL);
for cycle in 1..=3 {
let run = fixture.calcalist.run(&["sync"]);
assert!(
run.succeeded(),
"cycle {cycle} failed\n{}\n{}",
run.stdout,
run.stderr
);
assert!(
run.stdout.contains("1 event(s) kept local"),
"cycle {cycle} should report it as kept, not skipped:\n{}",
run.stdout
);
let kept = fixture
.server
.stored("unified")
.into_iter()
.find(|item| item.contains("Haircut"))
.expect("the event should still be in the aggregate");
// Stripping it would let the default sink claim the event next cycle.
assert!(kept.contains("@local"), "cycle {cycle}: {kept}");
assert!(
!fixture
.server
.stored("work")
.iter()
.any(|item| item.contains("Haircut")),
"cycle {cycle}: it reached the default sink anyway"
);
}
}
/// Discovery, against a server that really has two calendars.
///
/// The command exists because the CalDAV `url` must name the collection exactly
/// and providers rarely show it. What it prints therefore has to be usable
/// as-is, which is what the parse-back below actually checks.
#[test]
fn discover_lists_the_servers_calendars_as_pasteable_config() {
let Some(fixture) = fixture() else { return };
let run = fixture.calcalist.run(&[
"discover",
&format!(
"http://127.0.0.1:{}/{}/",
fixture.server.port,
support::USER
),
"--username",
support::USER,
"--secret-command",
"printf password",
]);
assert!(
run.succeeded(),
"discover failed\n{}\n{}",
run.stdout,
run.stderr
);
assert!(run.stdout.contains("id = \"work\""), "{}", run.stdout);
assert!(run.stdout.contains("id = \"unified\""), "{}", run.stdout);
// The URL has to come back absolute, not as the bare path pimsync reports.
assert!(
run.stdout
.contains(&format!("http://127.0.0.1:{}/", fixture.server.port)),
"{}",
run.stdout
);
// The point of the command: what it prints is valid configuration.
let config = format!("version = 1\n{}", run.stdout);
let parsed: toml::Value = toml::from_str(&config).expect("the output should be valid TOML");
let endpoints = parsed["endpoint"].as_array().expect("endpoints");
assert_eq!(endpoints.len(), 2, "{}", run.stdout);
assert_eq!(endpoints[0]["type"].as_str(), Some("caldav"));
assert_eq!(
endpoints[0]["secret_command"].as_str(),
Some("printf password")
);
}
/// An unreachable server has to say so rather than print an empty result that
/// looks like "this server has no calendars".
#[test]
fn discover_reports_a_server_it_cannot_reach() {
if !support::missing_binaries().is_empty() {
eprintln!("skipped: run under devbox");
return;
}
let root = tempfile::tempdir().expect("temp");
let calcalist = Calcalist::new(root.path(), "version = 1\n");
// Port 9 is discard: it refuses or blackholes, and nothing listens for DAV.
let run = calcalist.run(&[
"discover",
"http://127.0.0.1:9/calendars/me/",
"--username",
"me",
]);
assert!(
!run.succeeded(),
"it should not claim success\n{}",
run.stdout
);
assert!(
!run.stderr.is_empty(),
"the failure should be explained: {run:?}",
run = run.stderr
);
}
/// The lock is what stops a timer firing into a hand-run cycle.
///
/// The lock is taken here rather than by racing two real syncs, which would be
/// timing-dependent and would pass by accident most of the time.
#[test]
fn a_second_cycle_is_refused_while_one_is_running() {
let Some(fixture) = fixture() else { return };
assert!(fixture.calcalist.run(&["sync"]).succeeded());
let lock_path = fixture.calcalist.state.join("calcalist/lock");
let held = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&lock_path)
.expect("the lock file should exist after a cycle");
rustix::fs::flock(&held, rustix::fs::FlockOperation::NonBlockingLockExclusive)
.expect("the test should be able to take the lock");
let refused = fixture.calcalist.run(&["sync"]);
assert!(!refused.succeeded(), "{}", refused.stdout);
assert!(
refused.stderr.contains("already running"),
"the refusal should say why: {}",
refused.stderr
);
drop(held);
assert!(
fixture.calcalist.run(&["sync"]).succeeded(),
"the lock should be free again"
);
}
/// The same cycle, driven by calcalist's own CalDAV client instead of pimsync.
///
/// This is the comparison the `native` flag exists for: the assertions are the
/// pimsync test's, so a divergence between the two implementations shows up as
/// a failure here rather than as a surprise on someone's calendar.
fn native_fixture() -> Option<Fixture> {
let missing = support::missing_binaries();
if !missing.is_empty() {
eprintln!(
"skipped: {} not on PATH; run under devbox",
missing.join(", ")
);
return None;
}
let root = tempfile::tempdir().expect("temp");
let server = Radicale::start(root.path());
server.create_calendar("work");
server.create_calendar("unified");
server.put_event("work", "meeting.ics", MEETING);
let feed = Feed::start(HOLIDAY_FEED.to_string());
let config = format!(
r#"
version = 1
[[endpoint]]
id = "work"
type = "caldav"
native = true
url = "{work}"
username = "{user}"
secret_command = "printf password"
[[endpoint]]
id = "published"
type = "caldav"
native = true
url = "{unified}"
username = "{user}"
secret_command = "printf password"
[[endpoint]]
id = "holidays"
type = "webcal"
url = "{feed}"
[[aggregate]]
id = "everything"
target = "published"
sources = ["work", "holidays"]
default_sink = "work"
"#,
work = server.url("work"),
unified = server.url("unified"),
user = support::USER,
feed = feed.url(),
);
let calcalist = Calcalist::new(root.path(), &config);
Some(Fixture {
_root: root,
server,
_feed: feed,
calcalist,
})
}
#[test]
fn a_native_cycle_converges_and_the_next_one_does_nothing() {
let Some(fixture) = native_fixture() else {
return;
};
let first = fixture.calcalist.run(&["sync"]);
assert!(
first.succeeded(),
"first native sync failed\n{}\n{}",
first.stdout,
first.stderr
);
let published = fixture.server.stored("unified");
assert_eq!(
published.len(),
2,
"both sources should arrive: {published:?}"
);
let all = published.join("\n");
assert!(all.contains("Planning"), "{all}");
assert!(all.contains("New Year"), "{all}");
assert!(all.contains("X-CALCALIST-SOURCE:work"), "{all}");
assert!(all.contains("X-CALCALIST-SOURCE:holidays"), "{all}");
let second = fixture.calcalist.run(&["sync"]);
assert!(
second.succeeded(),
"second native sync failed\n{}\n{}",
second.stdout,
second.stderr
);
assert!(
second.stdout.contains("everything: 0 mirrored"),
"the second cycle should be a no-op:\n{}",
second.stdout
);
assert_eq!(
fixture.server.stored("unified"),
published,
"the second cycle rewrote the target"
);
}
/// A cursor is only worth storing if the cycle after it still sees everything.
/// Radicale answers the first `sync-collection` with no members at all, so a
/// client that trusted it would mirror an empty calendar and call it converged.
#[test]
fn a_native_pull_sees_an_event_added_behind_its_back() {
let Some(fixture) = native_fixture() else {
return;
};
assert!(fixture.calcalist.run(&["sync"]).succeeded(), "first sync");
fixture.server.put_event("work", "later.ics", LATER);
let second = fixture.calcalist.run(&["sync"]);
assert!(
second.succeeded(),
"second native sync failed\n{}\n{}",
second.stdout,
second.stderr
);
let published = fixture.server.stored("unified").join("\n");
assert!(
published.contains("Retrospective"),
"an event added after the cursor was stored should still arrive:\n{published}"
);
}
/// `skip_past` on an aggregate, end to end.
///
/// The source holds one finished event and one still to come, and the feed holds
/// a New Year that is long gone. Only the future event should be published.
#[test]
fn skip_past_publishes_only_what_is_still_to_come() {
let missing = support::missing_binaries();
if !missing.is_empty() {
eprintln!(
"skipped: {} not on PATH; run under devbox",
missing.join(", ")
);
return;
}
let root = tempfile::tempdir().expect("temp");
let server = Radicale::start(root.path());
server.create_calendar("work");
server.create_calendar("unified");
server.put_event("work", "meeting.ics", MEETING);
server.put_event("work", "later.ics", LATER);
let feed = Feed::start(HOLIDAY_FEED.to_string());
let config = format!(
r#"
version = 1
[[endpoint]]
id = "work"
type = "caldav"
native = true
url = "{work}"
username = "{user}"
secret_command = "printf password"
[[endpoint]]
id = "published"
type = "caldav"
native = true
url = "{unified}"
username = "{user}"
secret_command = "printf password"
[[endpoint]]
id = "holidays"
type = "webcal"
url = "{feed}"
[[aggregate]]
id = "everything"
target = "published"
sources = ["work", "holidays"]
default_sink = "work"
skip_past = "2026-09-13"
"#,
work = server.url("work"),
unified = server.url("unified"),
user = support::USER,
feed = feed.url(),
);
let calcalist = Calcalist::new(root.path(), &config);
let first = calcalist.run(&["sync"]);
assert!(
first.succeeded(),
"sync failed\n{}\n{}",
first.stdout,
first.stderr
);
let published = server.stored("unified");
let all = published.join("\n");
assert!(
all.contains("Retrospective"),
"the future event should be published:\n{all}"
);
assert!(
!all.contains("Planning"),
"the finished meeting should have been left behind:\n{all}"
);
assert!(
!all.contains("New Year"),
"the finished feed event should have been left behind:\n{all}"
);
assert_eq!(
published.len(),
1,
"only one event should arrive: {published:?}"
);
// The originals are untouched: excluding history must never reach back into
// a source calendar.
assert_eq!(
server.stored("work").len(),
2,
"both source events must survive"
);
let second = calcalist.run(&["sync"]);
assert!(second.succeeded(), "second sync failed\n{}", second.stderr);
assert!(
second.stdout.contains("everything: 0 mirrored"),
"the second cycle should be a no-op:\n{}",
second.stdout
);
}