Add the CalDAV client and a CLI to drive it
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.
This commit is contained in:
@@ -16,6 +16,8 @@ tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
runway-core = { workspace = true, features = ["ical", "recurrence"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
//! The HTTP layer: authentication, WebDAV methods, and status handling.
|
||||
|
||||
use crate::error::CalDavError;
|
||||
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue};
|
||||
use reqwest::{Method, Response, StatusCode, Url};
|
||||
use std::time::Duration;
|
||||
|
||||
/// A username and the password that goes with it.
|
||||
///
|
||||
/// `Debug` is implemented by hand. A derived one put the password in every
|
||||
/// trace and error report that ever touched a client — the previous iteration
|
||||
/// went further and logged `Password length: {}` from its login handler.
|
||||
#[derive(Clone)]
|
||||
pub struct Credentials {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl Credentials {
|
||||
pub fn new(username: impl Into<String>, password: impl Into<String>) -> Self {
|
||||
Self {
|
||||
username: username.into(),
|
||||
password: password.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Credentials {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Credentials")
|
||||
.field("username", &self.username)
|
||||
.field("password", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// What must be true of a resource for a write to be allowed.
|
||||
///
|
||||
/// CalDAV's answer to two clients editing at once. Every write goes through one
|
||||
/// of these; there is deliberately no way to write without saying which.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Precondition {
|
||||
/// The resource must not exist yet — `If-None-Match: *`.
|
||||
New,
|
||||
/// The resource must still carry this `ETag` — `If-Match`.
|
||||
Unchanged(String),
|
||||
/// No condition. Last write wins, and the loser is never told.
|
||||
Force,
|
||||
}
|
||||
|
||||
/// A CalDAV client bound to one server and one set of credentials.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CalDavClient {
|
||||
http: reqwest::Client,
|
||||
base: Url,
|
||||
credentials: Credentials,
|
||||
}
|
||||
|
||||
impl CalDavClient {
|
||||
/// Builds a client for a server root, such as
|
||||
/// `https://example.com/dav.php/`.
|
||||
pub fn new(base: &str, credentials: Credentials) -> Result<Self, CalDavError> {
|
||||
let base = Url::parse(base).map_err(|e| CalDavError::InvalidUrl(e.to_string()))?;
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.user_agent(concat!("Runway/", env!("CARGO_PKG_VERSION")))
|
||||
.build()?;
|
||||
Ok(Self {
|
||||
http,
|
||||
base,
|
||||
credentials,
|
||||
})
|
||||
}
|
||||
|
||||
/// The server root this client was built for.
|
||||
pub fn base(&self) -> &Url {
|
||||
&self.base
|
||||
}
|
||||
|
||||
/// Resolves an href from a response against the server root.
|
||||
///
|
||||
/// Servers return paths, not absolute URLs, so every href has to be joined
|
||||
/// back onto the origin before it can be requested.
|
||||
pub fn resolve(&self, href: &str) -> Result<Url, CalDavError> {
|
||||
self.base
|
||||
.join(href)
|
||||
.map_err(|e| CalDavError::InvalidUrl(format!("{href}: {e}")))
|
||||
}
|
||||
|
||||
/// Issues a WebDAV request and checks the status.
|
||||
pub(crate) async fn send(
|
||||
&self,
|
||||
method: &'static str,
|
||||
href: &str,
|
||||
headers: HeaderMap,
|
||||
body: Option<String>,
|
||||
) -> Result<Response, CalDavError> {
|
||||
let url = self.resolve(href)?;
|
||||
let method = Method::from_bytes(method.as_bytes())
|
||||
.map_err(|e| CalDavError::InvalidUrl(e.to_string()))?;
|
||||
|
||||
let mut request = self
|
||||
.http
|
||||
.request(method.clone(), url)
|
||||
.basic_auth(&self.credentials.username, Some(&self.credentials.password))
|
||||
.headers(headers);
|
||||
if let Some(body) = body {
|
||||
request = request.body(body);
|
||||
}
|
||||
|
||||
let response = request.send().await?;
|
||||
check(method_name(&method), href, response).await
|
||||
}
|
||||
|
||||
/// A `PROPFIND` with a body, at a given depth.
|
||||
pub(crate) async fn propfind(
|
||||
&self,
|
||||
href: &str,
|
||||
depth: &str,
|
||||
body: String,
|
||||
) -> Result<Vec<crate::xml::DavResponse>, CalDavError> {
|
||||
let mut headers = xml_headers();
|
||||
headers.insert(
|
||||
"Depth",
|
||||
HeaderValue::from_str(depth).unwrap_or(HeaderValue::from_static("0")),
|
||||
);
|
||||
let response = self.send("PROPFIND", href, headers, Some(body)).await?;
|
||||
let text = response.text().await?;
|
||||
Ok(crate::xml::parse_multistatus(&text)?)
|
||||
}
|
||||
|
||||
/// A `REPORT` with a body, at a given depth.
|
||||
pub(crate) async fn report(
|
||||
&self,
|
||||
href: &str,
|
||||
depth: &str,
|
||||
body: String,
|
||||
) -> Result<Vec<crate::xml::DavResponse>, CalDavError> {
|
||||
let mut headers = xml_headers();
|
||||
headers.insert(
|
||||
"Depth",
|
||||
HeaderValue::from_str(depth).unwrap_or(HeaderValue::from_static("1")),
|
||||
);
|
||||
let response = self.send("REPORT", href, headers, Some(body)).await?;
|
||||
let text = response.text().await?;
|
||||
Ok(crate::xml::parse_multistatus(&text)?)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn xml_headers() -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/xml; charset=utf-8"),
|
||||
);
|
||||
headers
|
||||
}
|
||||
|
||||
/// Applies a precondition to a request's headers.
|
||||
pub(crate) fn apply_precondition(headers: &mut HeaderMap, precondition: &Precondition) {
|
||||
match precondition {
|
||||
Precondition::New => {
|
||||
headers.insert("If-None-Match", HeaderValue::from_static("*"));
|
||||
}
|
||||
Precondition::Unchanged(etag) => {
|
||||
if let Ok(value) = HeaderValue::from_str(etag) {
|
||||
headers.insert("If-Match", value);
|
||||
}
|
||||
}
|
||||
Precondition::Force => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns a non-success status into the most specific error that fits.
|
||||
async fn check(
|
||||
method: &'static str,
|
||||
href: &str,
|
||||
response: Response,
|
||||
) -> Result<Response, CalDavError> {
|
||||
let status = response.status();
|
||||
if status.is_success() || status == StatusCode::MULTI_STATUS {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
Err(match status {
|
||||
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => CalDavError::Unauthorized,
|
||||
StatusCode::NOT_FOUND => CalDavError::NotFound {
|
||||
href: href.to_owned(),
|
||||
},
|
||||
// 412 is a failed If-Match: somebody else wrote first. 409 and 507 are
|
||||
// different problems and stay generic on purpose.
|
||||
StatusCode::PRECONDITION_FAILED => CalDavError::Conflict {
|
||||
href: href.to_owned(),
|
||||
},
|
||||
_ => CalDavError::Status {
|
||||
method,
|
||||
href: href.to_owned(),
|
||||
status: status.as_u16(),
|
||||
body: response.text().await.unwrap_or_default(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// The static name for a method, so errors can carry one without allocating.
|
||||
fn method_name(method: &Method) -> &'static str {
|
||||
match method.as_str() {
|
||||
"PROPFIND" => "PROPFIND",
|
||||
"REPORT" => "REPORT",
|
||||
"MKCALENDAR" => "MKCALENDAR",
|
||||
"PROPPATCH" => "PROPPATCH",
|
||||
"PUT" => "PUT",
|
||||
"GET" => "GET",
|
||||
"DELETE" => "DELETE",
|
||||
"OPTIONS" => "OPTIONS",
|
||||
_ => "request",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Finding out what calendars a server holds.
|
||||
//!
|
||||
//! Three steps, each a `PROPFIND`, exactly as RFC 4791 §6.2 and RFC 5397
|
||||
//! describe: the server names the current user's principal, the principal names
|
||||
//! its calendar home, and the home lists the collections. Nothing here guesses
|
||||
//! at a URL layout — the previous iteration walked a list of likely paths and
|
||||
//! tried each one, which works until it meets a server that arranges things
|
||||
//! differently.
|
||||
|
||||
use crate::client::CalDavClient;
|
||||
use crate::error::CalDavError;
|
||||
use crate::xml::{APPLE_ICAL, CALDAV, CALENDARSERVER, DAV, DavResponse};
|
||||
|
||||
/// A calendar collection on the server.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Calendar {
|
||||
/// Path of the collection, as the server gave it.
|
||||
pub href: String,
|
||||
/// `displayname`, when set.
|
||||
pub display_name: Option<String>,
|
||||
/// `calendar-description`.
|
||||
pub description: Option<String>,
|
||||
/// Apple's `calendar-color`, an `#RRGGBB` or `#RRGGBBAA` string.
|
||||
///
|
||||
/// Read rather than invented: the previous iteration hashed the path to
|
||||
/// pick a colour and ignored what the server already knew, so a calendar
|
||||
/// looked different here than in every other client.
|
||||
pub color: Option<String>,
|
||||
/// `getctag` — changes whenever anything in the collection does, which
|
||||
/// makes it a cheap way to skip a re-sync.
|
||||
pub ctag: Option<String>,
|
||||
/// The component types the collection accepts. Empty means the server did
|
||||
/// not say, which RFC 4791 defines as "all of them".
|
||||
pub supported_components: Vec<String>,
|
||||
}
|
||||
|
||||
impl Calendar {
|
||||
/// Whether this collection holds events.
|
||||
pub fn supports_events(&self) -> bool {
|
||||
self.supported_components.is_empty()
|
||||
|| self
|
||||
.supported_components
|
||||
.iter()
|
||||
.any(|c| c.eq_ignore_ascii_case("VEVENT"))
|
||||
}
|
||||
|
||||
/// The name to show, falling back to the last path segment.
|
||||
pub fn name(&self) -> &str {
|
||||
if let Some(name) = self.display_name.as_deref().filter(|n| !n.is_empty()) {
|
||||
return name;
|
||||
}
|
||||
self.href
|
||||
.trim_end_matches('/')
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or(&self.href)
|
||||
}
|
||||
}
|
||||
|
||||
const PRINCIPAL_BODY: &str = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:"><d:prop><d:current-user-principal/></d:prop></d:propfind>"#;
|
||||
|
||||
const HOME_BODY: &str = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:prop><c:calendar-home-set/></d:prop></d:propfind>"#;
|
||||
|
||||
const CALENDARS_BODY: &str = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"
|
||||
xmlns:cs="http://calendarserver.org/ns/"
|
||||
xmlns:ical="http://apple.com/ns/ical/">
|
||||
<d:prop>
|
||||
<d:resourcetype/><d:displayname/>
|
||||
<c:calendar-description/><c:supported-calendar-component-set/>
|
||||
<ical:calendar-color/><cs:getctag/>
|
||||
</d:prop></d:propfind>"#;
|
||||
|
||||
/// Reads a principal URL out of a `PROPFIND` response.
|
||||
///
|
||||
/// Separated from the request so it can be tested against recorded responses
|
||||
/// from a real server rather than only against a live one.
|
||||
pub fn principal_from(responses: &[DavResponse]) -> Result<String, CalDavError> {
|
||||
responses
|
||||
.iter()
|
||||
.find_map(|r| {
|
||||
r.prop(DAV, "current-user-principal")?
|
||||
.child_text(DAV, "href")
|
||||
.map(str::to_owned)
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
CalDavError::Discovery(
|
||||
"the server did not report a current-user-principal; check the URL points at \
|
||||
the DAV root and that the credentials are accepted"
|
||||
.to_owned(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads a calendar home out of a `PROPFIND` response.
|
||||
pub fn home_from(responses: &[DavResponse], principal: &str) -> Result<String, CalDavError> {
|
||||
responses
|
||||
.iter()
|
||||
.find_map(|r| {
|
||||
r.prop(CALDAV, "calendar-home-set")?
|
||||
.child_text(DAV, "href")
|
||||
.map(str::to_owned)
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
CalDavError::Discovery(format!(
|
||||
"{principal} does not advertise a calendar-home-set"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads the calendar collections out of a `PROPFIND` response.
|
||||
///
|
||||
/// Ordered by display name, so a sidebar built from this does not reshuffle
|
||||
/// itself between requests.
|
||||
pub fn calendars_from(responses: &[DavResponse]) -> Vec<Calendar> {
|
||||
let mut calendars: Vec<Calendar> = responses.iter().filter_map(as_calendar).collect();
|
||||
calendars.sort_by_key(|c| c.name().to_lowercase());
|
||||
calendars
|
||||
}
|
||||
|
||||
impl CalDavClient {
|
||||
/// The principal URL for the authenticated user.
|
||||
pub async fn current_user_principal(&self) -> Result<String, CalDavError> {
|
||||
let responses = self
|
||||
.propfind(self.base().as_str(), "0", PRINCIPAL_BODY.to_owned())
|
||||
.await?;
|
||||
principal_from(&responses)
|
||||
}
|
||||
|
||||
/// The collection holding a principal's calendars.
|
||||
pub async fn calendar_home(&self, principal: &str) -> Result<String, CalDavError> {
|
||||
let responses = self.propfind(principal, "0", HOME_BODY.to_owned()).await?;
|
||||
home_from(&responses, principal)
|
||||
}
|
||||
|
||||
/// The calendar collections inside a home.
|
||||
///
|
||||
/// Scheduling inboxes and outboxes are collections too and are filtered
|
||||
/// out here; they are not calendars a person browses.
|
||||
pub async fn calendars_in(&self, home: &str) -> Result<Vec<Calendar>, CalDavError> {
|
||||
let responses = self.propfind(home, "1", CALENDARS_BODY.to_owned()).await?;
|
||||
Ok(calendars_from(&responses))
|
||||
}
|
||||
|
||||
/// Discovery end to end: principal, home, then the calendars in it.
|
||||
pub async fn discover(&self) -> Result<Vec<Calendar>, CalDavError> {
|
||||
let principal = self.current_user_principal().await?;
|
||||
let home = self.calendar_home(&principal).await?;
|
||||
self.calendars_in(&home).await
|
||||
}
|
||||
}
|
||||
|
||||
fn as_calendar(response: &DavResponse) -> Option<Calendar> {
|
||||
let resourcetype = response.prop(DAV, "resourcetype")?;
|
||||
if !resourcetype.has_child(CALDAV, "calendar") {
|
||||
return None;
|
||||
}
|
||||
// A scheduling inbox or outbox also carries the calendar resourcetype on
|
||||
// some servers; neither belongs in a calendar list.
|
||||
if resourcetype.has_child(CALDAV, "schedule-inbox")
|
||||
|| resourcetype.has_child(CALDAV, "schedule-outbox")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Calendar {
|
||||
href: response.href.clone(),
|
||||
display_name: response.prop_text(DAV, "displayname").map(str::to_owned),
|
||||
description: response
|
||||
.prop_text(CALDAV, "calendar-description")
|
||||
.map(str::to_owned),
|
||||
color: response
|
||||
.prop_text(APPLE_ICAL, "calendar-color")
|
||||
.map(str::to_owned),
|
||||
ctag: response
|
||||
.prop_text(CALENDARSERVER, "getctag")
|
||||
.map(str::to_owned),
|
||||
supported_components: response
|
||||
.prop(CALDAV, "supported-calendar-component-set")
|
||||
.map(|set| {
|
||||
set.children(CALDAV, "comp")
|
||||
.filter_map(|c| c.attribute("name"))
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Errors from talking to a CalDAV server.
|
||||
//!
|
||||
//! Typed, so a caller can tell a stale `ETag` from a bad password from a
|
||||
//! malformed response. The previous iteration returned `Result<T, String>` at
|
||||
//! every boundary, which made a 401 and a JSON parse failure indistinguishable
|
||||
//! and left the UI with nothing to do but show the text.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CalDavError {
|
||||
/// The request never reached the server, or the connection failed.
|
||||
#[error("could not reach the CalDAV server: {0}")]
|
||||
Transport(String),
|
||||
|
||||
/// The credentials were rejected.
|
||||
#[error("the CalDAV server rejected these credentials")]
|
||||
Unauthorized,
|
||||
|
||||
/// The resource is not there.
|
||||
#[error("no such resource on the server: {href}")]
|
||||
NotFound { href: String },
|
||||
|
||||
/// The resource changed since it was read.
|
||||
///
|
||||
/// This is the case worth having a name for: it means somebody else edited
|
||||
/// the same event, and the right response is to re-read and merge rather
|
||||
/// than to retry blindly.
|
||||
#[error("{href} was modified by someone else since it was read")]
|
||||
Conflict { href: String },
|
||||
|
||||
/// The server refused for some other reason.
|
||||
#[error("{method} {href} failed: HTTP {status}{}", detail(.body))]
|
||||
Status {
|
||||
method: &'static str,
|
||||
href: String,
|
||||
status: u16,
|
||||
body: String,
|
||||
},
|
||||
|
||||
/// The response was not the XML it should have been.
|
||||
#[error("{0}")]
|
||||
Xml(#[from] crate::xml::XmlError),
|
||||
|
||||
/// The calendar data in a response could not be read.
|
||||
#[error("{href} contains unreadable calendar data: {source}")]
|
||||
Ical {
|
||||
href: String,
|
||||
#[source]
|
||||
source: runway_core::ical::IcalError,
|
||||
},
|
||||
|
||||
/// The server did not advertise what the protocol requires.
|
||||
#[error("CalDAV discovery failed: {0}")]
|
||||
Discovery(String),
|
||||
|
||||
/// A URL could not be built or resolved.
|
||||
#[error("invalid URL: {0}")]
|
||||
InvalidUrl(String),
|
||||
}
|
||||
|
||||
fn detail(body: &str) -> String {
|
||||
let trimmed = body.trim();
|
||||
if trimmed.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
// Server error bodies can be whole HTML pages; enough to identify the
|
||||
// problem is enough.
|
||||
let mut summary: String = trimmed.chars().take(300).collect();
|
||||
if trimmed.chars().count() > 300 {
|
||||
summary.push('…');
|
||||
}
|
||||
format!(" — {summary}")
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for CalDavError {
|
||||
fn from(error: reqwest::Error) -> Self {
|
||||
Self::Transport(error.to_string())
|
||||
}
|
||||
}
|
||||
@@ -1 +1,33 @@
|
||||
//! CalDAV client: discovery, time-ranged queries and ETag-aware CRUD.
|
||||
//! A CalDAV client: discovery, time-ranged queries, and ETag-aware writes.
|
||||
//!
|
||||
//! Built on `reqwest` for HTTP and `quick-xml` for the protocol. Namespaces are
|
||||
//! resolved properly rather than matched by prefix, and every write states a
|
||||
//! precondition, so two clients editing the same event produce a conflict a
|
||||
//! caller can act on instead of a silent overwrite.
|
||||
//!
|
||||
//! # Why not `libdav`
|
||||
//!
|
||||
//! [`libdav`](https://git.sr.ht/~whynothugo/libdav) covers this ground well and
|
||||
//! was evaluated before any of this was written — its ETag precondition design
|
||||
//! is where the shape of [`Precondition`] comes from. It was not adopted for
|
||||
//! one structural reason: its `HttpClient` trait is defined in terms of
|
||||
//! `hyper::body::Incoming`, so using it means running `hyper` directly and
|
||||
//! replacing `reqwest` everywhere, including the parts of Runway that only ever
|
||||
//! wanted a plain HTTPS GET. It also brings a full DNS resolver for the SRV and
|
||||
//! TXT service discovery this application does not do — the server URL is typed
|
||||
//! in by the person logging in — and a second XML parser alongside `quick-xml`.
|
||||
//!
|
||||
//! Set against that, what it would save is protocol glue that has to be typed
|
||||
//! to this application's own errors and model anyway. Worth revisiting if it
|
||||
//! grows an HTTP abstraction that is not hyper-shaped.
|
||||
|
||||
mod client;
|
||||
mod discovery;
|
||||
mod error;
|
||||
mod objects;
|
||||
pub mod xml;
|
||||
|
||||
pub use client::{CalDavClient, Credentials, Precondition};
|
||||
pub use discovery::{Calendar, calendars_from, home_from, principal_from};
|
||||
pub use error::CalDavError;
|
||||
pub use objects::{href_for, objects_from};
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
//! Reading and writing the resources inside a calendar collection.
|
||||
|
||||
use crate::client::{CalDavClient, Precondition, apply_precondition, xml_headers};
|
||||
use crate::error::CalDavError;
|
||||
use crate::xml::{CALDAV, DAV, DavResponse};
|
||||
use chrono::{DateTime, SecondsFormat, Utc};
|
||||
use reqwest::header::{CONTENT_TYPE, ETAG, HeaderMap, HeaderValue};
|
||||
use runway_core::ical;
|
||||
use runway_core::model::{CalendarObject, VCalendar};
|
||||
|
||||
impl CalDavClient {
|
||||
/// The objects in a collection that touch a time range.
|
||||
///
|
||||
/// The range is the point. RFC 4791 §9.9 has had `time-range` all along and
|
||||
/// the previous iteration never used it: every view change re-fetched the
|
||||
/// entire calendar and filtered in the browser. Here the server sends back
|
||||
/// the week that is being looked at.
|
||||
///
|
||||
/// Recurring series are returned whole — master plus overrides — because
|
||||
/// the filter asks whether the *series* touches the range. Expanding them
|
||||
/// is [`runway_core::recurrence`]'s job, not the server's.
|
||||
pub async fn events_in_range(
|
||||
&self,
|
||||
calendar_href: &str,
|
||||
from: DateTime<Utc>,
|
||||
to: DateTime<Utc>,
|
||||
) -> Result<Vec<CalendarObject>, CalDavError> {
|
||||
let body = format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:prop><d:getetag/><c:calendar-data/></d:prop>
|
||||
<c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT">
|
||||
<c:time-range start="{}" end="{}"/>
|
||||
</c:comp-filter></c:comp-filter></c:filter>
|
||||
</c:calendar-query>"#,
|
||||
ical_instant(from),
|
||||
ical_instant(to),
|
||||
);
|
||||
let responses = self.report(calendar_href, "1", body).await?;
|
||||
objects_from(calendar_href, responses)
|
||||
}
|
||||
|
||||
/// Every object in a collection, unfiltered.
|
||||
///
|
||||
/// Useful for a first sync and for the CLI; a view should be asking for a
|
||||
/// range instead.
|
||||
pub async fn all_events(
|
||||
&self,
|
||||
calendar_href: &str,
|
||||
) -> Result<Vec<CalendarObject>, CalDavError> {
|
||||
let body = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:prop><d:getetag/><c:calendar-data/></d:prop>
|
||||
<c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT"/></c:comp-filter></c:filter>
|
||||
</c:calendar-query>"#;
|
||||
let responses = self.report(calendar_href, "1", body.to_owned()).await?;
|
||||
objects_from(calendar_href, responses)
|
||||
}
|
||||
|
||||
/// The `ETag` of every object in a collection, without their bodies.
|
||||
///
|
||||
/// The cheap half of a sync: compare these against what is already held and
|
||||
/// fetch only what changed.
|
||||
pub async fn etags(&self, calendar_href: &str) -> Result<Vec<(String, String)>, CalDavError> {
|
||||
let body = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:"><d:prop><d:getetag/><d:resourcetype/></d:prop></d:propfind>"#;
|
||||
let responses = self.propfind(calendar_href, "1", body.to_owned()).await?;
|
||||
Ok(responses
|
||||
.into_iter()
|
||||
.filter(|r| r.href.trim_end_matches('/') != calendar_href.trim_end_matches('/'))
|
||||
.filter_map(|r| {
|
||||
let etag = r.prop_text(DAV, "getetag")?.to_owned();
|
||||
Some((r.href, etag))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Fetches specific objects by href in one round trip.
|
||||
pub async fn multiget(
|
||||
&self,
|
||||
calendar_href: &str,
|
||||
hrefs: &[String],
|
||||
) -> Result<Vec<CalendarObject>, CalDavError> {
|
||||
if hrefs.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let hrefs_xml: String = hrefs
|
||||
.iter()
|
||||
.map(|h| format!("<d:href>{}</d:href>", escape_xml(h)))
|
||||
.collect();
|
||||
let body = format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<c:calendar-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:prop><d:getetag/><c:calendar-data/></d:prop>{hrefs_xml}</c:calendar-multiget>"#,
|
||||
);
|
||||
let responses = self.report(calendar_href, "1", body).await?;
|
||||
objects_from(calendar_href, responses)
|
||||
}
|
||||
|
||||
/// Fetches one object with a plain `GET`.
|
||||
pub async fn get_object(
|
||||
&self,
|
||||
calendar_href: &str,
|
||||
href: &str,
|
||||
) -> Result<CalendarObject, CalDavError> {
|
||||
let response = self.send("GET", href, HeaderMap::new(), None).await?;
|
||||
let etag = etag_of(response.headers());
|
||||
let body = response.text().await?;
|
||||
ical::parse_object(href, calendar_href, etag, &body).map_err(|source| CalDavError::Ical {
|
||||
href: href.to_owned(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes an object, subject to a precondition.
|
||||
///
|
||||
/// Returns the new `ETag` when the server supplies one. Servers are not
|
||||
/// obliged to, and a caller that gets `None` has to re-read to find out
|
||||
/// where it stands rather than assuming.
|
||||
pub async fn put_object(
|
||||
&self,
|
||||
href: &str,
|
||||
calendar: &VCalendar,
|
||||
precondition: &Precondition,
|
||||
) -> Result<Option<String>, CalDavError> {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/calendar; charset=utf-8"),
|
||||
);
|
||||
apply_precondition(&mut headers, precondition);
|
||||
|
||||
let response = self
|
||||
.send("PUT", href, headers, Some(ical::write(calendar)))
|
||||
.await?;
|
||||
Ok(etag_of(response.headers()))
|
||||
}
|
||||
|
||||
/// Deletes an object, subject to a precondition.
|
||||
pub async fn delete_object(
|
||||
&self,
|
||||
href: &str,
|
||||
precondition: &Precondition,
|
||||
) -> Result<(), CalDavError> {
|
||||
let mut headers = HeaderMap::new();
|
||||
apply_precondition(&mut headers, precondition);
|
||||
self.send("DELETE", href, headers, None).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Creates a calendar collection.
|
||||
pub async fn create_calendar(
|
||||
&self,
|
||||
href: &str,
|
||||
display_name: &str,
|
||||
color: Option<&str>,
|
||||
) -> Result<(), CalDavError> {
|
||||
let color_xml = color
|
||||
.map(|c| {
|
||||
format!(
|
||||
"<ical:calendar-color>{}</ical:calendar-color>",
|
||||
escape_xml(c)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let body = format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"
|
||||
xmlns:ical="http://apple.com/ns/ical/">
|
||||
<d:set><d:prop>
|
||||
<d:displayname>{}</d:displayname>{color_xml}
|
||||
<c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set>
|
||||
</d:prop></d:set></c:mkcalendar>"#,
|
||||
escape_xml(display_name),
|
||||
);
|
||||
self.send("MKCALENDAR", href, xml_headers(), Some(body))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deletes a calendar collection and everything in it.
|
||||
pub async fn delete_calendar(&self, href: &str) -> Result<(), CalDavError> {
|
||||
self.send("DELETE", href, HeaderMap::new(), None).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The href a new object should be written to.
|
||||
///
|
||||
/// One resource per `UID`, which is what makes a series and its overrides share
|
||||
/// a file. The `UID` is percent-encoded because real ones contain `@` and `/`
|
||||
/// and a path segment cannot.
|
||||
pub fn href_for(calendar_href: &str, uid: &str) -> String {
|
||||
let mut out = String::with_capacity(calendar_href.len() + uid.len() + 8);
|
||||
out.push_str(calendar_href.trim_end_matches('/'));
|
||||
out.push('/');
|
||||
for byte in uid.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(byte as char);
|
||||
}
|
||||
other => out.push_str(&format!("%{other:02X}")),
|
||||
}
|
||||
}
|
||||
out.push_str(".ics");
|
||||
out
|
||||
}
|
||||
|
||||
/// Reads calendar objects out of a `REPORT` response.
|
||||
///
|
||||
/// Responses carrying no `calendar-data` are skipped rather than treated as
|
||||
/// empty calendars: a `propstat` with a 404 lists what the resource does not
|
||||
/// have, and folding those in would invent objects that are not there.
|
||||
pub fn objects_from(
|
||||
calendar_href: &str,
|
||||
responses: Vec<DavResponse>,
|
||||
) -> Result<Vec<CalendarObject>, CalDavError> {
|
||||
let mut out = Vec::new();
|
||||
for response in responses {
|
||||
let Some(data) = response.prop_text(CALDAV, "calendar-data") else {
|
||||
continue;
|
||||
};
|
||||
let etag = response.prop_text(DAV, "getetag").map(str::to_owned);
|
||||
out.push(
|
||||
ical::parse_object(&response.href, calendar_href, etag, data).map_err(|source| {
|
||||
CalDavError::Ical {
|
||||
href: response.href.clone(),
|
||||
source,
|
||||
}
|
||||
})?,
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn etag_of(headers: &HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get(ETAG)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
/// The `YYYYMMDDTHHMMSSZ` form a `time-range` filter takes.
|
||||
fn ical_instant(at: DateTime<Utc>) -> String {
|
||||
at.to_rfc3339_opts(SecondsFormat::Secs, true)
|
||||
.replace(['-', ':'], "")
|
||||
}
|
||||
|
||||
fn escape_xml(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! Reading WebDAV `multistatus` documents.
|
||||
//!
|
||||
//! Namespaces are resolved by the parser, which is the whole point. The
|
||||
//! previous iteration extracted values with regular expressions and tried
|
||||
//! **six** patterns in sequence to cope with the fact that one server writes
|
||||
//! `<d:href>`, another `<D:href>` and a third `<href xmlns="DAV:">` — then
|
||||
//! recompiled every one of those `Regex` objects inside the loop. A prefix is
|
||||
//! not a namespace; `quick-xml`'s `NsReader` knows the difference and this
|
||||
//! module never sees a prefix at all.
|
||||
//!
|
||||
//! Documents are read into a small generic element tree rather than parsed
|
||||
//! directly into typed responses. WebDAV properties nest arbitrarily —
|
||||
//! `<resourcetype><calendar/></resourcetype>`,
|
||||
//! `<supported-calendar-component-set><comp name="VEVENT"/></...>` — and a tree
|
||||
//! handles all of it without a special case per property.
|
||||
|
||||
use quick_xml::events::Event;
|
||||
use quick_xml::name::ResolveResult;
|
||||
use quick_xml::reader::NsReader;
|
||||
|
||||
pub const DAV: &str = "DAV:";
|
||||
pub const CALDAV: &str = "urn:ietf:params:xml:ns:caldav";
|
||||
pub const CALENDARSERVER: &str = "http://calendarserver.org/ns/";
|
||||
pub const APPLE_ICAL: &str = "http://apple.com/ns/ical/";
|
||||
|
||||
/// One element of a parsed document.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct Element {
|
||||
/// The resolved namespace URI, empty when the element has none.
|
||||
pub ns: String,
|
||||
/// The local name, with any prefix already stripped.
|
||||
pub name: String,
|
||||
/// Character data directly inside this element, trimmed.
|
||||
pub text: String,
|
||||
pub attributes: Vec<(String, String)>,
|
||||
pub children: Vec<Element>,
|
||||
}
|
||||
|
||||
impl Element {
|
||||
/// Whether this element is the named one.
|
||||
pub fn is(&self, ns: &str, name: &str) -> bool {
|
||||
self.ns == ns && self.name == name
|
||||
}
|
||||
|
||||
/// Direct children with a given name.
|
||||
pub fn children(&self, ns: &str, name: &str) -> impl Iterator<Item = &Element> {
|
||||
self.children.iter().filter(move |c| c.is(ns, name))
|
||||
}
|
||||
|
||||
/// The first direct child with a given name.
|
||||
pub fn child(&self, ns: &str, name: &str) -> Option<&Element> {
|
||||
self.children(ns, name).next()
|
||||
}
|
||||
|
||||
/// The text of the first direct child with a given name, if it is not empty.
|
||||
pub fn child_text(&self, ns: &str, name: &str) -> Option<&str> {
|
||||
self.child(ns, name)
|
||||
.map(|c| c.text.as_str())
|
||||
.filter(|t| !t.is_empty())
|
||||
}
|
||||
|
||||
/// An attribute value by name.
|
||||
pub fn attribute(&self, name: &str) -> Option<&str> {
|
||||
self.attributes
|
||||
.iter()
|
||||
.find(|(k, _)| k == name)
|
||||
.map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
/// Whether a child element exists — how WebDAV states a boolean, as in
|
||||
/// `<resourcetype><calendar/></resourcetype>`.
|
||||
pub fn has_child(&self, ns: &str, name: &str) -> bool {
|
||||
self.child(ns, name).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Failure to read a document.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("malformed XML response: {0}")]
|
||||
pub struct XmlError(pub String);
|
||||
|
||||
/// Parses a document into its root element.
|
||||
pub fn parse(xml: &str) -> Result<Element, XmlError> {
|
||||
let mut reader = NsReader::from_str(xml);
|
||||
|
||||
let mut stack: Vec<Element> = Vec::new();
|
||||
let mut root: Option<Element> = None;
|
||||
|
||||
loop {
|
||||
let (ns, event) = reader
|
||||
.read_resolved_event()
|
||||
.map_err(|e| XmlError(e.to_string()))?;
|
||||
|
||||
match event {
|
||||
Event::Start(start) => stack.push(element_from(&ns, &start)?),
|
||||
Event::Empty(empty) => {
|
||||
let element = element_from(&ns, &empty)?;
|
||||
push(&mut stack, &mut root, element);
|
||||
}
|
||||
Event::Text(text) => {
|
||||
if let Some(current) = stack.last_mut() {
|
||||
current.text.push_str(&text.xml10_content());
|
||||
}
|
||||
}
|
||||
// SabreDAV wraps some property values in CDATA.
|
||||
Event::CData(data) => {
|
||||
if let Some(current) = stack.last_mut() {
|
||||
current.text.push_str(&data.xml10_content());
|
||||
}
|
||||
}
|
||||
// Entity references arrive as their own event rather than inline,
|
||||
// so `&` in a display name has to be put back by hand. The
|
||||
// library's own resolver is reused for it, numeric references
|
||||
// included.
|
||||
Event::GeneralRef(reference) => {
|
||||
if let Some(current) = stack.last_mut() {
|
||||
let name = reference.into_inner();
|
||||
let resolved = quick_xml::escape::unescape(&format!("&{name};"))
|
||||
.map_err(|e| XmlError(format!("unknown entity &{name};: {e}")))?
|
||||
.into_owned();
|
||||
current.text.push_str(&resolved);
|
||||
}
|
||||
}
|
||||
Event::End(_) => {
|
||||
let Some(mut finished) = stack.pop() else {
|
||||
return Err(XmlError("closing tag with nothing open".to_owned()));
|
||||
};
|
||||
// Trimmed once, at the end. Trimming each text event instead
|
||||
// would eat the spaces around an entity reference and turn
|
||||
// "Bed & Breakfast" into "Bed&Breakfast".
|
||||
let trimmed = finished.text.trim();
|
||||
if trimmed.len() != finished.text.len() {
|
||||
finished.text = trimmed.to_owned();
|
||||
}
|
||||
push(&mut stack, &mut root, finished);
|
||||
}
|
||||
Event::Eof => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
root.ok_or_else(|| XmlError("no elements in document".to_owned()))
|
||||
}
|
||||
|
||||
fn push(stack: &mut [Element], root: &mut Option<Element>, element: Element) {
|
||||
match stack.last_mut() {
|
||||
Some(parent) => parent.children.push(element),
|
||||
None => *root = Some(element),
|
||||
}
|
||||
}
|
||||
|
||||
fn element_from(
|
||||
ns: &ResolveResult<'_>,
|
||||
tag: &quick_xml::events::BytesStart<'_>,
|
||||
) -> Result<Element, XmlError> {
|
||||
let namespace = match ns {
|
||||
ResolveResult::Bound(bound) => bound.as_ref().to_owned(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let mut attributes = Vec::new();
|
||||
for attribute in tag.attributes() {
|
||||
let attribute = attribute.map_err(|e| XmlError(e.to_string()))?;
|
||||
let key = attribute.key.local_name().as_ref().to_owned();
|
||||
let value = attribute
|
||||
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
|
||||
.map_err(|e| XmlError(e.to_string()))?
|
||||
.into_owned();
|
||||
attributes.push((key, value));
|
||||
}
|
||||
Ok(Element {
|
||||
ns: namespace,
|
||||
name: tag.name().local_name().as_ref().to_owned(),
|
||||
text: String::new(),
|
||||
attributes,
|
||||
children: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// One `<response>` from a `multistatus`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DavResponse {
|
||||
pub href: String,
|
||||
/// Properties that the server reported as found, flattened across every
|
||||
/// `propstat` whose status was 2xx.
|
||||
///
|
||||
/// A `propstat` carrying 404 lists the properties the resource does *not*
|
||||
/// have; folding those in as though they were present is how a missing
|
||||
/// display name becomes an empty one.
|
||||
pub found: Vec<Element>,
|
||||
/// A `<status>` on the response itself, present on error responses.
|
||||
pub status: Option<u16>,
|
||||
}
|
||||
|
||||
impl DavResponse {
|
||||
/// A found property by name.
|
||||
pub fn prop(&self, ns: &str, name: &str) -> Option<&Element> {
|
||||
self.found.iter().find(|p| p.is(ns, name))
|
||||
}
|
||||
|
||||
/// The text of a found property.
|
||||
pub fn prop_text(&self, ns: &str, name: &str) -> Option<&str> {
|
||||
self.prop(ns, name)
|
||||
.map(|p| p.text.as_str())
|
||||
.filter(|t| !t.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a `multistatus` document into its responses.
|
||||
pub fn parse_multistatus(xml: &str) -> Result<Vec<DavResponse>, XmlError> {
|
||||
let root = parse(xml)?;
|
||||
if !root.is(DAV, "multistatus") {
|
||||
return Err(XmlError(format!(
|
||||
"expected a DAV:multistatus document, got {{{}}}{}",
|
||||
root.ns, root.name,
|
||||
)));
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
for response in root.children(DAV, "response") {
|
||||
let href = response
|
||||
.child_text(DAV, "href")
|
||||
.ok_or_else(|| XmlError("a response has no href".to_owned()))?
|
||||
.to_owned();
|
||||
|
||||
let mut found = Vec::new();
|
||||
for propstat in response.children(DAV, "propstat") {
|
||||
let ok = propstat
|
||||
.child_text(DAV, "status")
|
||||
.and_then(status_code)
|
||||
.is_some_and(|code| (200..300).contains(&code));
|
||||
if !ok {
|
||||
continue;
|
||||
}
|
||||
if let Some(prop) = propstat.child(DAV, "prop") {
|
||||
found.extend(prop.children.iter().cloned());
|
||||
}
|
||||
}
|
||||
|
||||
out.push(DavResponse {
|
||||
href,
|
||||
found,
|
||||
status: response.child_text(DAV, "status").and_then(status_code),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Pulls the numeric code out of a `HTTP/1.1 200 OK` status line.
|
||||
pub fn status_code(line: &str) -> Option<u16> {
|
||||
line.split_whitespace().nth(1)?.parse().ok()
|
||||
}
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run the CalDAV integration tests against a throwaway Baikal.
|
||||
#
|
||||
# Starts a container, walks it through the install wizard, runs the live test
|
||||
# suite against it, and tears it down again. Nothing touches a real calendar.
|
||||
#
|
||||
# crates/runway-caldav/tests/baikal/run.sh # start, test, stop
|
||||
# KEEP=1 crates/runway-caldav/tests/baikal/run.sh # leave it running
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
NAME="${NAME:-runway-baikal}"
|
||||
PORT="${PORT:-8800}"
|
||||
IMAGE="${IMAGE:-docker.io/ckulka/baikal:nginx}"
|
||||
USERNAME="testuser"
|
||||
PASSWORD="testpassword"
|
||||
|
||||
runtime() {
|
||||
if command -v podman >/dev/null 2>&1; then echo podman
|
||||
elif command -v docker >/dev/null 2>&1; then echo docker
|
||||
else echo "need podman or docker" >&2; exit 1
|
||||
fi
|
||||
}
|
||||
RUNTIME="$(runtime)"
|
||||
|
||||
cleanup() {
|
||||
if [ "${KEEP:-0}" != "1" ]; then
|
||||
"$RUNTIME" rm -f "$NAME" >/dev/null 2>&1 || true
|
||||
else
|
||||
echo "container $NAME left running on port $PORT"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
"$RUNTIME" rm -f "$NAME" >/dev/null 2>&1 || true
|
||||
"$RUNTIME" run -d --rm --name "$NAME" -p "$PORT:80" "$IMAGE" >/dev/null
|
||||
echo "started $NAME ($IMAGE) on port $PORT"
|
||||
|
||||
# Baikal needs a moment before PHP answers.
|
||||
for _ in $(seq 1 60); do
|
||||
if [ "$(curl -sS -o /dev/null -w '%{http_code}' -L "http://localhost:$PORT/" 2>/dev/null)" = "200" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
python3 "$HERE/setup.py" "http://localhost:$PORT" "$USERNAME" "$PASSWORD"
|
||||
|
||||
export RUNWAY_CALDAV_URL="http://localhost:$PORT/dav.php/"
|
||||
export RUNWAY_CALDAV_USER="$USERNAME"
|
||||
export RUNWAY_CALDAV_PASSWORD="$PASSWORD"
|
||||
|
||||
cargo test -p runway-caldav --test live -- --test-threads=1 "$@"
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Drive a fresh Baikal container through its install wizard.
|
||||
|
||||
Baikal has no unattended-install path, so the integration tests would otherwise
|
||||
need a hand-prepared image. This walks the same web forms a person would,
|
||||
leaving a server with Basic authentication, one user, and one calendar.
|
||||
|
||||
Usage:
|
||||
podman run -d --rm --name runway-baikal -p 8800:80 docker.io/ckulka/baikal:nginx
|
||||
python3 setup.py http://localhost:8800 testuser testpassword
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import http.cookiejar
|
||||
|
||||
ADMIN_PASSWORD = "runway-integration-admin"
|
||||
|
||||
|
||||
def make_opener():
|
||||
jar = http.cookiejar.CookieJar()
|
||||
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||
|
||||
|
||||
def get(opener, url):
|
||||
with opener.open(url, timeout=30) as response:
|
||||
return response.read().decode("utf-8", "replace")
|
||||
|
||||
|
||||
def post(opener, url, fields):
|
||||
body = urllib.parse.urlencode(fields).encode()
|
||||
request = urllib.request.Request(
|
||||
url, data=body, headers={"Content-Type": "application/x-www-form-urlencoded"}
|
||||
)
|
||||
with opener.open(request, timeout=30) as response:
|
||||
return response.read().decode("utf-8", "replace")
|
||||
|
||||
|
||||
def csrf(html):
|
||||
"""The page's CSRF token. Not every form carries one -- the admin login
|
||||
does not -- so callers that may see either use `maybe_csrf`."""
|
||||
token = maybe_csrf(html)
|
||||
if token is None:
|
||||
raise SystemExit("no CSRF token in page; Baikal's forms have changed")
|
||||
return token
|
||||
|
||||
|
||||
def maybe_csrf(html):
|
||||
match = re.search(r'name="CSRF_TOKEN"\s+value="([^"]+)"', html)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def install(base, username, password):
|
||||
"""Walk the wizard until it stops offering forms.
|
||||
|
||||
The wizard serves every step from the same URL and decides which form to
|
||||
show from its own stored state, so this is driven by what comes back rather
|
||||
than by a fixed sequence of URLs -- which is also what keeps it working
|
||||
across Baikal versions that add or reorder a step.
|
||||
"""
|
||||
opener = make_opener()
|
||||
|
||||
for _ in range(6):
|
||||
page = get(opener, f"{base}/admin/install/")
|
||||
if "Baikal_Model_Config_Standard::submitted" in page:
|
||||
post(opener, f"{base}/admin/install/", system_fields(csrf(page)))
|
||||
elif "Baikal_Model_Config_Database::submitted" in page:
|
||||
post(opener, f"{base}/admin/install/", database_fields(csrf(page)))
|
||||
else:
|
||||
break
|
||||
else:
|
||||
raise SystemExit("the install wizard did not finish")
|
||||
|
||||
# Log in to the admin interface. This form has no CSRF token of its own.
|
||||
page = get(opener, f"{base}/admin/")
|
||||
if 'name="auth"' in page:
|
||||
fields = {"auth": "1", "login": "admin", "password": ADMIN_PASSWORD}
|
||||
token = maybe_csrf(page)
|
||||
if token:
|
||||
fields["CSRF_TOKEN"] = token
|
||||
post(opener, f"{base}/admin/", fields)
|
||||
|
||||
# Create the test user. Baikal gives every new user a default calendar.
|
||||
page = get(opener, f"{base}/admin/?/users/new/1/")
|
||||
post(
|
||||
opener,
|
||||
f"{base}/admin/?/users/new/1/",
|
||||
{
|
||||
"Baikal_Model_User::submitted": "1",
|
||||
"refreshed": "0",
|
||||
"CSRF_TOKEN": csrf(page),
|
||||
"data[username]": username,
|
||||
"witness[username]": "1",
|
||||
"data[displayname]": "Integration Test",
|
||||
"witness[displayname]": "1",
|
||||
"data[email]": f"{username}@example.org",
|
||||
"witness[email]": "1",
|
||||
"data[password]": password,
|
||||
"witness[password]": "1",
|
||||
"data[passwordconfirm]": password,
|
||||
"witness[passwordconfirm]": "1",
|
||||
},
|
||||
)
|
||||
|
||||
users = get(opener, f"{base}/admin/?/users/")
|
||||
if username not in users:
|
||||
raise SystemExit(f"user {username} was not created")
|
||||
print(f"ready: {base}/dav.php/ as {username}")
|
||||
|
||||
|
||||
def system_fields(token):
|
||||
"""Step one. Basic authentication, because that is what the client sends;
|
||||
Baikal defaults to Digest."""
|
||||
return {
|
||||
"Baikal_Model_Config_Standard::submitted": "1",
|
||||
"refreshed": "0",
|
||||
"CSRF_TOKEN": token,
|
||||
"data[timezone]": "UTC",
|
||||
"witness[timezone]": "1",
|
||||
"data[card_enabled]": "1",
|
||||
"witness[card_enabled]": "1",
|
||||
"data[cal_enabled]": "1",
|
||||
"witness[cal_enabled]": "1",
|
||||
"data[invite_from]": "noreply@example.org",
|
||||
"witness[invite_from]": "1",
|
||||
"data[dav_auth_type]": "Basic",
|
||||
"witness[dav_auth_type]": "1",
|
||||
"data[admin_passwordhash]": ADMIN_PASSWORD,
|
||||
"witness[admin_passwordhash]": "1",
|
||||
"data[admin_passwordhash_confirm]": ADMIN_PASSWORD,
|
||||
"witness[admin_passwordhash_confirm]": "1",
|
||||
}
|
||||
|
||||
|
||||
def database_fields(token):
|
||||
"""Step two. SQLite, at the path the form arrives pre-filled with."""
|
||||
return {
|
||||
"Baikal_Model_Config_Database::submitted": "1",
|
||||
"refreshed": "0",
|
||||
"CSRF_TOKEN": token,
|
||||
"data[backend]": "sqlite",
|
||||
"witness[backend]": "1",
|
||||
"data[sqlite_file]": "/var/www/baikal/Specific/db/db.sqlite",
|
||||
"witness[sqlite_file]": "1",
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 4:
|
||||
raise SystemExit(__doc__)
|
||||
install(sys.argv[1].rstrip("/"), sys.argv[2], sys.argv[3])
|
||||
@@ -0,0 +1,200 @@
|
||||
<?xml version="1.0" ?>
|
||||
<multistatus xmlns="DAV:" xmlns:s="http://sabredav.org/ns" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||
<response>
|
||||
<href>/dav.php/calendars/alex/</href>
|
||||
<propstat>
|
||||
<prop>
|
||||
<resourcetype>
|
||||
<collection/>
|
||||
</resourcetype>
|
||||
</prop>
|
||||
<status>HTTP/1.1 200 OK</status>
|
||||
</propstat>
|
||||
<propstat>
|
||||
<prop>
|
||||
<displayname/>
|
||||
<CAL:calendar-description/>
|
||||
<CAL:supported-calendar-component-set/>
|
||||
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/"/>
|
||||
<cs:getctag/>
|
||||
</prop>
|
||||
<status>HTTP/1.1 404 Not Found</status>
|
||||
</propstat>
|
||||
</response>
|
||||
<response>
|
||||
<href>/dav.php/calendars/alex/household-chores/</href>
|
||||
<propstat>
|
||||
<prop>
|
||||
<resourcetype>
|
||||
<collection/>
|
||||
<CAL:calendar/>
|
||||
</resourcetype>
|
||||
<displayname>Household Chores</displayname>
|
||||
<CAL:supported-calendar-component-set>
|
||||
<CAL:comp name="VEVENT"/>
|
||||
</CAL:supported-calendar-component-set>
|
||||
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#DC2626</x1:calendar-color>
|
||||
<cs:getctag>http://sabre.io/ns/sync/4</cs:getctag>
|
||||
</prop>
|
||||
<status>HTTP/1.1 200 OK</status>
|
||||
</propstat>
|
||||
<propstat>
|
||||
<prop>
|
||||
<CAL:calendar-description/>
|
||||
</prop>
|
||||
<status>HTTP/1.1 404 Not Found</status>
|
||||
</propstat>
|
||||
</response>
|
||||
<response>
|
||||
<href>/dav.php/calendars/alex/partner-chores/</href>
|
||||
<propstat>
|
||||
<prop>
|
||||
<resourcetype>
|
||||
<collection/>
|
||||
<CAL:calendar/>
|
||||
</resourcetype>
|
||||
<displayname>Partner Chores</displayname>
|
||||
<CAL:supported-calendar-component-set>
|
||||
<CAL:comp name="VEVENT"/>
|
||||
</CAL:supported-calendar-component-set>
|
||||
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#7C3AED</x1:calendar-color>
|
||||
<cs:getctag>http://sabre.io/ns/sync/6</cs:getctag>
|
||||
</prop>
|
||||
<status>HTTP/1.1 200 OK</status>
|
||||
</propstat>
|
||||
<propstat>
|
||||
<prop>
|
||||
<CAL:calendar-description/>
|
||||
</prop>
|
||||
<status>HTTP/1.1 404 Not Found</status>
|
||||
</propstat>
|
||||
</response>
|
||||
<response>
|
||||
<href>/dav.php/calendars/alex/trips/</href>
|
||||
<propstat>
|
||||
<prop>
|
||||
<resourcetype>
|
||||
<collection/>
|
||||
<CAL:calendar/>
|
||||
</resourcetype>
|
||||
<displayname>Trips</displayname>
|
||||
<CAL:supported-calendar-component-set>
|
||||
<CAL:comp name="VEVENT"/>
|
||||
</CAL:supported-calendar-component-set>
|
||||
<cs:getctag>http://sabre.io/ns/sync/23</cs:getctag>
|
||||
</prop>
|
||||
<status>HTTP/1.1 200 OK</status>
|
||||
</propstat>
|
||||
<propstat>
|
||||
<prop>
|
||||
<CAL:calendar-description/>
|
||||
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/"/>
|
||||
</prop>
|
||||
<status>HTTP/1.1 404 Not Found</status>
|
||||
</propstat>
|
||||
</response>
|
||||
<response>
|
||||
<href>/dav.php/calendars/alex/workouts/</href>
|
||||
<propstat>
|
||||
<prop>
|
||||
<resourcetype>
|
||||
<collection/>
|
||||
<CAL:calendar/>
|
||||
</resourcetype>
|
||||
<displayname>Workouts</displayname>
|
||||
<CAL:calendar-description>Calendar for logging workouts</CAL:calendar-description>
|
||||
<CAL:supported-calendar-component-set>
|
||||
<CAL:comp name="VEVENT"/>
|
||||
</CAL:supported-calendar-component-set>
|
||||
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#3B82F6</x1:calendar-color>
|
||||
<cs:getctag>http://sabre.io/ns/sync/64</cs:getctag>
|
||||
</prop>
|
||||
<status>HTTP/1.1 200 OK</status>
|
||||
</propstat>
|
||||
</response>
|
||||
<response>
|
||||
<href>/dav.php/calendars/alex/birthdays/</href>
|
||||
<propstat>
|
||||
<prop>
|
||||
<resourcetype>
|
||||
<collection/>
|
||||
<CAL:calendar/>
|
||||
<cs:shared-owner/>
|
||||
</resourcetype>
|
||||
<displayname>Birthdays</displayname>
|
||||
<CAL:calendar-description/>
|
||||
<CAL:supported-calendar-component-set>
|
||||
<CAL:comp name="VEVENT"/>
|
||||
</CAL:supported-calendar-component-set>
|
||||
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#DD403A</x1:calendar-color>
|
||||
<cs:getctag>http://sabre.io/ns/sync/36</cs:getctag>
|
||||
</prop>
|
||||
<status>HTTP/1.1 200 OK</status>
|
||||
</propstat>
|
||||
</response>
|
||||
<response>
|
||||
<href>/dav.php/calendars/alex/personal/</href>
|
||||
<propstat>
|
||||
<prop>
|
||||
<resourcetype>
|
||||
<collection/>
|
||||
<CAL:calendar/>
|
||||
</resourcetype>
|
||||
<displayname>Personal</displayname>
|
||||
<CAL:calendar-description/>
|
||||
<CAL:supported-calendar-component-set>
|
||||
<CAL:comp name="VEVENT"/>
|
||||
<CAL:comp name="VTODO"/>
|
||||
<CAL:comp name="VJOURNAL"/>
|
||||
</CAL:supported-calendar-component-set>
|
||||
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#0CCE6B</x1:calendar-color>
|
||||
<cs:getctag>http://sabre.io/ns/sync/930</cs:getctag>
|
||||
</prop>
|
||||
<status>HTTP/1.1 200 OK</status>
|
||||
</propstat>
|
||||
</response>
|
||||
<response>
|
||||
<href>/dav.php/calendars/alex/inbox/</href>
|
||||
<propstat>
|
||||
<prop>
|
||||
<resourcetype>
|
||||
<collection/>
|
||||
<CAL:schedule-inbox/>
|
||||
</resourcetype>
|
||||
</prop>
|
||||
<status>HTTP/1.1 200 OK</status>
|
||||
</propstat>
|
||||
<propstat>
|
||||
<prop>
|
||||
<displayname/>
|
||||
<CAL:calendar-description/>
|
||||
<CAL:supported-calendar-component-set/>
|
||||
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/"/>
|
||||
<cs:getctag/>
|
||||
</prop>
|
||||
<status>HTTP/1.1 404 Not Found</status>
|
||||
</propstat>
|
||||
</response>
|
||||
<response>
|
||||
<href>/dav.php/calendars/alex/outbox/</href>
|
||||
<propstat>
|
||||
<prop>
|
||||
<resourcetype>
|
||||
<collection/>
|
||||
<CAL:schedule-outbox/>
|
||||
</resourcetype>
|
||||
</prop>
|
||||
<status>HTTP/1.1 200 OK</status>
|
||||
</propstat>
|
||||
<propstat>
|
||||
<prop>
|
||||
<displayname/>
|
||||
<CAL:calendar-description/>
|
||||
<CAL:supported-calendar-component-set/>
|
||||
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/"/>
|
||||
<cs:getctag/>
|
||||
</prop>
|
||||
<status>HTTP/1.1 404 Not Found</status>
|
||||
</propstat>
|
||||
</response>
|
||||
</multistatus>
|
||||
@@ -0,0 +1,200 @@
|
||||
<?xml version="1.0" ?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns" xmlns:cal="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||
<d:response>
|
||||
<d:href>/dav.php/calendars/alex/</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:resourcetype>
|
||||
<d:collection/>
|
||||
</d:resourcetype>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
<cal:calendar-description/>
|
||||
<cal:supported-calendar-component-set/>
|
||||
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/"/>
|
||||
<cs:getctag/>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/dav.php/calendars/alex/household-chores/</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:resourcetype>
|
||||
<d:collection/>
|
||||
<cal:calendar/>
|
||||
</d:resourcetype>
|
||||
<d:displayname>Household Chores</d:displayname>
|
||||
<cal:supported-calendar-component-set>
|
||||
<cal:comp name="VEVENT"/>
|
||||
</cal:supported-calendar-component-set>
|
||||
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#DC2626</x1:calendar-color>
|
||||
<cs:getctag>http://sabre.io/ns/sync/4</cs:getctag>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<cal:calendar-description/>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/dav.php/calendars/alex/partner-chores/</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:resourcetype>
|
||||
<d:collection/>
|
||||
<cal:calendar/>
|
||||
</d:resourcetype>
|
||||
<d:displayname>Partner Chores</d:displayname>
|
||||
<cal:supported-calendar-component-set>
|
||||
<cal:comp name="VEVENT"/>
|
||||
</cal:supported-calendar-component-set>
|
||||
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#7C3AED</x1:calendar-color>
|
||||
<cs:getctag>http://sabre.io/ns/sync/6</cs:getctag>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<cal:calendar-description/>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/dav.php/calendars/alex/trips/</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:resourcetype>
|
||||
<d:collection/>
|
||||
<cal:calendar/>
|
||||
</d:resourcetype>
|
||||
<d:displayname>Trips</d:displayname>
|
||||
<cal:supported-calendar-component-set>
|
||||
<cal:comp name="VEVENT"/>
|
||||
</cal:supported-calendar-component-set>
|
||||
<cs:getctag>http://sabre.io/ns/sync/23</cs:getctag>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<cal:calendar-description/>
|
||||
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/"/>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/dav.php/calendars/alex/workouts/</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:resourcetype>
|
||||
<d:collection/>
|
||||
<cal:calendar/>
|
||||
</d:resourcetype>
|
||||
<d:displayname>Workouts</d:displayname>
|
||||
<cal:calendar-description>Calendar for logging workouts</cal:calendar-description>
|
||||
<cal:supported-calendar-component-set>
|
||||
<cal:comp name="VEVENT"/>
|
||||
</cal:supported-calendar-component-set>
|
||||
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#3B82F6</x1:calendar-color>
|
||||
<cs:getctag>http://sabre.io/ns/sync/64</cs:getctag>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/dav.php/calendars/alex/birthdays/</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:resourcetype>
|
||||
<d:collection/>
|
||||
<cal:calendar/>
|
||||
<cs:shared-owner/>
|
||||
</d:resourcetype>
|
||||
<d:displayname>Birthdays</d:displayname>
|
||||
<cal:calendar-description/>
|
||||
<cal:supported-calendar-component-set>
|
||||
<cal:comp name="VEVENT"/>
|
||||
</cal:supported-calendar-component-set>
|
||||
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#DD403A</x1:calendar-color>
|
||||
<cs:getctag>http://sabre.io/ns/sync/36</cs:getctag>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/dav.php/calendars/alex/personal/</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:resourcetype>
|
||||
<d:collection/>
|
||||
<cal:calendar/>
|
||||
</d:resourcetype>
|
||||
<d:displayname>Personal</d:displayname>
|
||||
<cal:calendar-description/>
|
||||
<cal:supported-calendar-component-set>
|
||||
<cal:comp name="VEVENT"/>
|
||||
<cal:comp name="VTODO"/>
|
||||
<cal:comp name="VJOURNAL"/>
|
||||
</cal:supported-calendar-component-set>
|
||||
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#0CCE6B</x1:calendar-color>
|
||||
<cs:getctag>http://sabre.io/ns/sync/930</cs:getctag>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/dav.php/calendars/alex/inbox/</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:resourcetype>
|
||||
<d:collection/>
|
||||
<cal:schedule-inbox/>
|
||||
</d:resourcetype>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
<cal:calendar-description/>
|
||||
<cal:supported-calendar-component-set/>
|
||||
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/"/>
|
||||
<cs:getctag/>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/dav.php/calendars/alex/outbox/</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:resourcetype>
|
||||
<d:collection/>
|
||||
<cal:schedule-outbox/>
|
||||
</d:resourcetype>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
<cal:calendar-description/>
|
||||
<cal:supported-calendar-component-set/>
|
||||
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/"/>
|
||||
<cs:getctag/>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" ?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns" xmlns:cal="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||
<d:response>
|
||||
<d:href>/dav.php/</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:current-user-principal>
|
||||
<d:href>/dav.php/principals/alex/</d:href>
|
||||
</d:current-user-principal>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>
|
||||
@@ -0,0 +1,96 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns" xmlns:cal="urn:ietf:params:xml:ns:caldav">
|
||||
<d:response>
|
||||
<d:href>/dav.php/calendars/alex/personal/allday.ics</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:getetag>"7c9e8a1d2f3b4c5d6e7f8a9b0c1d2e3f"</d:getetag>
|
||||
<cal:calendar-data>BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:DAVx5/4.4.8-gplay ical4j/3.2.19 (com.digibites.calendar)
|
||||
BEGIN:VEVENT
|
||||
UID:eee51914-187b-40d5-342c-dc80c118438a
|
||||
STATUS:CONFIRMED
|
||||
SUMMARY:Check-in
|
||||
CLASS:PUBLIC
|
||||
TRANSP:OPAQUE
|
||||
DTSTART;VALUE=DATE:20250331
|
||||
DTEND;VALUE=DATE:20250401
|
||||
RRULE:FREQ=WEEKLY;BYDAY=MO
|
||||
CREATED:20250902T164854Z
|
||||
DTSTAMP:20250902T164854Z
|
||||
LAST-MODIFIED:20250902T164854Z
|
||||
SEQUENCE:2
|
||||
BEGIN:VALARM
|
||||
ACTION:DISPLAY
|
||||
DESCRIPTION:Retrospective demo retro
|
||||
TRIGGER:-PT4H
|
||||
END:VALARM
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
</cal:calendar-data>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/dav.php/calendars/alex/personal/zoned.ics</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:getetag>"1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d"</d:getetag>
|
||||
<cal:calendar-data>BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:DAVx5/4.5.19-gplay ical4j/4.3.0
|
||||
BEGIN:VEVENT
|
||||
DTSTAMP:20260811T142404Z
|
||||
UID:3a21fd46-26c4-85b5-eee3-6d2d2256f8ef
|
||||
SUMMARY:Briefing
|
||||
DTSTART;TZID=America/New_York:20260818T083000
|
||||
DTEND;TZID=America/New_York:20260818T093000
|
||||
RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TU
|
||||
STATUS:CONFIRMED
|
||||
BEGIN:VALARM
|
||||
TRIGGER:-PT1H
|
||||
ACTION:DISPLAY
|
||||
DESCRIPTION:Design
|
||||
END:VALARM
|
||||
BEGIN:VALARM
|
||||
TRIGGER:-PT12H
|
||||
ACTION:DISPLAY
|
||||
DESCRIPTION:Design
|
||||
END:VALARM
|
||||
END:VEVENT
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:America/New_York
|
||||
BEGIN:STANDARD
|
||||
TZNAME:EST
|
||||
TZOFFSETFROM:-0400
|
||||
TZOFFSETTO:-0500
|
||||
DTSTART:20071104T020000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
|
||||
END:STANDARD
|
||||
BEGIN:DAYLIGHT
|
||||
TZNAME:EDT
|
||||
TZOFFSETFROM:-0500
|
||||
TZOFFSETTO:-0400
|
||||
DTSTART:20070311T020000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
|
||||
END:DAYLIGHT
|
||||
END:VTIMEZONE
|
||||
END:VCALENDAR
|
||||
</cal:calendar-data>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/dav.php/calendars/alex/personal/gone.ics</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:getetag/>
|
||||
<cal:calendar-data/>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>
|
||||
@@ -0,0 +1,474 @@
|
||||
//! 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;
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
//! The CalDAV protocol layer, tested against responses recorded from a real
|
||||
//! server.
|
||||
//!
|
||||
//! No mocks and no hand-invented XML for the main cases: the fixtures are what
|
||||
//! Baikal actually sent, scrubbed of names. That matters because the awkward
|
||||
//! parts of WebDAV are not in the specification's examples — they are the
|
||||
//! second `propstat` carrying a 404 for properties the resource does not have,
|
||||
//! the scheduling collections that look like calendars, and the fact that a
|
||||
//! prefix is not a namespace.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use runway_caldav::xml::{self, CALDAV, DAV, DavResponse};
|
||||
use runway_caldav::{calendars_from, href_for, objects_from, principal_from};
|
||||
use std::path::Path;
|
||||
|
||||
fn fixture(name: &str) -> String {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/fixtures")
|
||||
.join(name);
|
||||
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
fn responses(name: &str) -> Vec<DavResponse> {
|
||||
xml::parse_multistatus(&fixture(name)).unwrap_or_else(|e| panic!("{name}: {e}"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- discovery --
|
||||
|
||||
#[test]
|
||||
fn the_principal_is_read_from_a_real_response() {
|
||||
assert_eq!(
|
||||
principal_from(&responses("propfind-principal.xml")).unwrap(),
|
||||
"/dav.php/principals/alex/",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_the_server_reported_as_missing_are_not_treated_as_present() {
|
||||
// The same response carries a second propstat with 404 listing displayname.
|
||||
// Folding both propstats together would turn "this resource has no display
|
||||
// name" into "this resource has an empty display name".
|
||||
let responses = responses("propfind-principal.xml");
|
||||
|
||||
assert!(responses[0].prop(DAV, "current-user-principal").is_some());
|
||||
assert!(
|
||||
responses[0].prop(DAV, "displayname").is_none(),
|
||||
"a property inside a 404 propstat was treated as found",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_calendar_list_matches_the_server() {
|
||||
let calendars = calendars_from(&responses("propfind-calendars.xml"));
|
||||
|
||||
assert_eq!(
|
||||
calendars.iter().map(|c| c.name()).collect::<Vec<_>>(),
|
||||
vec![
|
||||
"Birthdays",
|
||||
"Household Chores",
|
||||
"Partner Chores",
|
||||
"Personal",
|
||||
"Trips",
|
||||
"Workouts",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheduling_collections_are_not_calendars() {
|
||||
let calendars = calendars_from(&responses("propfind-calendars.xml"));
|
||||
|
||||
assert!(
|
||||
!calendars
|
||||
.iter()
|
||||
.any(|c| c.href.contains("inbox") || c.href.contains("outbox")),
|
||||
"a scheduling inbox is a collection, not something to show in a sidebar",
|
||||
);
|
||||
assert!(
|
||||
!calendars
|
||||
.iter()
|
||||
.any(|c| c.href == "/dav.php/calendars/alex/"),
|
||||
"the home collection itself is not a calendar",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calendar_metadata_is_read_rather_than_invented() {
|
||||
let calendars = calendars_from(&responses("propfind-calendars.xml"));
|
||||
let personal = calendars.iter().find(|c| c.name() == "Personal").unwrap();
|
||||
|
||||
assert_eq!(personal.color.as_deref(), Some("#0CCE6B"));
|
||||
assert!(
|
||||
personal.ctag.is_some(),
|
||||
"a ctag makes a cheap sync check possible"
|
||||
);
|
||||
assert_eq!(
|
||||
personal.supported_components,
|
||||
vec!["VEVENT", "VTODO", "VJOURNAL"],
|
||||
"this collection really does accept all three",
|
||||
);
|
||||
assert!(personal.supports_events());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_calendar_with_no_colour_simply_has_none() {
|
||||
let calendars = calendars_from(&responses("propfind-calendars.xml"));
|
||||
let trips = calendars.iter().find(|c| c.name() == "Trips").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
trips.color, None,
|
||||
"the server returned a 404 propstat for its colour; hashing the path to \
|
||||
invent one is what made the last iteration disagree with every other \
|
||||
client about what colour a calendar was",
|
||||
);
|
||||
assert!(trips.ctag.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_prefixes_do_not_matter() {
|
||||
// The same document with DAV: as the default namespace and CalDAV under a
|
||||
// differently-cased prefix. The previous backend tried six regular
|
||||
// expressions in sequence to cope with this, recompiling each one inside
|
||||
// the loop; a namespace-aware parser makes the question disappear.
|
||||
let normal = calendars_from(&responses("propfind-calendars.xml"));
|
||||
let rewritten = calendars_from(&responses("propfind-calendars-other-prefixes.xml"));
|
||||
|
||||
assert_eq!(normal, rewritten);
|
||||
assert!(!normal.is_empty());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ objects --
|
||||
|
||||
#[test]
|
||||
fn a_calendar_query_yields_objects_with_their_etags() {
|
||||
let objects = objects_from(
|
||||
"/dav.php/calendars/alex/personal/",
|
||||
responses("report-calendar-query.xml"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(objects.len(), 2);
|
||||
assert_eq!(
|
||||
objects[0].href,
|
||||
"/dav.php/calendars/alex/personal/allday.ics"
|
||||
);
|
||||
assert_eq!(
|
||||
objects[0].etag.as_deref(),
|
||||
Some("\"7c9e8a1d2f3b4c5d6e7f8a9b0c1d2e3f\""),
|
||||
"the ETag is what makes a conditional write possible; losing it means \
|
||||
every save silently overwrites whatever arrived in the meantime",
|
||||
);
|
||||
assert_eq!(
|
||||
objects[0].calendar_path,
|
||||
"/dav.php/calendars/alex/personal/"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_response_with_no_calendar_data_is_skipped_not_invented() {
|
||||
let all = responses("report-calendar-query.xml");
|
||||
assert_eq!(all.len(), 3, "the fixture includes a 404 response");
|
||||
|
||||
let objects = objects_from("/dav.php/calendars/alex/personal/", all).unwrap();
|
||||
assert_eq!(
|
||||
objects.len(),
|
||||
2,
|
||||
"a resource the server reported as gone must not become an empty event",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calendar_data_survives_the_trip_through_xml() {
|
||||
// XML normalises CRLF to LF, so the iCalendar arriving here has different
|
||||
// line endings from the bytes on the wire. Unfolding has to cope.
|
||||
let objects = objects_from(
|
||||
"/dav.php/calendars/alex/personal/",
|
||||
responses("report-calendar-query.xml"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let zoned = &objects[1];
|
||||
assert_eq!(zoned.events().len(), 1);
|
||||
let event = &zoned.events()[0];
|
||||
assert_eq!(event.alarms.len(), 2, "both alarms survived");
|
||||
assert_eq!(
|
||||
event.dtstart.tzid().map(runway_core::model::TzId::as_str),
|
||||
Some("America/New_York"),
|
||||
);
|
||||
assert_eq!(
|
||||
zoned.calendar.timezones.len(),
|
||||
1,
|
||||
"the VTIMEZONE came through with it",
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------- xml --
|
||||
|
||||
#[test]
|
||||
fn an_entity_reference_is_resolved_with_its_surrounding_spaces() {
|
||||
let doc = r#"<d:multistatus xmlns:d="DAV:"><d:response>
|
||||
<d:href>/c/</d:href>
|
||||
<d:propstat><d:prop><d:displayname>Bed & Breakfast</d:displayname></d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status></d:propstat>
|
||||
</d:response></d:multistatus>"#;
|
||||
|
||||
let parsed = xml::parse_multistatus(doc).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed[0].prop_text(DAV, "displayname"),
|
||||
Some("Bed & Breakfast"),
|
||||
"trimming each text fragment instead of the whole value would give \
|
||||
\"Bed&Breakfast\"",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_numeric_character_reference_is_resolved() {
|
||||
let doc = r#"<d:multistatus xmlns:d="DAV:"><d:response>
|
||||
<d:href>/c/</d:href>
|
||||
<d:propstat><d:prop><d:displayname>café</d:displayname></d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status></d:propstat>
|
||||
</d:response></d:multistatus>"#;
|
||||
|
||||
let parsed = xml::parse_multistatus(doc).unwrap();
|
||||
assert_eq!(parsed[0].prop_text(DAV, "displayname"), Some("café"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_property_values_are_navigable() {
|
||||
let doc = r#"<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:response><d:href>/c/</d:href><d:propstat><d:prop>
|
||||
<c:supported-calendar-component-set>
|
||||
<c:comp name="VEVENT"/><c:comp name="VTODO"/>
|
||||
</c:supported-calendar-component-set>
|
||||
</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>"#;
|
||||
|
||||
let parsed = xml::parse_multistatus(doc).unwrap();
|
||||
let names: Vec<&str> = parsed[0]
|
||||
.prop(CALDAV, "supported-calendar-component-set")
|
||||
.unwrap()
|
||||
.children(CALDAV, "comp")
|
||||
.filter_map(|c| c.attribute("name"))
|
||||
.collect();
|
||||
|
||||
assert_eq!(names, vec!["VEVENT", "VTODO"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_that_is_not_a_multistatus_is_rejected() {
|
||||
let html = "<html><body>502 Bad Gateway</body></html>";
|
||||
|
||||
assert!(
|
||||
xml::parse_multistatus(html).is_err(),
|
||||
"a proxy error page must not parse as an empty calendar list",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_lines_are_read_for_their_code() {
|
||||
assert_eq!(xml::status_code("HTTP/1.1 200 OK"), Some(200));
|
||||
assert_eq!(xml::status_code("HTTP/1.1 404 Not Found"), Some(404));
|
||||
assert_eq!(xml::status_code("nonsense"), None);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- href_for --
|
||||
|
||||
#[test]
|
||||
fn an_object_href_is_one_resource_per_uid() {
|
||||
assert_eq!(
|
||||
href_for("/dav.php/calendars/alex/personal/", "abc-123"),
|
||||
"/dav.php/calendars/alex/personal/abc-123.ics",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_uid_that_is_not_url_safe_is_encoded() {
|
||||
// Real UIDs from Google and Exchange contain characters a path segment
|
||||
// cannot carry unescaped.
|
||||
assert_eq!(
|
||||
href_for("/c/", "26u614553d18@google.com"),
|
||||
"/c/26u614553d18%40google.com.ics",
|
||||
);
|
||||
assert_eq!(href_for("/c/", "a/b c"), "/c/a%2Fb%20c.ics");
|
||||
}
|
||||
@@ -9,6 +9,8 @@ description = "Smoke tool for exercising a real CalDAV server from the terminal.
|
||||
runway-core = { workspace = true, features = ["ical", "recurrence"] }
|
||||
runway-caldav = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
chrono-tz = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
@@ -1,3 +1,233 @@
|
||||
fn main() {
|
||||
println!("runway-cli: not yet implemented");
|
||||
//! A terminal tool for pointing Runway's own stack at a real CalDAV server.
|
||||
//!
|
||||
//! Deliberately thin: it holds no logic of its own, so what it prints is what
|
||||
//! `runway-caldav` fetched and what `runway-core` expanded. When a calendar
|
||||
//! looks wrong in the browser, this is how to find out whether the problem is
|
||||
//! in the view or underneath it — a question the previous iteration could only
|
||||
//! answer by adding `println!`s to the backend and redeploying.
|
||||
|
||||
use chrono::{DateTime, NaiveDate, TimeZone, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use clap::{Parser, Subcommand};
|
||||
use runway_caldav::{CalDavClient, Credentials};
|
||||
use runway_core::model::Occurrence;
|
||||
use runway_core::recurrence::{Window, Zones, expand, unresolved_zones};
|
||||
use std::process::ExitCode;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "runway",
|
||||
about = "Talk to a CalDAV server the way Runway does."
|
||||
)]
|
||||
struct Cli {
|
||||
/// The DAV root, e.g. https://example.com/dav.php/
|
||||
#[arg(long, env = "RUNWAY_CALDAV_URL")]
|
||||
server: String,
|
||||
|
||||
#[arg(long, env = "RUNWAY_CALDAV_USER")]
|
||||
user: String,
|
||||
|
||||
/// Read from the environment rather than the command line, so it does not
|
||||
/// end up in shell history or in the process list.
|
||||
#[arg(long, env = "RUNWAY_CALDAV_PASSWORD", hide_env_values = true)]
|
||||
password: String,
|
||||
|
||||
/// The zone to show times in, and to read floating times as.
|
||||
#[arg(long, default_value = "America/Denver")]
|
||||
timezone: Tz,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// List the calendars the server offers.
|
||||
Calendars,
|
||||
|
||||
/// List occurrences in a date range, expanded.
|
||||
ListEvents {
|
||||
/// Inclusive start date, YYYY-MM-DD.
|
||||
#[arg(long)]
|
||||
from: NaiveDate,
|
||||
/// Exclusive end date, YYYY-MM-DD.
|
||||
#[arg(long)]
|
||||
to: NaiveDate,
|
||||
/// Only this calendar, matched on href or display name. Repeatable.
|
||||
#[arg(long = "calendar")]
|
||||
calendars: Vec<String>,
|
||||
},
|
||||
|
||||
/// Print the raw iCalendar for one resource, as the server stores it.
|
||||
Show {
|
||||
/// The href of the resource.
|
||||
href: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "runway_cli=info,runway_caldav=info".into()),
|
||||
)
|
||||
.with_target(false)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
match run(cli).await {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("error: {error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let client = CalDavClient::new(&cli.server, Credentials::new(&cli.user, cli.password))?;
|
||||
|
||||
match cli.command {
|
||||
Command::Calendars => list_calendars(&client).await,
|
||||
Command::ListEvents {
|
||||
from,
|
||||
to,
|
||||
calendars,
|
||||
} => list_events(&client, from, to, &calendars, cli.timezone).await,
|
||||
Command::Show { href } => show(&client, &href).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_calendars(client: &CalDavClient) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let calendars = client.discover().await?;
|
||||
if calendars.is_empty() {
|
||||
println!("no calendars on this server");
|
||||
return Ok(());
|
||||
}
|
||||
for calendar in &calendars {
|
||||
let colour = calendar.color.as_deref().unwrap_or("—");
|
||||
println!("{:<28} {:<9} {}", calendar.name(), colour, calendar.href);
|
||||
if !calendar.supports_events() {
|
||||
println!("{:<28} (holds no events)", "");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_events(
|
||||
client: &CalDavClient,
|
||||
from: NaiveDate,
|
||||
to: NaiveDate,
|
||||
wanted: &[String],
|
||||
timezone: Tz,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let zones = Zones::new(timezone);
|
||||
let window = Window::new(midnight(timezone, from), midnight(timezone, to));
|
||||
|
||||
let calendars: Vec<_> = client
|
||||
.discover()
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|c| c.supports_events())
|
||||
.filter(|c| wanted.is_empty() || wanted.iter().any(|w| matches(c, w)))
|
||||
.collect();
|
||||
|
||||
if calendars.is_empty() {
|
||||
return Err("no calendar matched".into());
|
||||
}
|
||||
|
||||
let mut rows: Vec<(String, Occurrence)> = Vec::new();
|
||||
for calendar in &calendars {
|
||||
let objects = client
|
||||
.events_in_range(&calendar.href, window.from, window.to)
|
||||
.await?;
|
||||
|
||||
for object in &objects {
|
||||
// Reported, not swallowed: a zone we could not identify means the
|
||||
// times below may be wrong by hours.
|
||||
for zone in unresolved_zones(&object.calendar, zones) {
|
||||
eprintln!("warning: {} names an unknown time zone {zone}", object.href);
|
||||
}
|
||||
for occurrence in expand(&object.calendar, window, zones)? {
|
||||
rows.push((calendar.name().to_owned(), occurrence));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows.sort_by_key(|(_, occurrence)| occurrence.start_utc);
|
||||
|
||||
println!(
|
||||
"{} occurrence(s) between {from} and {to}, shown in {timezone}",
|
||||
rows.len(),
|
||||
);
|
||||
for (calendar, occurrence) in &rows {
|
||||
println!(
|
||||
"{:<10} {:<13} {:<16} {}",
|
||||
occurrence.start.date(),
|
||||
span(occurrence, timezone),
|
||||
truncate(calendar, 16),
|
||||
label(occurrence),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn show(client: &CalDavClient, href: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let calendar_path = href.rsplit_once('/').map(|(dir, _)| dir).unwrap_or("");
|
||||
let object = client
|
||||
.get_object(&format!("{calendar_path}/"), href)
|
||||
.await?;
|
||||
if let Some(etag) = &object.etag {
|
||||
eprintln!("etag: {etag}");
|
||||
}
|
||||
print!("{}", runway_core::ical::write(&object.calendar));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn matches(calendar: &runway_caldav::Calendar, wanted: &str) -> bool {
|
||||
calendar.href.contains(wanted) || calendar.name().eq_ignore_ascii_case(wanted)
|
||||
}
|
||||
|
||||
fn midnight(tz: Tz, date: NaiveDate) -> DateTime<Utc> {
|
||||
let local = date.and_hms_opt(0, 0, 0).unwrap_or_default();
|
||||
runway_core::recurrence::instant_in(tz, local)
|
||||
}
|
||||
|
||||
/// The time column: a clock range, or the fact that there is not one.
|
||||
fn span(occurrence: &Occurrence, tz: Tz) -> String {
|
||||
if occurrence.is_all_day() {
|
||||
return "all-day".to_owned();
|
||||
}
|
||||
let clock = |instant: DateTime<Utc>| {
|
||||
tz.from_utc_datetime(&instant.naive_utc())
|
||||
.format("%H:%M")
|
||||
.to_string()
|
||||
};
|
||||
format!(
|
||||
"{}-{}",
|
||||
clock(occurrence.start_utc),
|
||||
clock(occurrence.end_utc)
|
||||
)
|
||||
}
|
||||
|
||||
fn label(occurrence: &Occurrence) -> String {
|
||||
let title = occurrence.title().unwrap_or("(untitled)");
|
||||
if occurrence.is_override {
|
||||
format!("{title} [moved]")
|
||||
} else if occurrence.recurrence_id.is_some() {
|
||||
format!("{title} [recurring]")
|
||||
} else {
|
||||
title.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(text: &str, width: usize) -> String {
|
||||
if text.chars().count() <= width {
|
||||
return text.to_owned();
|
||||
}
|
||||
text.chars()
|
||||
.take(width.saturating_sub(1))
|
||||
.collect::<String>()
|
||||
+ "…"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user