diff --git a/src/application/adapters/caldav_adapter.rs b/src/application/adapters/caldav_adapter.rs index b2541e2f..7b500695 100644 --- a/src/application/adapters/caldav_adapter.rs +++ b/src/application/adapters/caldav_adapter.rs @@ -17,6 +17,36 @@ use crate::application::adapters::webdav_adapter::{ }; use crate::application::dtos::calendar_dto::{CalendarDto, CalendarEventDto}; +/// Parse a CalDAV `time-range` element's `start` / `end` attribute +/// value into a UTC `DateTime`. +/// +/// RFC 4791 §9.9 requires iCalendar DATE-TIME format +/// (`YYYYMMDDTHHMMSSZ` — no dashes, no colons). Every real client +/// (Thunderbird, Apple Calendar, DAVx⁵, Gnome Calendar) sends this +/// shape, as does the `python-caldav` library. +/// +/// A prior pass parsed the value with `DateTime::parse_from_rfc3339` +/// exclusively, which expects `YYYY-MM-DDTHH:MM:SSZ` and fails on +/// the standard shape — silently returning `None`. The caller then +/// dropped the whole time-range filter and fell through to +/// `list_events`, returning the entire calendar regardless of the +/// window. RFC 3339 is retained as a defensive fallback for the rare +/// client that emits it. +/// +/// Returns `None` on any parse failure — callers propagate that as +/// "no time-range filter provided", matching the pre-fix behaviour +/// for missing attributes. +fn parse_caldav_datetime(value: &str) -> Option> { + chrono::NaiveDateTime::parse_from_str(value, "%Y%m%dT%H%M%SZ") + .map(|nd| nd.and_utc()) + .ok() + .or_else(|| { + DateTime::parse_from_rfc3339(value) + .ok() + .map(|dt| dt.with_timezone(&Utc)) + }) +} + /// Returns whether `caller_id` owns `calendar`. /// /// CalDAV clients (DAVx5, Apple Calendar, Thunderbird) only mount a collection @@ -92,7 +122,6 @@ impl CalDavAdapter { s if s == "prop" || s.ends_with(":prop") => in_prop = true, s if s == "filter" || s.ends_with(":filter") => in_filter = true, s if s == "time-range" || s.ends_with(":time-range") => { - // Parse time-range attributes for attr in e.attributes().flatten() { let attr_name = std::str::from_utf8(attr.key.as_ref()).unwrap_or(""); @@ -101,14 +130,9 @@ impl CalDavAdapter { .unwrap_or_default(); if attr_name == "start" { - // Parse ISO date format with Z for UTC - start_time = DateTime::parse_from_rfc3339(&attr_value) - .ok() - .map(|dt| dt.with_timezone(&Utc)); + start_time = parse_caldav_datetime(&attr_value); } else if attr_name == "end" { - end_time = DateTime::parse_from_rfc3339(&attr_value) - .ok() - .map(|dt| dt.with_timezone(&Utc)); + end_time = parse_caldav_datetime(&attr_value); } } } @@ -160,7 +184,7 @@ impl CalDavAdapter { let qname = WebDavAdapter::resolve_name(name_str, &ns_map); props.push(qname); } else if name_str == "time-range" || name_str.ends_with(":time-range") { - // Parse time-range attributes + // Empty-element form: for attr in e.attributes().flatten() { let attr_name = std::str::from_utf8(attr.key.as_ref()).unwrap_or(""); let attr_value = attr @@ -168,14 +192,9 @@ impl CalDavAdapter { .unwrap_or_default(); if attr_name == "start" { - // Parse ISO date format with Z for UTC - start_time = DateTime::parse_from_rfc3339(&attr_value) - .ok() - .map(|dt| dt.with_timezone(&Utc)); + start_time = parse_caldav_datetime(&attr_value); } else if attr_name == "end" { - end_time = DateTime::parse_from_rfc3339(&attr_value) - .ok() - .map(|dt| dt.with_timezone(&Utc)); + end_time = parse_caldav_datetime(&attr_value); } } } @@ -1304,3 +1323,112 @@ impl CalDavAdapter { Ok((displayname, description, color)) } } + +// ───────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────── + +#[cfg(test)] +mod time_range_parser_tests { + use super::*; + + // ── parse_caldav_datetime ───────────────────────────────── + + #[test] + fn ical_date_time_utc_form_parses() { + // Standard shape per RFC 4791 §9.9 / RFC 5545 §3.3.5 — + // what every real CalDAV client sends. + let parsed = parse_caldav_datetime("20260103T090000Z").expect("iCal DATE-TIME must parse"); + assert_eq!(parsed.to_rfc3339(), "2026-01-03T09:00:00+00:00"); + } + + #[test] + fn rfc3339_form_parses_as_fallback() { + // Defensive fallback for the rare client that emits + // dashes+colons. Retained so behaviour is a superset of + // the pre-fix parser (which accepted only this shape). + let parsed = parse_caldav_datetime("2026-01-03T09:00:00Z").expect("RFC 3339 fallback"); + assert_eq!(parsed.to_rfc3339(), "2026-01-03T09:00:00+00:00"); + } + + #[test] + fn ical_and_rfc3339_agree_on_same_instant() { + // Sanity: the two accepted forms represent the same + // instant when they describe the same wall time. + let a = parse_caldav_datetime("20260103T090000Z").unwrap(); + let b = parse_caldav_datetime("2026-01-03T09:00:00Z").unwrap(); + assert_eq!(a, b); + } + + #[test] + fn empty_string_returns_none() { + assert!(parse_caldav_datetime("").is_none()); + } + + #[test] + fn malformed_returns_none() { + // Neither iCal nor RFC 3339 shape — parser must reject + // without panicking. The caller treats None as "no + // time-range attribute provided", falling through to the + // unfiltered event listing (same as the pre-fix + // behaviour on unparseable input — but at least now we + // reach that branch by intent, not by silent parse loss). + assert!(parse_caldav_datetime("not-a-datetime").is_none()); + assert!(parse_caldav_datetime("20260103").is_none()); // date only, no time + assert!(parse_caldav_datetime("20260103T090000").is_none()); // missing Z + } + + // ── parse_report — end-to-end integration ───────────────── + + #[test] + fn calendar_query_with_ical_time_range_captures_both_bounds() { + // The end-to-end regression: a calendar-query REPORT + // with iCal DATE-TIME `time-range` attributes MUST + // surface both bounds as Some in `CalDavReportType:: + // CalendarQuery { time_range, .. }`. Pre-fix this test + // would have seen `time_range = None` because + // parse_from_rfc3339 rejected `20260101T093000Z`. + let xml = r#" + + + + + + + + + +"#; + + let report = CalDavAdapter::parse_report(xml.as_bytes()).expect("REPORT parses"); + + match report { + CalDavReportType::CalendarQuery { time_range, .. } => { + let (start, end) = time_range + .expect("iCal DATE-TIME time-range must parse as Some; got None (regression)"); + assert_eq!(start.to_rfc3339(), "2026-01-01T09:30:00+00:00"); + assert_eq!(end.to_rfc3339(), "2026-01-01T12:00:00+00:00"); + } + other => panic!("Expected CalendarQuery, got {:?}", other), + } + } + + #[test] + fn calendar_query_without_time_range_has_none() { + // Baseline: a filter-less calendar-query still produces + // CalendarQuery with time_range=None. Guards against a + // fix that overreaches and starts inventing time bounds. + let xml = r#" + + +"#; + + let report = CalDavAdapter::parse_report(xml.as_bytes()).expect("REPORT parses"); + match report { + CalDavReportType::CalendarQuery { time_range, .. } => { + assert!(time_range.is_none()); + } + other => panic!("Expected CalendarQuery, got {:?}", other), + } + } +} diff --git a/tests/api/caldav_calendar_query.hurl b/tests/api/caldav_calendar_query.hurl new file mode 100644 index 00000000..9f679928 --- /dev/null +++ b/tests/api/caldav_calendar_query.hurl @@ -0,0 +1,261 @@ +# ============================================================= +# OxiCloud — CalDAV calendar-query REPORT time-range regression +# ============================================================= +# Regression pin for the time-range parser fix on +# fix/caldav-time-range-parser. +# +# Pre-fix: caldav_adapter.rs::parse_report used +# `DateTime::parse_from_rfc3339` on the `` attribute values. That parser expects +# `YYYY-MM-DDTHH:MM:SSZ` (dashes + colons). CalDAV clients send +# iCalendar DATE-TIME format (`YYYYMMDDTHHMMSSZ` — no separators) +# per RFC 4791 §9.9 / RFC 5545 §3.3.5. Result: parse silently +# failed, `time_range` was `None`, and the REPORT handler fell +# through to `list_events`, returning the ENTIRE calendar +# regardless of the requested window. +# +# Post-fix: `parse_caldav_datetime` accepts iCal DATE-TIME +# (the standard) with RFC 3339 as a defensive fallback. Time- +# range filters now actually filter. +# +# Test shape: create two events at 09:00 UTC and 15:00 UTC, then +# calendar-query REPORT with an iCal-DATE-TIME window covering +# only the 09:00 event. Assert the response contains the 09:00 +# event's UID and does NOT contain the 15:00 event's UID. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Admin login. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +admin_token: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — Provision a fresh calendar (`tr-cal`) so the seeded +# events don't collide with anything downstream tests provisioned. +# MKCALENDAR returns 201; the server assigns its own UUID which +# we capture via PROPFIND in Step 3. +# ───────────────────────────────────────────────────────────── +MKCALENDAR {{base_url}}/caldav/tr-cal/ +Authorization: Bearer {{admin_token}} + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 3 — Discover the server-assigned UUID via PROPFIND. The +# `(?s).*` anchor greedy-matches to the LAST /caldav// in +# the body — that's `tr-cal`, freshest by created_at. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/caldav/ +Authorization: Bearer {{admin_token}} +Depth: 1 +Content-Type: application/xml +``` + + + + +``` + +HTTP 207 +[Captures] +tr_cal_id: body regex "(?s).*/caldav/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/" +[Asserts] +body contains "tr-cal" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — Seed the morning event (09:00–10:00 UTC). +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/caldav/{{tr_cal_id}}/tr-morning.ics +Authorization: Bearer {{admin_token}} +Content-Type: text/calendar +``` +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//OxiCloud time-range test//EN +BEGIN:VEVENT +UID:tr-morning +DTSTAMP:20260101T080000Z +DTSTART:20260101T090000Z +DTEND:20260101T100000Z +SUMMARY:Morning event +END:VEVENT +END:VCALENDAR +``` + +HTTP * +[Asserts] +status >= 200 +status < 300 + + +# ───────────────────────────────────────────────────────────── +# Step 5 — Seed the afternoon event (15:00–16:00 UTC). +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/caldav/{{tr_cal_id}}/tr-afternoon.ics +Authorization: Bearer {{admin_token}} +Content-Type: text/calendar +``` +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//OxiCloud time-range test//EN +BEGIN:VEVENT +UID:tr-afternoon +DTSTAMP:20260101T080000Z +DTSTART:20260101T150000Z +DTEND:20260101T160000Z +SUMMARY:Afternoon event +END:VEVENT +END:VCALENDAR +``` + +HTTP * +[Asserts] +status >= 200 +status < 300 + + +# ───────────────────────────────────────────────────────────── +# Step 6 — calendar-query REPORT with iCal-DATE-TIME time-range +# covering the morning event only (08:00 → 13:00 UTC). Post-fix +# the response must contain `tr-morning` and MUST NOT contain +# `tr-afternoon`. +# +# Pre-fix: `time_range` parses as None → falls through to +# `list_events`, response contains BOTH events. This test's +# "body not contains tr-afternoon" assertion catches that. +# ───────────────────────────────────────────────────────────── +REPORT {{base_url}}/caldav/{{tr_cal_id}}/ +Authorization: Bearer {{admin_token}} +Content-Type: application/xml +Depth: 1 +``` + + + + + + + + + + + + + + +``` + +HTTP 207 +[Asserts] +body contains "tr-morning" +body not contains "tr-afternoon" + + +# ───────────────────────────────────────────────────────────── +# Step 7 — Symmetric window: afternoon only (14:00 → 17:00 UTC). +# Guards against a fix that accidentally hardcodes the morning +# window or reverses start/end. +# ───────────────────────────────────────────────────────────── +REPORT {{base_url}}/caldav/{{tr_cal_id}}/ +Authorization: Bearer {{admin_token}} +Content-Type: application/xml +Depth: 1 +``` + + + + + + + + + + + + + + +``` + +HTTP 207 +[Asserts] +body contains "tr-afternoon" +body not contains "tr-morning" + + +# ───────────────────────────────────────────────────────────── +# Step 8 — Window with no overlap (year 2027) returns neither +# event. Proves the filter is actually applied (pre-fix this +# returned both). +# ───────────────────────────────────────────────────────────── +REPORT {{base_url}}/caldav/{{tr_cal_id}}/ +Authorization: Bearer {{admin_token}} +Content-Type: application/xml +Depth: 1 +``` + + + + + + + + + + + + + + +``` + +HTTP 207 +[Asserts] +body not contains "tr-morning" +body not contains "tr-afternoon" + + +# ───────────────────────────────────────────────────────────── +# Step 9 — Sanity: a filter-less REPORT still returns both +# events. Guards against a fix that over-corrects and starts +# treating "no time-range" as "empty window". +# ───────────────────────────────────────────────────────────── +REPORT {{base_url}}/caldav/{{tr_cal_id}}/ +Authorization: Bearer {{admin_token}} +Content-Type: application/xml +Depth: 1 +``` + + + + + + + +``` + +HTTP 207 +[Asserts] +body contains "tr-morning" +body contains "tr-afternoon" + + +# ───────────────────────────────────────────────────────────── +# Step 10 — Cleanup: delete the calendar so downstream test +# files don't inherit an extra collection (per memory +# feedback_hurl_teardown_shared_db). +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/caldav/{{tr_cal_id}}/ +Authorization: Bearer {{admin_token}} + +HTTP 204 diff --git a/tests/api/run.sh b/tests/api/run.sh index 3fc6ff98..e026246e 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -169,6 +169,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test "$API_DIR/contacts.hurl" \ "$API_DIR/calendar.hurl" \ "$API_DIR/caldav_recurring.hurl" \ + "$API_DIR/caldav_calendar_query.hurl" \ "$API_DIR/playlists.hurl" \ "$API_DIR/public_shares.hurl" \ "$API_DIR/permissions.hurl" \