1891 lines
59 KiB
Rust
1891 lines
59 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
|
*
|
|
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
|
*/
|
|
|
|
use std::io::Cursor;
|
|
|
|
use vandelay::dav::client::DavClient;
|
|
use vandelay::dav::discover::{DavKind, discover};
|
|
use vandelay::dav::parse::{parse_multistatus, strip_ascii_control_chars};
|
|
use vandelay::dav::xml;
|
|
use vandelay::jmap::error::JmapError;
|
|
use vandelay::jmap::http::{Auth, RetryPolicy};
|
|
|
|
fn client(retries: u32) -> DavClient {
|
|
DavClient::new(
|
|
Auth::Basic {
|
|
user: "u".into(),
|
|
password: "p".into(),
|
|
},
|
|
RetryPolicy::new(retries),
|
|
false,
|
|
)
|
|
}
|
|
|
|
const MULTISTATUS_HEADERS: &[(&str, &str)] = &[("content-type", "application/xml; charset=utf-8")];
|
|
|
|
fn multistatus_response(
|
|
server: &mut mockito::Server,
|
|
method: &str,
|
|
path: &str,
|
|
body: &str,
|
|
) -> mockito::Mock {
|
|
let mut m = server.mock(method, path).with_status(207);
|
|
for (k, v) in MULTISTATUS_HEADERS {
|
|
m = m.with_header(*k, v);
|
|
}
|
|
m.with_body(body).create()
|
|
}
|
|
|
|
#[test]
|
|
fn discovery_uses_url_as_homeset_when_collection_present() {
|
|
let mut server = mockito::Server::new();
|
|
let url = server.url();
|
|
let body = format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>{url}/dav/cal/u/default/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
|
|
<d:displayname>Default</d:displayname>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
);
|
|
let _m = multistatus_response(&mut server, "PROPFIND", "/dav/cal/", &body);
|
|
let c = client(0);
|
|
let url_with_path = format!("{url}/dav/cal/");
|
|
let disc = discover(&c, DavKind::Caldav, &url_with_path).expect("discover");
|
|
assert_eq!(disc.collections.len(), 1);
|
|
assert!(disc.collections[0].props.is_calendar);
|
|
}
|
|
|
|
#[test]
|
|
fn discovery_resolves_per_user_principal_even_when_url_lists_collections() {
|
|
let mut server = mockito::Server::new();
|
|
let url = server.url();
|
|
|
|
let collections_body = format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>{url}/dav/cal/[email protected]/default/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
|
|
<d:displayname>Default</d:displayname>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
);
|
|
let _listing = server
|
|
.mock("PROPFIND", "/dav/cal/")
|
|
.match_header("depth", "1")
|
|
.with_status(207)
|
|
.with_header("content-type", "application/xml; charset=utf-8")
|
|
.with_body(&collections_body)
|
|
.create();
|
|
|
|
let principal_body = format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:">
|
|
<d:response>
|
|
<d:href>{url}/dav/cal/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:current-user-principal><d:href>{url}/dav/principals/[email protected]/</d:href></d:current-user-principal>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
);
|
|
let _principal = server
|
|
.mock("PROPFIND", "/dav/cal/")
|
|
.match_header("depth", "0")
|
|
.with_status(207)
|
|
.with_header("content-type", "application/xml; charset=utf-8")
|
|
.with_body(&principal_body)
|
|
.create();
|
|
|
|
let c = client(0);
|
|
let disc = discover(&c, DavKind::Caldav, &format!("{url}/dav/cal/")).expect("discover");
|
|
assert_eq!(disc.collections.len(), 1);
|
|
assert_eq!(
|
|
disc.principal_url.as_deref(),
|
|
Some(format!("{url}/dav/principals/[email protected]/").as_str()),
|
|
"account identity must be the per-user principal, not the shared base DAV root"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn discovery_falls_through_principal_to_home_set() {
|
|
let mut server = mockito::Server::new();
|
|
let url = server.url();
|
|
|
|
let empty_collection_body = r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:"/>"#;
|
|
let _m1 = multistatus_response(&mut server, "PROPFIND", "/", empty_collection_body);
|
|
|
|
let principal_body = format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:">
|
|
<d:response>
|
|
<d:href>{url}/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:current-user-principal><d:href>{url}/principals/alice/</d:href></d:current-user-principal>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
);
|
|
let _m2 = multistatus_response(&mut server, "PROPFIND", "/", &principal_body);
|
|
|
|
let homeset_body = format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>{url}/principals/alice/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<c:calendar-home-set><d:href>{url}/dav/cal/alice/</d:href></c:calendar-home-set>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
);
|
|
let _m3 = multistatus_response(&mut server, "PROPFIND", "/principals/alice/", &homeset_body);
|
|
|
|
let collections_body = format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>{url}/dav/cal/alice/default/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
|
|
<d:displayname>Default</d:displayname>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
);
|
|
let _m4 = multistatus_response(
|
|
&mut server,
|
|
"PROPFIND",
|
|
"/dav/cal/alice/",
|
|
&collections_body,
|
|
);
|
|
|
|
let c = client(0);
|
|
let disc = discover(&c, DavKind::Caldav, &url).expect("discover");
|
|
assert_eq!(disc.collections.len(), 1);
|
|
assert!(disc.principal_url.is_some());
|
|
assert!(disc.home_set_url.ends_with("/dav/cal/alice/"));
|
|
}
|
|
|
|
#[test]
|
|
fn discovery_returns_not_found_when_no_collections() {
|
|
let mut server = mockito::Server::new();
|
|
let empty = r#"<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"/>"#;
|
|
let _m = server
|
|
.mock("PROPFIND", mockito::Matcher::Any)
|
|
.with_status(207)
|
|
.with_header("content-type", "application/xml")
|
|
.with_body(empty)
|
|
.expect_at_least(1)
|
|
.create();
|
|
|
|
let c = client(0);
|
|
let err = discover(&c, DavKind::Caldav, &server.url()).unwrap_err();
|
|
assert!(matches!(
|
|
err,
|
|
vandelay::dav::discover::DiscoveryError::NotFound { .. }
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_multistatus_with_default_namespace_no_prefix() {
|
|
let body = r#"<?xml version="1.0"?>
|
|
<multistatus xmlns="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<response>
|
|
<href>/cal/a/</href>
|
|
<propstat>
|
|
<prop>
|
|
<resourcetype><collection/><c:calendar/></resourcetype>
|
|
<displayname>A</displayname>
|
|
</prop>
|
|
<status>HTTP/1.1 200 OK</status>
|
|
</propstat>
|
|
</response>
|
|
</multistatus>"#;
|
|
let r = parse_multistatus(Cursor::new(body), "https://x/").expect("parse");
|
|
assert_eq!(r.len(), 1);
|
|
assert!(r[0].props.is_calendar);
|
|
assert_eq!(r[0].props.displayname.as_deref(), Some("A"));
|
|
}
|
|
|
|
#[test]
|
|
fn ascii_control_chars_are_stripped_before_parse() {
|
|
let dirty: Vec<u8> = b"<?xml version=\"1.0\"?>\x02<d:multistatus xmlns:d=\"DAV:\"/>".to_vec();
|
|
let cleaned = strip_ascii_control_chars(&dirty);
|
|
let r = parse_multistatus(Cursor::new(cleaned.as_ref()), "https://x/").expect("parse");
|
|
assert!(r.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn rate_limited_propfind_retries_via_shared_schedule() {
|
|
let mut server = mockito::Server::new();
|
|
let _m1 = server
|
|
.mock("PROPFIND", "/dav/")
|
|
.with_status(429)
|
|
.with_header("retry-after", "1")
|
|
.with_body("rate-limited")
|
|
.create();
|
|
let body = r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>/dav/cal/u/d/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#;
|
|
let _m2 = multistatus_response(&mut server, "PROPFIND", "/dav/", body);
|
|
|
|
let c = client(3);
|
|
let start = std::time::Instant::now();
|
|
let result = c
|
|
.propfind(&format!("{}/dav/", server.url()), 1, "<x/>")
|
|
.expect("propfind");
|
|
let elapsed = start.elapsed();
|
|
assert_eq!(result.status, 207);
|
|
assert!(
|
|
elapsed.as_millis() >= 900,
|
|
"expected Retry-After 1s honoured, elapsed={elapsed:?}"
|
|
);
|
|
assert!(c.retries_observed() >= 1);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_failure_401_is_fatal() {
|
|
let mut server = mockito::Server::new();
|
|
let _m = server
|
|
.mock("PROPFIND", "/")
|
|
.with_status(401)
|
|
.with_body("Unauthorized")
|
|
.create();
|
|
|
|
let c = client(0);
|
|
let err = c
|
|
.propfind(&format!("{}/", server.url()), 0, "<x/>")
|
|
.unwrap_err();
|
|
assert!(matches!(err, JmapError::Auth(_)), "got {err:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn http_404_classified_as_vanished_returns_response() {
|
|
let mut server = mockito::Server::new();
|
|
let _m = server
|
|
.mock("GET", "/dav/cal/u/d/missing.ics")
|
|
.with_status(404)
|
|
.create();
|
|
|
|
let c = client(0);
|
|
let result = c
|
|
.get(&format!("{}/dav/cal/u/d/missing.ics", server.url()))
|
|
.expect("get");
|
|
assert_eq!(result.status, 404);
|
|
}
|
|
|
|
#[test]
|
|
fn http_503_retries_then_succeeds() {
|
|
let mut server = mockito::Server::new();
|
|
let _m1 = server.mock("PROPFIND", "/").with_status(503).create();
|
|
let _m2 = server
|
|
.mock("PROPFIND", "/")
|
|
.with_status(207)
|
|
.with_header("content-type", "application/xml")
|
|
.with_body(r#"<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"/>"#)
|
|
.create();
|
|
|
|
let c = client(3);
|
|
let result = c
|
|
.propfind(&format!("{}/", server.url()), 0, "<x/>")
|
|
.expect("propfind");
|
|
assert_eq!(result.status, 207);
|
|
assert!(c.retries_observed() >= 1);
|
|
}
|
|
|
|
#[test]
|
|
fn xml_entities_decoded_in_displayname_and_calendar_data() {
|
|
let body = r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>/cal/1.ics</d:href>
|
|
<d:propstat><d:prop>
|
|
<d:displayname>Sales & Marketing <Team></d:displayname>
|
|
<c:calendar-data>BEGIN:VEVENT
|
|
SUMMARY:Q&A & review
|
|
END:VEVENT</c:calendar-data>
|
|
</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#;
|
|
let r = parse_multistatus(Cursor::new(body), "https://x/").expect("parse");
|
|
assert_eq!(r.len(), 1);
|
|
assert_eq!(
|
|
r[0].props.displayname.as_deref(),
|
|
Some("Sales & Marketing <Team>")
|
|
);
|
|
let cal = r[0].props.calendar_data.as_deref().unwrap();
|
|
assert!(
|
|
cal.contains("SUMMARY:Q&A & review"),
|
|
"calendar-data entities must be decoded: {cal}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_response_hrefs_collapsed_on_parse() {
|
|
let body = r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:">
|
|
<d:response>
|
|
<d:href>/x</d:href>
|
|
<d:propstat><d:prop><d:getetag>"a"</d:getetag></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>/x</d:href>
|
|
<d:propstat><d:prop><d:getetag>"b"</d:getetag></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#;
|
|
let r = parse_multistatus(Cursor::new(body), "https://x/").expect("parse");
|
|
assert_eq!(r.len(), 1);
|
|
assert_eq!(r[0].props.etag.as_deref(), Some("\"a\""));
|
|
}
|
|
|
|
#[test]
|
|
fn xml_request_bodies_match_expected_shape() {
|
|
let cup = xml::propfind_current_user_principal();
|
|
assert!(cup.contains("<d:current-user-principal/>"));
|
|
let chs = xml::propfind_calendar_home_set();
|
|
assert!(chs.contains("<c:calendar-home-set/>"));
|
|
let ahs = xml::propfind_addressbook_home_set();
|
|
assert!(ahs.contains("<c:addressbook-home-set/>"));
|
|
let listing = xml::propfind_webdav_listing();
|
|
assert!(listing.contains("<d:getcontentlength/>"));
|
|
}
|
|
|
|
#[test]
|
|
fn report_multiget_against_mock_returns_calendar_data() {
|
|
let mut server = mockito::Server::new();
|
|
let body = r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>/dav/cal/u/d/a.ics</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:getetag>"v1"</d:getetag>
|
|
<c:calendar-data>BEGIN:VCALENDAR
|
|
VERSION:2.0
|
|
PRODID:-//Test//EN
|
|
BEGIN:VEVENT
|
|
UID:[email protected]
|
|
DTSTAMP:20260101T000000Z
|
|
DTSTART:20260101T090000Z
|
|
DTEND:20260101T100000Z
|
|
SUMMARY:Hi
|
|
END:VEVENT
|
|
END:VCALENDAR
|
|
</c:calendar-data>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#;
|
|
let _m = multistatus_response(&mut server, "REPORT", "/dav/cal/u/d/", body);
|
|
let c = client(0);
|
|
let result = c
|
|
.report(&format!("{}/dav/cal/u/d/", server.url()), 1, "<x/>")
|
|
.expect("report");
|
|
assert_eq!(result.status, 207);
|
|
let parsed = parse_multistatus(Cursor::new(&result.body), "https://x/").expect("parse");
|
|
assert_eq!(parsed.len(), 1);
|
|
assert!(parsed[0].props.calendar_data.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn webdav_listing_returns_collection_and_file() {
|
|
let mut server = mockito::Server::new();
|
|
let url = server.url();
|
|
let body = format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:">
|
|
<d:response>
|
|
<d:href>{url}/files/</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:response>
|
|
<d:response>
|
|
<d:href>{url}/files/sub/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:resourcetype><d:collection/></d:resourcetype>
|
|
<d:displayname>sub</d:displayname>
|
|
<d:creationdate>2026-01-01T00:00:00Z</d:creationdate>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>{url}/files/hello.txt</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:resourcetype/>
|
|
<d:getcontenttype>text/plain</d:getcontenttype>
|
|
<d:getetag>"f1"</d:getetag>
|
|
<d:getcontentlength>5</d:getcontentlength>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
);
|
|
let _m = multistatus_response(&mut server, "PROPFIND", "/files/", &body);
|
|
let c = client(0);
|
|
let result = c
|
|
.propfind(&format!("{url}/files/"), 1, "<x/>")
|
|
.expect("propfind");
|
|
let parsed =
|
|
parse_multistatus(Cursor::new(&result.body), &format!("{url}/files/")).expect("parse");
|
|
assert!(
|
|
parsed
|
|
.iter()
|
|
.any(|r| !r.props.is_collection && r.href.as_str().ends_with("hello.txt")),
|
|
"expected a non-collection file in listing"
|
|
);
|
|
assert!(
|
|
parsed
|
|
.iter()
|
|
.any(|r| r.props.is_collection && r.href.as_str().ends_with("/sub/")),
|
|
"expected a sub-collection in listing"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn google_usage_limits_403_classified_retryable() {
|
|
use vandelay::dav::retry::{DavOutcome, classify};
|
|
let body = br#"{"error":{"errors":[{"domain":"usageLimits"}]}}"#;
|
|
assert_eq!(classify(403, body), DavOutcome::Retryable);
|
|
}
|
|
|
|
#[test]
|
|
fn non_quota_403_is_auth() {
|
|
use vandelay::dav::retry::{DavOutcome, classify};
|
|
assert_eq!(classify(403, b"forbidden"), DavOutcome::Auth);
|
|
}
|
|
|
|
#[test]
|
|
fn server_extra_unsolicited_href_in_multiget_is_logged_not_crashed() {
|
|
let body = r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>/dav/cal/u/d/requested.ics</d:href>
|
|
<d:propstat>
|
|
<d:prop><d:getetag>"v"</d:getetag><c:calendar-data>X</c:calendar-data></d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>/dav/cal/u/d/unsolicited.ics</d:href>
|
|
<d:propstat>
|
|
<d:prop><d:getetag>"v"</d:getetag><c:calendar-data>Y</c:calendar-data></d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#;
|
|
let r = parse_multistatus(Cursor::new(body), "https://x/").expect("parse");
|
|
assert_eq!(r.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn server_404_on_individual_item_in_multiget_recorded_as_vanished() {
|
|
let body = r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:">
|
|
<d:response>
|
|
<d:href>/dav/cal/u/d/gone.ics</d:href>
|
|
<d:status>HTTP/1.1 404 Not Found</d:status>
|
|
</d:response>
|
|
</d:multistatus>"#;
|
|
let r = parse_multistatus(Cursor::new(body), "https://x/").expect("parse");
|
|
assert_eq!(r.len(), 1);
|
|
assert_eq!(r[0].status, Some(404));
|
|
}
|
|
|
|
#[test]
|
|
fn discovery_via_well_known_redirect_307() {
|
|
let mut server = mockito::Server::new();
|
|
let url = server.url();
|
|
|
|
let empty = r#"<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"/>"#;
|
|
let _root = multistatus_response(&mut server, "PROPFIND", "/", empty);
|
|
|
|
let principal_with_homeset = format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>{url}/dav/principals/u/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:current-user-principal><d:href>{url}/dav/principals/u/</d:href></d:current-user-principal>
|
|
<c:calendar-home-set><d:href>{url}/dav/cal/u/</d:href></c:calendar-home-set>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
);
|
|
let collections_body = format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>{url}/dav/cal/u/default/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
|
|
<d:displayname>Default</d:displayname>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
);
|
|
|
|
let _well_known = multistatus_response(
|
|
&mut server,
|
|
"PROPFIND",
|
|
"/.well-known/caldav",
|
|
&principal_with_homeset,
|
|
);
|
|
let _principal = multistatus_response(
|
|
&mut server,
|
|
"PROPFIND",
|
|
"/dav/principals/u/",
|
|
&principal_with_homeset,
|
|
);
|
|
let _collections =
|
|
multistatus_response(&mut server, "PROPFIND", "/dav/cal/u/", &collections_body);
|
|
|
|
let c = client(0);
|
|
let disc = discover(&c, DavKind::Caldav, &url).expect("discover");
|
|
assert_eq!(disc.collections.len(), 1);
|
|
assert!(disc.home_set_url.ends_with("/dav/cal/u/"));
|
|
}
|
|
|
|
#[test]
|
|
fn discovery_survives_501_on_the_user_supplied_url() {
|
|
let mut server = mockito::Server::new();
|
|
let url = server.url();
|
|
|
|
let _root = server
|
|
.mock("PROPFIND", "/")
|
|
.with_status(501)
|
|
.with_body("")
|
|
.expect_at_least(1)
|
|
.create();
|
|
|
|
let principal_with_homeset = format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>{url}/dav/principals/u/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:current-user-principal><d:href>{url}/dav/principals/u/</d:href></d:current-user-principal>
|
|
<c:calendar-home-set><d:href>{url}/dav/cal/u/</d:href></c:calendar-home-set>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
);
|
|
let collections_body = format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>{url}/dav/cal/u/default/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
|
|
<d:displayname>Default</d:displayname>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
);
|
|
let _well_known = multistatus_response(
|
|
&mut server,
|
|
"PROPFIND",
|
|
"/.well-known/caldav",
|
|
&principal_with_homeset,
|
|
);
|
|
let _principal = multistatus_response(
|
|
&mut server,
|
|
"PROPFIND",
|
|
"/dav/principals/u/",
|
|
&principal_with_homeset,
|
|
);
|
|
let _collections =
|
|
multistatus_response(&mut server, "PROPFIND", "/dav/cal/u/", &collections_body);
|
|
|
|
let c = client(0);
|
|
let disc = discover(&c, DavKind::Caldav, &url).expect("discovery must not abort on 501");
|
|
assert_eq!(disc.collections.len(), 1);
|
|
assert!(disc.home_set_url.ends_with("/dav/cal/u/"));
|
|
}
|
|
|
|
#[test]
|
|
fn discovery_when_server_omits_current_user_principal() {
|
|
let mut server = mockito::Server::new();
|
|
let url = server.url();
|
|
|
|
let empty = r#"<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"/>"#;
|
|
let _step1 = multistatus_response(&mut server, "PROPFIND", "/", empty);
|
|
|
|
let no_principal_with_homeset = format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>{url}/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<c:calendar-home-set><d:href>{url}/dav/cal/u/</d:href></c:calendar-home-set>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
);
|
|
let collections_body = format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>{url}/dav/cal/u/default/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
);
|
|
let _step2 = multistatus_response(&mut server, "PROPFIND", "/", &no_principal_with_homeset);
|
|
let _collections =
|
|
multistatus_response(&mut server, "PROPFIND", "/dav/cal/u/", &collections_body);
|
|
|
|
let c = client(0);
|
|
let disc = discover(&c, DavKind::Caldav, &url).expect("discover");
|
|
assert_eq!(disc.collections.len(), 1);
|
|
assert!(
|
|
disc.home_set_url.ends_with("/dav/cal/u/"),
|
|
"home-set discovered even without principal href"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn propfind_depth1_self_inclusion_is_filtered_during_enumeration() {
|
|
let body = r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>/dav/cal/u/d/</d:href>
|
|
<d:propstat>
|
|
<d:prop><d:resourcetype><d:collection/><c:calendar/></d:resourcetype></d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>/dav/cal/u/d/event1.ics</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:resourcetype/>
|
|
<d:getetag>"v1"</d:getetag>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#;
|
|
let parsed = parse_multistatus(Cursor::new(body), "https://x/dav/cal/u/d/").expect("parse");
|
|
let non_collection: Vec<_> = parsed.iter().filter(|r| !r.props.is_collection).collect();
|
|
let collection: Vec<_> = parsed.iter().filter(|r| r.props.is_collection).collect();
|
|
assert_eq!(non_collection.len(), 1);
|
|
assert_eq!(collection.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn item_missing_etag_records_empty_etag() {
|
|
let body = r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:">
|
|
<d:response>
|
|
<d:href>/dav/cal/u/d/event.ics</d:href>
|
|
<d:propstat>
|
|
<d:prop><d:resourcetype/></d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#;
|
|
let r = parse_multistatus(Cursor::new(body), "https://x/").expect("parse");
|
|
assert!(r[0].props.etag.is_none(), "missing etag should be None");
|
|
}
|
|
|
|
#[test]
|
|
fn etag_mismatch_between_enumerate_and_multiget_uses_new_etag() {
|
|
let multiget_body = r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>/dav/cal/u/d/event.ics</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:getetag>"v2"</d:getetag>
|
|
<c:calendar-data>BEGIN:VCALENDAR
|
|
END:VCALENDAR</c:calendar-data>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#;
|
|
let r = parse_multistatus(Cursor::new(multiget_body), "https://x/").expect("parse");
|
|
assert_eq!(r[0].props.etag.as_deref(), Some("\"v2\""));
|
|
}
|
|
|
|
#[test]
|
|
fn webdav_cycle_breaks_on_lex_smallest() {
|
|
let body = r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:">
|
|
<d:response>
|
|
<d:href>/files/a/</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:response>
|
|
<d:response>
|
|
<d:href>/files/a/b/</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:response>
|
|
</d:multistatus>"#;
|
|
let parsed = parse_multistatus(Cursor::new(body), "https://x/").expect("parse");
|
|
let mut hrefs: Vec<&str> = parsed.iter().map(|r| r.href.as_str()).collect();
|
|
hrefs.sort();
|
|
assert_eq!(hrefs[0], "/files/a/");
|
|
}
|
|
|
|
#[test]
|
|
fn multiget_405_method_not_allowed_triggers_get_fallback() {
|
|
use vandelay::dav::retry::{DavOutcome, classify};
|
|
assert_eq!(classify(405, b""), DavOutcome::Fatal);
|
|
}
|
|
|
|
#[test]
|
|
fn parser_accepts_response_with_propstat_404_on_one_property() {
|
|
let body = r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:">
|
|
<d:response>
|
|
<d:href>/dav/cal/u/d/e.ics</d:href>
|
|
<d:propstat>
|
|
<d:prop><d:getetag>"v"</d:getetag></d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
<d:propstat>
|
|
<d:prop><d:custom-property/></d:prop>
|
|
<d:status>HTTP/1.1 404 Not Found</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#;
|
|
let r = parse_multistatus(Cursor::new(body), "https://x/").expect("parse");
|
|
assert_eq!(r[0].props.etag.as_deref(), Some("\"v\""));
|
|
assert_eq!(r[0].propstat_errors, vec![404]);
|
|
}
|
|
|
|
#[test]
|
|
fn streaming_multistatus_returns_responses_via_propfind_responses() {
|
|
let mut server = mockito::Server::new();
|
|
let url = server.url();
|
|
let body = format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>{url}/dav/cal/u/d/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
|
|
<d:displayname>Default</d:displayname>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
);
|
|
let _m = multistatus_response(&mut server, "PROPFIND", "/dav/cal/u/", &body);
|
|
let c = client(0);
|
|
let ms = c
|
|
.propfind_responses(
|
|
&format!("{url}/dav/cal/u/"),
|
|
1,
|
|
"<x/>",
|
|
&format!("{url}/dav/cal/u/"),
|
|
)
|
|
.expect("propfind_responses");
|
|
assert_eq!(ms.status, 207);
|
|
assert_eq!(ms.responses.len(), 1);
|
|
assert!(ms.responses[0].props.is_calendar);
|
|
}
|
|
|
|
#[test]
|
|
fn streaming_propfind_propagates_401_as_auth_error() {
|
|
let mut server = mockito::Server::new();
|
|
let _m = server.mock("PROPFIND", "/").with_status(401).create();
|
|
let c = client(0);
|
|
let err = c
|
|
.propfind_responses(&format!("{}/", server.url()), 0, "<x/>", &server.url())
|
|
.unwrap_err();
|
|
assert!(matches!(err, JmapError::Auth(_)), "got {err:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn streaming_propfind_404_returns_empty_responses() {
|
|
let mut server = mockito::Server::new();
|
|
let _m = server
|
|
.mock("PROPFIND", "/missing")
|
|
.with_status(404)
|
|
.create();
|
|
let c = client(0);
|
|
let ms = c
|
|
.propfind_responses(
|
|
&format!("{}/missing", server.url()),
|
|
0,
|
|
"<x/>",
|
|
&server.url(),
|
|
)
|
|
.expect("propfind_responses");
|
|
assert_eq!(ms.status, 404);
|
|
assert!(ms.responses.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn source_change_protection_rejects_different_account_id() {
|
|
use rusqlite::Connection;
|
|
use vandelay::db::{init, sources};
|
|
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
init::apply_schema(&conn).unwrap();
|
|
let first = sources::SourceKey {
|
|
kind: "caldav".to_owned(),
|
|
session_url: "https://x".to_owned(),
|
|
account_id: "alice".to_owned(),
|
|
};
|
|
sources::upsert_source(&conn, &first, Some("alice"), "alice").unwrap();
|
|
|
|
let conflict = sources::conflicting_source(&conn, "caldav", "https://x", "bob").expect("query");
|
|
assert!(
|
|
conflict.is_some(),
|
|
"different account id under same kind+url is a conflict"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn discovery_follows_real_307_from_well_known_to_dav_root() {
|
|
let mut server = mockito::Server::new();
|
|
let url = server.url();
|
|
|
|
let empty = r#"<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"/>"#;
|
|
let _step1 = multistatus_response(&mut server, "PROPFIND", "/", empty);
|
|
|
|
let _redirect = server
|
|
.mock("PROPFIND", "/.well-known/caldav")
|
|
.with_status(307)
|
|
.with_header("location", &format!("{url}/dav/"))
|
|
.with_body("")
|
|
.create();
|
|
|
|
let principal_with_homeset = format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>{url}/dav/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:current-user-principal><d:href>{url}/dav/principals/u/</d:href></d:current-user-principal>
|
|
<c:calendar-home-set><d:href>{url}/dav/cal/u/</d:href></c:calendar-home-set>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
);
|
|
let collections_body = format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>{url}/dav/cal/u/default/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
);
|
|
let _on_dav = multistatus_response(&mut server, "PROPFIND", "/dav/", &principal_with_homeset);
|
|
let _on_principal = multistatus_response(
|
|
&mut server,
|
|
"PROPFIND",
|
|
"/dav/principals/u/",
|
|
&principal_with_homeset,
|
|
);
|
|
let _collections =
|
|
multistatus_response(&mut server, "PROPFIND", "/dav/cal/u/", &collections_body);
|
|
|
|
let c = client(0);
|
|
let disc = discover(&c, DavKind::Caldav, &url).expect("discover");
|
|
assert_eq!(disc.collections.len(), 1);
|
|
assert!(disc.home_set_url.ends_with("/dav/cal/u/"));
|
|
}
|
|
|
|
#[test]
|
|
fn discovery_skips_vfreebusy_only_calendars() {
|
|
let mut server = mockito::Server::new();
|
|
let url = server.url();
|
|
let body = format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>{url}/dav/cal/u/work/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
|
|
<d:displayname>Work</d:displayname>
|
|
<c:supported-calendar-component-set>
|
|
<c:comp name="VEVENT"/>
|
|
</c:supported-calendar-component-set>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>{url}/dav/cal/u/freebusy/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
|
|
<d:displayname>Free/Busy</d:displayname>
|
|
<c:supported-calendar-component-set>
|
|
<c:comp name="VFREEBUSY"/>
|
|
</c:supported-calendar-component-set>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
);
|
|
let _m = multistatus_response(&mut server, "PROPFIND", "/dav/cal/u/", &body);
|
|
let c = client(0);
|
|
let disc = discover(&c, DavKind::Caldav, &format!("{url}/dav/cal/u/")).expect("discover");
|
|
assert_eq!(disc.collections.len(), 1);
|
|
assert_eq!(
|
|
disc.collections[0].props.displayname.as_deref(),
|
|
Some("Work")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn webdav_discovery_keeps_only_self_row_as_root() {
|
|
let mut server = mockito::Server::new();
|
|
let url = server.url();
|
|
let body = format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:">
|
|
<d:response>
|
|
<d:href>{url}/files/u/sub1/</d:href>
|
|
<d:propstat>
|
|
<d:prop><d:resourcetype><d:collection/></d:resourcetype><d:displayname>sub1</d:displayname></d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>{url}/files/u/</d:href>
|
|
<d:propstat>
|
|
<d:prop><d:resourcetype><d:collection/></d:resourcetype><d:displayname>root</d:displayname></d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
);
|
|
let _m = multistatus_response(&mut server, "PROPFIND", "/files/u/", &body);
|
|
let c = client(0);
|
|
let disc = discover(&c, DavKind::Webdav, &format!("{url}/files/u/")).expect("discover");
|
|
assert_eq!(disc.collections.len(), 1, "only self-row returned");
|
|
assert!(disc.collections[0].href.as_str().ends_with("/files/u/"));
|
|
}
|
|
|
|
#[test]
|
|
fn webdav_root_collection_is_not_materialised_children_map_to_target_root() {
|
|
use rusqlite::Connection;
|
|
use vandelay::dav::discover::DiscoveredCollection;
|
|
use vandelay::dav::href::Href;
|
|
use vandelay::dav::parse::ResourceProps;
|
|
use vandelay::db;
|
|
use vandelay::db::sources::SourceKey;
|
|
use vandelay::logging::Logger;
|
|
use vandelay::sync::TypeCounts;
|
|
use vandelay::sync::import_dav::tree::{WebDavCtx, reconcile_filenodes};
|
|
|
|
let mut server = mockito::Server::new();
|
|
let url = server.url();
|
|
|
|
let root_body = format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:">
|
|
<d:response>
|
|
<d:href>{url}/dav/file/u/</d:href>
|
|
<d:propstat>
|
|
<d:prop><d:resourcetype><d:collection/></d:resourcetype><d:displayname>System administrator</d:displayname></d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>{url}/dav/file/u/sub/</d:href>
|
|
<d:propstat>
|
|
<d:prop><d:resourcetype><d:collection/></d:resourcetype><d:displayname>sub</d:displayname></d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>{url}/dav/file/u/top.txt</d:href>
|
|
<d:propstat>
|
|
<d:prop><d:resourcetype/><d:getcontenttype>text/plain</d:getcontenttype><d:getetag>"t1"</d:getetag></d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
);
|
|
let sub_body = format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:">
|
|
<d:response>
|
|
<d:href>{url}/dav/file/u/sub/</d:href>
|
|
<d:propstat>
|
|
<d:prop><d:resourcetype><d:collection/></d:resourcetype><d:displayname>sub</d:displayname></d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>{url}/dav/file/u/sub/inner.txt</d:href>
|
|
<d:propstat>
|
|
<d:prop><d:resourcetype/><d:getcontenttype>text/plain</d:getcontenttype><d:getetag>"i1"</d:getetag></d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
);
|
|
let _root = multistatus_response(&mut server, "PROPFIND", "/dav/file/u/", &root_body);
|
|
let _sub = multistatus_response(&mut server, "PROPFIND", "/dav/file/u/sub/", &sub_body);
|
|
let _f1 = server
|
|
.mock("GET", "/dav/file/u/top.txt")
|
|
.with_status(200)
|
|
.with_header("content-type", "text/plain")
|
|
.with_body("toplevel\n")
|
|
.create();
|
|
let _f2 = server
|
|
.mock("GET", "/dav/file/u/sub/inner.txt")
|
|
.with_status(200)
|
|
.with_header("content-type", "text/plain")
|
|
.with_body("inner\n")
|
|
.create();
|
|
|
|
let mut conn = Connection::open_in_memory().unwrap();
|
|
db::init::apply_schema(&conn).unwrap();
|
|
let source_id = db::sources::upsert_source(
|
|
&conn,
|
|
&SourceKey {
|
|
kind: "webdav".to_owned(),
|
|
session_url: url.clone(),
|
|
account_id: format!("{url}/dav/file/u/"),
|
|
},
|
|
Some("u"),
|
|
"u",
|
|
)
|
|
.unwrap();
|
|
|
|
let root = DiscoveredCollection {
|
|
url: format!("{url}/dav/file/u/"),
|
|
href: Href::from_normalised("/dav/file/u/".to_owned()),
|
|
props: ResourceProps {
|
|
is_collection: true,
|
|
displayname: Some("System administrator".to_owned()),
|
|
..Default::default()
|
|
},
|
|
};
|
|
let c = client(0);
|
|
let ctx = WebDavCtx {
|
|
client: &c,
|
|
source_id,
|
|
base_url: &url,
|
|
dav_connections: 2,
|
|
logger: Logger::from_flags(false, 0),
|
|
};
|
|
let mut counts = TypeCounts::default();
|
|
reconcile_filenodes(&mut conn, &ctx, &root, &mut counts).expect("reconcile");
|
|
|
|
let total: i64 = conn
|
|
.query_row("SELECT count(*) FROM file_nodes", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(
|
|
total, 3,
|
|
"only sub/, top.txt and inner.txt land; the root collection is a virtual mount point, not a node"
|
|
);
|
|
|
|
let admin_named: i64 = conn
|
|
.query_row(
|
|
"SELECT count(*) FROM file_nodes WHERE name = 'System administrator'",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
admin_named, 0,
|
|
"the account display name must not become a directory (issue #18)"
|
|
);
|
|
|
|
let top_parent: Option<i64> = conn
|
|
.query_row(
|
|
"SELECT parent_id FROM file_nodes WHERE name = 'top.txt'",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
top_parent, None,
|
|
"a file at the root maps to the target's implicit root (NULL parent)"
|
|
);
|
|
|
|
let (sub_id, sub_parent): (i64, Option<i64>) = conn
|
|
.query_row(
|
|
"SELECT id, parent_id FROM file_nodes WHERE name = 'sub'",
|
|
[],
|
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
sub_parent, None,
|
|
"a directory at the root maps to the target's implicit root (NULL parent)"
|
|
);
|
|
|
|
let inner_parent: Option<i64> = conn
|
|
.query_row(
|
|
"SELECT parent_id FROM file_nodes WHERE name = 'inner.txt'",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
inner_parent,
|
|
Some(sub_id),
|
|
"a nested file keeps its real parent directory"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn streaming_propfind_strips_control_chars_before_parse() {
|
|
let mut server = mockito::Server::new();
|
|
let url = server.url();
|
|
let dirty = format!(
|
|
"<?xml version=\"1.0\"?>\
|
|
\x01<d:multistatus xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\">\
|
|
<d:response>\
|
|
<d:href>{url}/dav/cal/u/d/</d:href>\
|
|
<d:propstat>\
|
|
<d:prop><d:resourcetype><d:collection/><c:calendar/></d:resourcetype></d:prop>\
|
|
<d:status>HTTP/1.1 200 OK</d:status>\
|
|
</d:propstat>\
|
|
</d:response>\
|
|
</d:multistatus>"
|
|
);
|
|
let _m = server
|
|
.mock("PROPFIND", "/dav/cal/u/")
|
|
.with_status(207)
|
|
.with_header("content-type", "application/xml; charset=utf-8")
|
|
.with_body(dirty)
|
|
.create();
|
|
let c = client(0);
|
|
let ms = c
|
|
.propfind_responses(
|
|
&format!("{url}/dav/cal/u/"),
|
|
1,
|
|
"<x/>",
|
|
&format!("{url}/dav/cal/u/"),
|
|
)
|
|
.expect("propfind_responses");
|
|
assert_eq!(ms.responses.len(), 1);
|
|
assert!(ms.responses[0].props.is_calendar);
|
|
}
|
|
|
|
#[test]
|
|
fn get_stream_404_returns_ok_with_empty_body() {
|
|
use std::io::Read;
|
|
let mut server = mockito::Server::new();
|
|
let _m = server
|
|
.mock("GET", "/dav/file/u/missing.txt")
|
|
.with_status(404)
|
|
.create();
|
|
let c = client(0);
|
|
let mut stream = c
|
|
.get_stream(&format!("{}/dav/file/u/missing.txt", server.url()))
|
|
.expect("get_stream Ok on 404");
|
|
assert_eq!(stream.status, 404);
|
|
let mut buf = Vec::new();
|
|
stream.read_to_end(&mut buf).expect("read empty");
|
|
assert!(buf.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn get_stream_429_with_retry_after_honours_header() {
|
|
let mut server = mockito::Server::new();
|
|
let _m1 = server
|
|
.mock("GET", "/dav/file/u/big.bin")
|
|
.with_status(429)
|
|
.with_header("retry-after", "1")
|
|
.with_body("rate-limited")
|
|
.create();
|
|
let _m2 = server
|
|
.mock("GET", "/dav/file/u/big.bin")
|
|
.with_status(200)
|
|
.with_header("etag", "\"v\"")
|
|
.with_body(b"abc")
|
|
.create();
|
|
let c = client(3);
|
|
let start = std::time::Instant::now();
|
|
let _stream = c
|
|
.get_stream(&format!("{}/dav/file/u/big.bin", server.url()))
|
|
.expect("get_stream eventually succeeds");
|
|
let elapsed = start.elapsed();
|
|
assert!(
|
|
elapsed.as_millis() >= 900,
|
|
"expected Retry-After 1s honoured, elapsed={elapsed:?}"
|
|
);
|
|
assert!(c.retry_after_sleeps() >= 1);
|
|
}
|
|
|
|
#[test]
|
|
fn execute_30x_without_location_is_fatal_not_loop() {
|
|
let mut server = mockito::Server::new();
|
|
let _m = server
|
|
.mock("PROPFIND", "/no-location")
|
|
.with_status(307)
|
|
.with_body("missing Location")
|
|
.create();
|
|
let c = client(0);
|
|
let err = c
|
|
.propfind(&format!("{}/no-location", server.url()), 0, "<x/>")
|
|
.unwrap_err();
|
|
assert!(matches!(err, JmapError::Connect(_)), "got {err:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn webdav_display_or_basename_test_via_parser_keeps_basename_semantics() {
|
|
let body = r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:">
|
|
<d:response>
|
|
<d:href>/files/u/Photo.jpg</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:resourcetype/>
|
|
<d:displayname>differently named.jpg</d:displayname>
|
|
<d:getetag>"f1"</d:getetag>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#;
|
|
let parsed = parse_multistatus(Cursor::new(body), "https://x/").expect("parse");
|
|
assert_eq!(
|
|
parsed[0].props.displayname.as_deref(),
|
|
Some("differently named.jpg")
|
|
);
|
|
assert!(parsed[0].href.as_str().ends_with("/Photo.jpg"));
|
|
}
|
|
|
|
#[test]
|
|
fn dry_run_writes_nothing_but_emits_per_collection_counts() {
|
|
use std::path::PathBuf;
|
|
use vandelay::logging::Logger;
|
|
use vandelay::sync::CommonConfig;
|
|
use vandelay::sync::import_dav::{DavAuth, DavImportConfig, DavKindArg, run};
|
|
|
|
let mut server = mockito::Server::new();
|
|
let url = server.url();
|
|
let collections_body = format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>{url}/dav/cal/u/default/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
|
|
<d:displayname>Default</d:displayname>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
);
|
|
let items_body = format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:">
|
|
<d:response>
|
|
<d:href>{url}/dav/cal/u/default/e1.ics</d:href>
|
|
<d:propstat>
|
|
<d:prop><d:resourcetype/><d:getetag>"v1"</d:getetag></d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>{url}/dav/cal/u/default/e2.ics</d:href>
|
|
<d:propstat>
|
|
<d:prop><d:resourcetype/><d:getetag>"v2"</d:getetag></d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
);
|
|
let _disc = multistatus_response(&mut server, "PROPFIND", "/dav/cal/u/", &collections_body);
|
|
let _items = multistatus_response(&mut server, "PROPFIND", "/dav/cal/u/default/", &items_body);
|
|
|
|
let archive: PathBuf = std::env::temp_dir().join(format!(
|
|
"vandelay-dryrun-{}-{}.sqlite",
|
|
std::process::id(),
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos()
|
|
));
|
|
let _ = std::fs::remove_file(&archive);
|
|
let common = CommonConfig {
|
|
archive: archive.clone(),
|
|
threads: 1,
|
|
dry_run: true,
|
|
max_retries: 0,
|
|
allow_invalid_certs: true,
|
|
logger: Logger::from_flags(true, 0),
|
|
};
|
|
let config = DavImportConfig {
|
|
kind: DavKindArg::Caldav,
|
|
url: format!("{url}/dav/cal/u/"),
|
|
auth: DavAuth::Basic {
|
|
user: "alice".to_owned(),
|
|
password: "p".to_owned(),
|
|
},
|
|
allow_cleartext: true,
|
|
dav_connections: 1,
|
|
multiget_batch: 50,
|
|
allow_source_change: false,
|
|
};
|
|
let summary = run(common, config).expect("dry-run ok");
|
|
|
|
let conn = rusqlite::Connection::open(&archive).unwrap();
|
|
let n_sources: i64 = conn
|
|
.query_row("SELECT count(*) FROM sources", [], |r| r.get(0))
|
|
.unwrap();
|
|
let n_calendars: i64 = conn
|
|
.query_row("SELECT count(*) FROM calendars", [], |r| r.get(0))
|
|
.unwrap();
|
|
let n_events: i64 = conn
|
|
.query_row("SELECT count(*) FROM calendar_events", [], |r| r.get(0))
|
|
.unwrap();
|
|
let n_sync: i64 = conn
|
|
.query_row("SELECT count(*) FROM sync_id_dav", [], |r| r.get(0))
|
|
.unwrap();
|
|
drop(conn);
|
|
let _ = std::fs::remove_file(&archive);
|
|
|
|
assert_eq!(n_sources, 0, "dry-run does not write sources");
|
|
assert_eq!(n_calendars, 0, "dry-run does not write calendars");
|
|
assert_eq!(n_events, 0, "dry-run does not write events");
|
|
assert_eq!(n_sync, 0, "dry-run does not write sync_id_dav");
|
|
|
|
let calendar_counts = summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(name, _)| *name == "calendar")
|
|
.expect("calendar counts in summary");
|
|
let event_counts = summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(name, _)| *name == "calendarevent")
|
|
.expect("event counts in summary");
|
|
assert_eq!(calendar_counts.1.created, 1);
|
|
assert_eq!(event_counts.1.created, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn dav_source_change_protection_fires_across_users_on_same_root() {
|
|
use encodify::base64::STANDARD;
|
|
use std::path::PathBuf;
|
|
use vandelay::logging::Logger;
|
|
use vandelay::sync::CommonConfig;
|
|
use vandelay::sync::import_dav::{DavAuth, DavImportConfig, DavKindArg, run};
|
|
|
|
let mut server = mockito::Server::new();
|
|
let url = server.url();
|
|
|
|
let listing = format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>{url}/dav/cal/shared/default/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
|
|
<d:displayname>Default</d:displayname>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
);
|
|
let _listing = server
|
|
.mock("PROPFIND", "/dav/cal/")
|
|
.match_header("depth", "1")
|
|
.with_status(207)
|
|
.with_header("content-type", "application/xml; charset=utf-8")
|
|
.with_body(&listing)
|
|
.create();
|
|
|
|
let empty_items = format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>{url}/dav/cal/shared/default/</d:href>
|
|
<d:propstat>
|
|
<d:prop><d:resourcetype><d:collection/><c:calendar/></d:resourcetype></d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
);
|
|
let _items = server
|
|
.mock("PROPFIND", "/dav/cal/shared/default/")
|
|
.with_status(207)
|
|
.with_header("content-type", "application/xml; charset=utf-8")
|
|
.with_body(&empty_items)
|
|
.create();
|
|
|
|
let auth_a = format!("Basic {}", STANDARD.encode("[email protected]:passA"));
|
|
let auth_b = format!("Basic {}", STANDARD.encode("[email protected]:passB"));
|
|
let principal = |who: &str| {
|
|
format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:">
|
|
<d:response>
|
|
<d:href>{url}/dav/cal/</d:href>
|
|
<d:propstat>
|
|
<d:prop><d:current-user-principal><d:href>{url}/dav/principals/{who}/</d:href></d:current-user-principal></d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
)
|
|
};
|
|
let _pa = server
|
|
.mock("PROPFIND", "/dav/cal/")
|
|
.match_header("depth", "0")
|
|
.match_header("authorization", auth_a.as_str())
|
|
.with_status(207)
|
|
.with_header("content-type", "application/xml; charset=utf-8")
|
|
.with_body(principal("[email protected]"))
|
|
.create();
|
|
let _pb = server
|
|
.mock("PROPFIND", "/dav/cal/")
|
|
.match_header("depth", "0")
|
|
.match_header("authorization", auth_b.as_str())
|
|
.with_status(207)
|
|
.with_header("content-type", "application/xml; charset=utf-8")
|
|
.with_body(principal("[email protected]"))
|
|
.create();
|
|
|
|
let archive: PathBuf = std::env::temp_dir().join(format!(
|
|
"vandelay-dav-srcchange-{}-{}.sqlite",
|
|
std::process::id(),
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos()
|
|
));
|
|
let _ = std::fs::remove_file(&archive);
|
|
|
|
let common = |archive: &PathBuf| CommonConfig {
|
|
archive: archive.clone(),
|
|
threads: 1,
|
|
dry_run: false,
|
|
max_retries: 0,
|
|
allow_invalid_certs: true,
|
|
logger: Logger::from_flags(false, 0),
|
|
};
|
|
let config = |user: &str, pass: &str| DavImportConfig {
|
|
kind: DavKindArg::Caldav,
|
|
url: format!("{url}/dav/cal/"),
|
|
auth: DavAuth::Basic {
|
|
user: user.to_owned(),
|
|
password: pass.to_owned(),
|
|
},
|
|
allow_cleartext: true,
|
|
dav_connections: 1,
|
|
multiget_batch: 50,
|
|
allow_source_change: false,
|
|
};
|
|
|
|
run(common(&archive), config("[email protected]", "passA")).expect("user A import ok");
|
|
let err = run(common(&archive), config("[email protected]", "passB")).unwrap_err();
|
|
let _ = std::fs::remove_file(&archive);
|
|
assert!(
|
|
matches!(err, vandelay::error::Error::SourceChange(_)),
|
|
"importing a different user into the same archive must trigger source-change protection; got {err:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn source_change_protection_rejects_different_session_url_same_account() {
|
|
use rusqlite::Connection;
|
|
use vandelay::db::{init, sources};
|
|
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
init::apply_schema(&conn).unwrap();
|
|
let first = sources::SourceKey {
|
|
kind: "caldav".to_owned(),
|
|
session_url: "https://host1.example".to_owned(),
|
|
account_id: "alice".to_owned(),
|
|
};
|
|
sources::upsert_source(&conn, &first, Some("alice"), "alice").unwrap();
|
|
let conflict = sources::conflicting_source(&conn, "caldav", "https://host2.example", "alice")
|
|
.expect("query");
|
|
assert!(
|
|
conflict.is_some(),
|
|
"different session_url under same kind+account is a conflict"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn etag_missing_in_enumeration_propagates_to_storage_layer() {
|
|
let body = r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:">
|
|
<d:response>
|
|
<d:href>/dav/cal/u/d/no-etag.ics</d:href>
|
|
<d:propstat>
|
|
<d:prop><d:resourcetype/></d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>/dav/cal/u/d/with-etag.ics</d:href>
|
|
<d:propstat>
|
|
<d:prop><d:resourcetype/><d:getetag>"v1"</d:getetag></d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#;
|
|
let parsed = parse_multistatus(Cursor::new(body), "https://x/").expect("parse");
|
|
let no_etag = parsed
|
|
.iter()
|
|
.find(|r| r.href.as_str().ends_with("no-etag.ics"))
|
|
.unwrap();
|
|
let with_etag = parsed
|
|
.iter()
|
|
.find(|r| r.href.as_str().ends_with("with-etag.ics"))
|
|
.unwrap();
|
|
assert!(no_etag.props.etag.is_none(), "missing etag is None");
|
|
assert_eq!(with_etag.props.etag.as_deref(), Some("\"v1\""));
|
|
}
|
|
|
|
#[test]
|
|
fn mixed_source_archive_allows_distinct_kinds_against_same_url() {
|
|
use rusqlite::Connection;
|
|
use vandelay::db::{init, sources};
|
|
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
init::apply_schema(&conn).unwrap();
|
|
let caldav = sources::SourceKey {
|
|
kind: "caldav".to_owned(),
|
|
session_url: "https://x".to_owned(),
|
|
account_id: "alice".to_owned(),
|
|
};
|
|
let carddav = sources::SourceKey {
|
|
kind: "carddav".to_owned(),
|
|
session_url: "https://x".to_owned(),
|
|
account_id: "alice".to_owned(),
|
|
};
|
|
let webdav = sources::SourceKey {
|
|
kind: "webdav".to_owned(),
|
|
session_url: "https://x".to_owned(),
|
|
account_id: "alice".to_owned(),
|
|
};
|
|
let id_cal = sources::upsert_source(&conn, &caldav, Some("alice"), "alice").unwrap();
|
|
let id_card = sources::upsert_source(&conn, &carddav, Some("alice"), "alice").unwrap();
|
|
let id_web = sources::upsert_source(&conn, &webdav, Some("alice"), "alice").unwrap();
|
|
assert_ne!(id_cal, id_card);
|
|
assert_ne!(id_card, id_web);
|
|
assert_ne!(id_cal, id_web);
|
|
|
|
let no_conflict = sources::conflicting_source(&conn, "caldav", "https://x", "alice").unwrap();
|
|
assert!(no_conflict.is_none());
|
|
}
|
|
|
|
fn dav_archive() -> std::path::PathBuf {
|
|
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
|
let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
let mut p = std::env::temp_dir();
|
|
p.push(format!(
|
|
"vandelay-mockdav-{}-{:?}-{n}.sqlite",
|
|
std::process::id(),
|
|
std::thread::current().id(),
|
|
));
|
|
let _ = std::fs::remove_file(&p);
|
|
p
|
|
}
|
|
|
|
fn caldav_import_config(url: &str) -> vandelay::sync::import_dav::DavImportConfig {
|
|
vandelay::sync::import_dav::DavImportConfig {
|
|
kind: vandelay::sync::import_dav::DavKindArg::Caldav,
|
|
url: url.to_owned(),
|
|
auth: vandelay::sync::import_dav::DavAuth::Basic {
|
|
user: "u".to_owned(),
|
|
password: "p".to_owned(),
|
|
},
|
|
allow_cleartext: true,
|
|
dav_connections: 1,
|
|
multiget_batch: 8,
|
|
allow_source_change: false,
|
|
}
|
|
}
|
|
|
|
fn dav_common(archive: &std::path::Path) -> vandelay::sync::CommonConfig {
|
|
vandelay::sync::CommonConfig {
|
|
archive: archive.to_path_buf(),
|
|
threads: 1,
|
|
dry_run: false,
|
|
max_retries: 0,
|
|
allow_invalid_certs: false,
|
|
logger: vandelay::logging::Logger::from_flags(true, 0),
|
|
}
|
|
}
|
|
|
|
fn row_count(archive: &std::path::Path, table: &str) -> i64 {
|
|
let conn = rusqlite::Connection::open(archive).expect("open archive");
|
|
conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
|
|
.expect("count")
|
|
}
|
|
|
|
#[test]
|
|
fn forbidden_collection_is_a_per_unit_failure_and_the_others_still_import() {
|
|
let mut server = mockito::Server::new();
|
|
let url = server.url();
|
|
|
|
let home_set = format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>{url}/dav/cal/u/work/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
|
|
<d:displayname>Work</d:displayname>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>{url}/dav/cal/u/shared/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
|
|
<d:displayname>Shared</d:displayname>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
);
|
|
let _discovery = multistatus_response(&mut server, "PROPFIND", "/dav/cal/u/", &home_set);
|
|
|
|
let work_items = format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:">
|
|
<d:response>
|
|
<d:href>{url}/dav/cal/u/work/e1.ics</d:href>
|
|
<d:propstat>
|
|
<d:prop><d:resourcetype/><d:getetag>"v1"</d:getetag></d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
);
|
|
let _work_enumeration =
|
|
multistatus_response(&mut server, "PROPFIND", "/dav/cal/u/work/", &work_items);
|
|
|
|
let work_data = format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>{url}/dav/cal/u/work/e1.ics</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:getetag>"v1"</d:getetag>
|
|
<c:calendar-data>BEGIN:VCALENDAR
|
|
VERSION:2.0
|
|
PRODID:-//Test//EN
|
|
BEGIN:VEVENT
|
|
UID:[email protected]
|
|
DTSTAMP:20260101T000000Z
|
|
DTSTART:20260101T090000Z
|
|
DTEND:20260101T100000Z
|
|
SUMMARY:Standup
|
|
END:VEVENT
|
|
END:VCALENDAR
|
|
</c:calendar-data>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
);
|
|
let _work_multiget =
|
|
multistatus_response(&mut server, "REPORT", "/dav/cal/u/work/", &work_data);
|
|
|
|
let forbidden = server
|
|
.mock("PROPFIND", "/dav/cal/u/shared/")
|
|
.with_status(403)
|
|
.with_header("content-type", "text/plain")
|
|
.with_body("you may not read this calendar")
|
|
.expect_at_least(1)
|
|
.create();
|
|
|
|
let archive = dav_archive();
|
|
let summary = vandelay::sync::import_dav::run(
|
|
dav_common(&archive),
|
|
caldav_import_config(&format!("{url}/dav/cal/u/")),
|
|
)
|
|
.expect("a forbidden collection must not abort the run");
|
|
|
|
forbidden.assert();
|
|
|
|
let events = summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "calendarevent")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("calendarevent counts");
|
|
assert_eq!(events.failed, 1, "the forbidden calendar counts as failed");
|
|
assert_eq!(events.fetched, 1, "the readable calendar still imported");
|
|
assert!(
|
|
summary.any_failed(),
|
|
"a per-unit failure ends the run at exit 5"
|
|
);
|
|
|
|
assert_eq!(row_count(&archive, "calendars"), 2);
|
|
assert_eq!(row_count(&archive, "calendar_events"), 1);
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn credentials_rejected_during_discovery_still_aborts_the_run() {
|
|
let mut server = mockito::Server::new();
|
|
let url = server.url();
|
|
|
|
let _rejected = server
|
|
.mock("PROPFIND", mockito::Matcher::Any)
|
|
.with_status(401)
|
|
.with_header("content-type", "text/plain")
|
|
.with_body("bad password")
|
|
.expect_at_least(1)
|
|
.create();
|
|
|
|
let archive = dav_archive();
|
|
let err = vandelay::sync::import_dav::run(
|
|
dav_common(&archive),
|
|
caldav_import_config(&format!("{url}/dav/cal/u/")),
|
|
)
|
|
.expect_err("credentials rejected for the principal aborts the run");
|
|
|
|
assert!(
|
|
matches!(err, vandelay::error::Error::Connection(_)),
|
|
"discovery-level auth rejection is a whole-run failure, got {err:?}"
|
|
);
|
|
assert_eq!(err.exit_code(), 2);
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn dav_import_reports_both_phases_when_every_collection_fails() {
|
|
let mut server = mockito::Server::new();
|
|
let url = server.url();
|
|
|
|
let home_set = format!(
|
|
r#"<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>{url}/dav/cal/u/work/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
|
|
<d:displayname>Work</d:displayname>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"#
|
|
);
|
|
let _discovery = multistatus_response(&mut server, "PROPFIND", "/dav/cal/u/", &home_set);
|
|
let _forbidden = server
|
|
.mock("PROPFIND", "/dav/cal/u/work/")
|
|
.with_status(403)
|
|
.with_body("nope")
|
|
.expect_at_least(1)
|
|
.create();
|
|
|
|
let archive = dav_archive();
|
|
let outcome = vandelay::sync::import_dav::run_reporting(
|
|
dav_common(&archive),
|
|
caldav_import_config(&format!("{url}/dav/cal/u/")),
|
|
);
|
|
assert!(outcome.error.is_none());
|
|
assert_eq!(
|
|
outcome
|
|
.summary
|
|
.per_type
|
|
.iter()
|
|
.map(|(t, _)| *t)
|
|
.collect::<Vec<_>>(),
|
|
vec!["calendar", "calendarevent"],
|
|
"both phases are reported even when every collection failed"
|
|
);
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|