CalCalist/tests/support/mod.rs

311 lines
10 KiB
Rust
Raw Normal View History

Close the remaining M1 gaps Eight items were still open at the end of M1: two integration tests that had only been run by hand, and six known gaps. Recurrence overrides now reach Google. Google addresses an exception through the series rather than as an event of its own, so the master is sent first and each override is then matched to its instance by original start time and patched. Matching needs the two sides' spellings reduced to one key: iCalendar writes a zoned local time, Google an absolute offset. An override matching no occurrence is counted rather than forced — that means a stale RECURRENCE-ID left behind by an edited RRULE, and inventing an event for it would put something in the calendar the series does not contain. Reading one component apart from another needed a view `properties` cannot give: it flattens every VEVENT together, which is right for the UID a series shares and wrong for an override, whose SUMMARY and the master's are then indistinguishable. `Calendar::events` splits them. A TZID now travels with the VTIMEZONE that defines it, derived from the zone's own transition table as the yearly rule it implies. This changes the content hash of every zoned recurring event, so the first cycle after this re-pushes them. A Google authorisation is filed under the account it was granted for rather than the endpoint that asked for it, so two endpoints on one account no longer need a login each. The account is read from the primary calendar's id, which needs no scope beyond the calendar one already granted. Authorisations written by the previous scheme are still honoured, and move across at the next login. An unreachable Google endpoint no longer ends the cycle — one lapsed token used to stop the CalDAV side too. It is named, only the aggregates depending on it stand down, and the run exits non-zero so a partial cycle cannot pass for success. The CalDAV leg cannot be narrowed the same way: pimsync is one process covering every pair, so a failure does not say which pair it belongs to. `--dry-run` now pulls for real, into a throwaway copy of the local mirrors and through a pimsync configuration that only ever reads from a server. What it reports is measured against the calendars as they are now rather than against whatever the last real cycle left behind. `calcalist prune` reports local mirrors of endpoints the configuration no longer names, and removes them under --force. The integration tests run against a real Radicale server and a real iCal feed: convergence and idempotence, the dry run, prune, and the scheduling rule asserted on the bytes that actually reached the server. The plan asked for an SMTP sink for that last one; Radicale implements no RFC 6638 scheduling, so a quiet SMTP port would have proved nothing about the transform. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 13:49:38 +03:00
//! Scaffolding for the integration tests: a real CalDAV server, a real feed.
//!
//! Everything here talks HTTP over a plain socket rather than through a client
//! crate. The requests involved are few and mostly unusual — `MKCALENDAR`, `PUT`
//! of an `.ics` — and writing them out makes exactly what the server is asked
//! for visible in the test.
use std::io::{Read, Write};
use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
/// How long to wait for a server to start answering.
const STARTUP_TIMEOUT: Duration = Duration::from_secs(20);
/// The account Radicale files everything under. With authentication disabled it
/// accepts whatever name arrives, and `owner_only` rights then grant that name
/// its own tree — so the URLs below all live beneath it.
pub const USER: &str = "calcalist";
/// A binary the integration tests need, and whether it is here.
pub fn missing_binaries() -> Vec<&'static str> {
["radicale", "pimsync"]
.into_iter()
.filter(|binary| which(binary).is_none())
.collect()
}
fn which(binary: &str) -> Option<PathBuf> {
std::env::var_os("PATH")?
.to_str()?
.split(':')
.map(|dir| Path::new(dir).join(binary))
.find(|candidate| candidate.is_file())
}
/// A Radicale instance with its own storage, shut down when dropped.
pub struct Radicale {
process: Child,
pub port: u16,
storage: PathBuf,
}
impl Radicale {
pub fn start(root: &Path) -> Radicale {
let storage = root.join("radicale");
std::fs::create_dir_all(&storage).expect("create storage");
let port = free_port();
let process = Command::new("radicale")
.arg("--config")
.arg("")
.arg("--server-hosts")
.arg(format!("127.0.0.1:{port}"))
.arg("--auth-type")
.arg("none")
.arg("--storage-filesystem-folder")
.arg(&storage)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("radicale should start");
let server = Radicale {
process,
port,
storage,
};
wait_until_listening(port, "radicale");
server
}
pub fn url(&self, calendar: &str) -> String {
format!("http://127.0.0.1:{}/{USER}/{calendar}/", self.port)
}
/// Creates a calendar collection. pimsync deliberately never creates one,
/// so the calendars a test syncs have to exist on the server first.
pub fn create_calendar(&self, calendar: &str) {
let body = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\
<C:mkcalendar xmlns:D=\"DAV:\" xmlns:C=\"urn:ietf:params:xml:ns:caldav\">\
<D:set><D:prop><D:displayname>calendar</D:displayname></D:prop></D:set>\
</C:mkcalendar>";
let response = self.request(
"MKCALENDAR",
&format!("/{USER}/{calendar}/"),
"application/xml; charset=utf-8",
body,
);
// Radicale answers HTTP/1.0, so the status is read out of the line
// rather than matched against a whole prefix.
assert!(
matches!(status_of(&response), Some(201 | 405)),
"creating {calendar}: {response}"
);
}
/// Stores an event, as a calendar client would.
pub fn put_event(&self, calendar: &str, name: &str, ics: &str) {
let response = self.request(
"PUT",
&format!("/{USER}/{calendar}/{name}"),
"text/calendar; charset=utf-8",
ics,
);
assert!(
status_of(&response).is_some_and(|status| (200..300).contains(&status)),
"storing {name}: {response}"
);
}
/// Everything the server holds in a calendar, as stored.
///
/// Read from Radicale's own storage rather than fetched back, so what is
/// asserted on is the bytes that reached the server.
pub fn stored(&self, calendar: &str) -> Vec<String> {
let dir = self
.storage
.join("collection-root")
.join(USER)
.join(calendar);
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut items: Vec<String> = entries
.flatten()
.filter(|entry| {
entry
.path()
.extension()
.is_some_and(|extension| extension == "ics")
})
.filter_map(|entry| std::fs::read_to_string(entry.path()).ok())
.collect();
items.sort();
items
}
fn request(&self, method: &str, path: &str, content_type: &str, body: &str) -> String {
let address = SocketAddr::from((Ipv4Addr::LOCALHOST, self.port));
let mut stream = TcpStream::connect(address).expect("connect to radicale");
// With authentication disabled the credentials only pick the account.
let authorization = base64(format!("{USER}:password").as_bytes());
let request = format!(
"{method} {path} HTTP/1.1\r\nHost: 127.0.0.1:{}\r\n\
Authorization: Basic {authorization}\r\nContent-Type: {content_type}\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
self.port,
body.len()
);
stream.write_all(request.as_bytes()).expect("send request");
let mut response = String::new();
stream
.read_to_string(&mut response)
.expect("read the response");
response
}
}
impl Drop for Radicale {
fn drop(&mut self) {
let _ = self.process.kill();
let _ = self.process.wait();
}
}
/// A one-file HTTP server, standing in for a published iCal feed.
pub struct Feed {
pub port: u16,
}
impl Feed {
/// Serves `ics` at any path, for as long as the test runs.
pub fn start(ics: String) -> Feed {
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.expect("bind the feed port");
let port = listener.local_addr().expect("feed address").port();
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
serve_once(stream, &ics);
}
});
Feed { port }
}
pub fn url(&self) -> String {
format!("http://127.0.0.1:{}/holidays.ics", self.port)
}
}
fn serve_once(mut stream: TcpStream, ics: &str) {
// Enough of the request to reach the blank line; the path does not matter.
let mut buffer = [0u8; 2048];
let _ = stream.read(&mut buffer);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/calendar; charset=utf-8\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n{ics}",
ics.len()
);
let _ = stream.write_all(response.as_bytes());
}
/// Runs `calcalist` against a config and state directory of the test's own.
pub struct Calcalist {
pub config: PathBuf,
pub state: PathBuf,
}
/// What one `calcalist` invocation printed and returned.
pub struct Run {
pub status: Option<i32>,
pub stdout: String,
pub stderr: String,
}
impl Run {
pub fn succeeded(&self) -> bool {
self.status == Some(0)
}
}
impl Calcalist {
pub fn new(root: &Path, config: &str) -> Calcalist {
let config_path = root.join("calcalist.toml");
std::fs::write(&config_path, config).expect("write config");
let state = root.join("state");
std::fs::create_dir_all(&state).expect("create state directory");
Calcalist {
config: config_path,
state,
}
}
/// The same configuration with the read-only feed taken out, for the test
/// that retires an endpoint.
pub fn config_without_feed(&self) -> String {
let text = std::fs::read_to_string(&self.config).expect("read config");
text.split("\n\n")
.filter(|block| !block.contains("id = \"holidays\""))
.map(|block| block.replace(", \"holidays\"", ""))
.collect::<Vec<_>>()
.join("\n\n")
}
pub fn run(&self, arguments: &[&str]) -> Run {
let output = Command::new(env!("CARGO_BIN_EXE_calcalist"))
.arg("--config")
.arg(&self.config)
.args(arguments)
// The state directory is resolved through XDG, so this is what
// keeps the test off the developer's own calendars and state.
.env("XDG_STATE_HOME", &self.state)
.output()
.expect("calcalist should run");
Run {
status: output.status.code(),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
}
}
}
/// The status code out of a response's first line, whatever HTTP version it
/// claims.
fn status_of(response: &str) -> Option<u16> {
response
.lines()
.next()?
.split_whitespace()
.nth(1)?
.parse()
.ok()
}
fn free_port() -> u16 {
let listener =
TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).expect("bind a free port");
listener.local_addr().expect("port").port()
}
fn wait_until_listening(port: u16, what: &str) {
let address = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
let deadline = Instant::now() + STARTUP_TIMEOUT;
while Instant::now() < deadline {
if TcpStream::connect_timeout(&address, Duration::from_millis(200)).is_ok() {
return;
}
std::thread::sleep(Duration::from_millis(100));
}
panic!("{what} did not start listening on {port} within {STARTUP_TIMEOUT:?}");
}
fn base64(input: &[u8]) -> String {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for chunk in input.chunks(3) {
let mut block = [0u8; 3];
block[..chunk.len()].copy_from_slice(chunk);
let packed = u32::from(block[0]) << 16 | u32::from(block[1]) << 8 | u32::from(block[2]);
for index in 0..4 {
if index <= chunk.len() {
out.push(ALPHABET[(packed >> (18 - index * 6)) as usize & 0x3f] as char);
} else {
out.push('=');
}
}
}
out
}