/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * 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#" {url}/dav/cal/u/default/ Default HTTP/1.1 200 OK "# ); 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#" {url}/dav/cal/secondary@vandelay.org/default/ Default HTTP/1.1 200 OK "# ); 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#" {url}/dav/cal/ {url}/dav/principals/secondary@vandelay.org/ HTTP/1.1 200 OK "# ); 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/secondary@vandelay.org/").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#" "#; let _m1 = multistatus_response(&mut server, "PROPFIND", "/", empty_collection_body); let principal_body = format!( r#" {url}/ {url}/principals/alice/ HTTP/1.1 200 OK "# ); let _m2 = multistatus_response(&mut server, "PROPFIND", "/", &principal_body); let homeset_body = format!( r#" {url}/principals/alice/ {url}/dav/cal/alice/ HTTP/1.1 200 OK "# ); let _m3 = multistatus_response(&mut server, "PROPFIND", "/principals/alice/", &homeset_body); let collections_body = format!( r#" {url}/dav/cal/alice/default/ Default HTTP/1.1 200 OK "# ); 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#""#; 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#" /cal/a/ A HTTP/1.1 200 OK "#; 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 = b"\x02".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#" /dav/cal/u/d/ HTTP/1.1 200 OK "#; 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, "") .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, "") .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#""#) .create(); let c = client(3); let result = c .propfind(&format!("{}/", server.url()), 0, "") .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#" /cal/1.ics Sales & Marketing <Team> BEGIN:VEVENT SUMMARY:Q&A & review END:VEVENT HTTP/1.1 200 OK "#; 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 ") ); 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#" /x "a"HTTP/1.1 200 OK /x "b"HTTP/1.1 200 OK "#; 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("")); let chs = xml::propfind_calendar_home_set(); assert!(chs.contains("")); let ahs = xml::propfind_addressbook_home_set(); assert!(ahs.contains("")); let listing = xml::propfind_webdav_listing(); assert!(listing.contains("")); } #[test] fn report_multiget_against_mock_returns_calendar_data() { let mut server = mockito::Server::new(); let body = r#" /dav/cal/u/d/a.ics "v1" BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Test//EN BEGIN:VEVENT UID:e1@example.com DTSTAMP:20260101T000000Z DTSTART:20260101T090000Z DTEND:20260101T100000Z SUMMARY:Hi END:VEVENT END:VCALENDAR HTTP/1.1 200 OK "#; 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, "") .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#" {url}/files/ HTTP/1.1 200 OK {url}/files/sub/ sub 2026-01-01T00:00:00Z HTTP/1.1 200 OK {url}/files/hello.txt text/plain "f1" 5 HTTP/1.1 200 OK "# ); let _m = multistatus_response(&mut server, "PROPFIND", "/files/", &body); let c = client(0); let result = c .propfind(&format!("{url}/files/"), 1, "") .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#" /dav/cal/u/d/requested.ics "v"X HTTP/1.1 200 OK /dav/cal/u/d/unsolicited.ics "v"Y HTTP/1.1 200 OK "#; 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#" /dav/cal/u/d/gone.ics HTTP/1.1 404 Not Found "#; 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#""#; let _root = multistatus_response(&mut server, "PROPFIND", "/", empty); let principal_with_homeset = format!( r#" {url}/dav/principals/u/ {url}/dav/principals/u/ {url}/dav/cal/u/ HTTP/1.1 200 OK "# ); let collections_body = format!( r#" {url}/dav/cal/u/default/ Default HTTP/1.1 200 OK "# ); 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#" {url}/dav/principals/u/ {url}/dav/principals/u/ {url}/dav/cal/u/ HTTP/1.1 200 OK "# ); let collections_body = format!( r#" {url}/dav/cal/u/default/ Default HTTP/1.1 200 OK "# ); 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#""#; let _step1 = multistatus_response(&mut server, "PROPFIND", "/", empty); let no_principal_with_homeset = format!( r#" {url}/ {url}/dav/cal/u/ HTTP/1.1 200 OK "# ); let collections_body = format!( r#" {url}/dav/cal/u/default/ HTTP/1.1 200 OK "# ); 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#" /dav/cal/u/d/ HTTP/1.1 200 OK /dav/cal/u/d/event1.ics "v1" HTTP/1.1 200 OK "#; 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#" /dav/cal/u/d/event.ics HTTP/1.1 200 OK "#; 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#" /dav/cal/u/d/event.ics "v2" BEGIN:VCALENDAR END:VCALENDAR HTTP/1.1 200 OK "#; 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#" /files/a/ HTTP/1.1 200 OK /files/a/b/ HTTP/1.1 200 OK "#; 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#" /dav/cal/u/d/e.ics "v" HTTP/1.1 200 OK HTTP/1.1 404 Not Found "#; 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#" {url}/dav/cal/u/d/ Default HTTP/1.1 200 OK "# ); 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, "", &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, "", &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, "", &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#""#; 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#" {url}/dav/ {url}/dav/principals/u/ {url}/dav/cal/u/ HTTP/1.1 200 OK "# ); let collections_body = format!( r#" {url}/dav/cal/u/default/ HTTP/1.1 200 OK "# ); 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#" {url}/dav/cal/u/work/ Work HTTP/1.1 200 OK {url}/dav/cal/u/freebusy/ Free/Busy HTTP/1.1 200 OK "# ); 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#" {url}/files/u/sub1/ sub1 HTTP/1.1 200 OK {url}/files/u/ root HTTP/1.1 200 OK "# ); 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#" {url}/dav/file/u/ System administrator HTTP/1.1 200 OK {url}/dav/file/u/sub/ sub HTTP/1.1 200 OK {url}/dav/file/u/top.txt text/plain"t1" HTTP/1.1 200 OK "# ); let sub_body = format!( r#" {url}/dav/file/u/sub/ sub HTTP/1.1 200 OK {url}/dav/file/u/sub/inner.txt text/plain"i1" HTTP/1.1 200 OK "# ); 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 = 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) = 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 = 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!( "\ \x01\ \ {url}/dav/cal/u/d/\ \ \ HTTP/1.1 200 OK\ \ \ " ); 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, "", &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, "") .unwrap_err(); assert!(matches!(err, JmapError::Connect(_)), "got {err:?}"); } #[test] fn webdav_display_or_basename_test_via_parser_keeps_basename_semantics() { let body = r#" /files/u/Photo.jpg differently named.jpg "f1" HTTP/1.1 200 OK "#; 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#" {url}/dav/cal/u/default/ Default HTTP/1.1 200 OK "# ); let items_body = format!( r#" {url}/dav/cal/u/default/e1.ics "v1" HTTP/1.1 200 OK {url}/dav/cal/u/default/e2.ics "v2" HTTP/1.1 200 OK "# ); 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#" {url}/dav/cal/shared/default/ Default HTTP/1.1 200 OK "# ); 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#" {url}/dav/cal/shared/default/ HTTP/1.1 200 OK "# ); 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("secondary@vandelay.org:passA")); let auth_b = format!("Basic {}", STANDARD.encode("tertiary@vandelay.org:passB")); let principal = |who: &str| { format!( r#" {url}/dav/cal/ {url}/dav/principals/{who}/ HTTP/1.1 200 OK "# ) }; 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("secondary@vandelay.org")) .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("tertiary@vandelay.org")) .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("secondary@vandelay.org", "passA")).expect("user A import ok"); let err = run(common(&archive), config("tertiary@vandelay.org", "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#" /dav/cal/u/d/no-etag.ics HTTP/1.1 200 OK /dav/cal/u/d/with-etag.ics "v1" HTTP/1.1 200 OK "#; 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#" {url}/dav/cal/u/work/ Work HTTP/1.1 200 OK {url}/dav/cal/u/shared/ Shared HTTP/1.1 200 OK "# ); let _discovery = multistatus_response(&mut server, "PROPFIND", "/dav/cal/u/", &home_set); let work_items = format!( r#" {url}/dav/cal/u/work/e1.ics "v1" HTTP/1.1 200 OK "# ); let _work_enumeration = multistatus_response(&mut server, "PROPFIND", "/dav/cal/u/work/", &work_items); let work_data = format!( r#" {url}/dav/cal/u/work/e1.ics "v1" BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Test//EN BEGIN:VEVENT UID:e1@example.com DTSTAMP:20260101T000000Z DTSTART:20260101T090000Z DTEND:20260101T100000Z SUMMARY:Standup END:VEVENT END:VCALENDAR HTTP/1.1 200 OK "# ); 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#" {url}/dav/cal/u/work/ Work HTTP/1.1 200 OK "# ); 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!["calendar", "calendarevent"], "both phases are reported even when every collection failed" ); let _ = std::fs::remove_file(&archive); }