From 8159733c6cea6920000b9db0676870e02fbf2816 Mon Sep 17 00:00:00 2001 From: L1 <148907002+davieduard0x01@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:18:16 -0300 Subject: [PATCH] fix(caldav): pull Google Calendar events from the events collection, not the /user principal (#2531) * fix(caldav): pull Google Calendar events from the events collection, not the /user principal Google serves its CalDAV principal at .../caldav/v2//user but events live under .../caldav/v2//events. The caldav library's principal->home-set discovery does not reliably enumerate calendars from Google's /user endpoint, so _sync_blocking fell into its 'treat the URL as a single calendar' fallback and ran every calendar-query REPORT against the principal URL. /user holds no VEVENTs, so the REPORT returned a clean but empty 200 for every date range: auth succeeded, the calendar stayed empty (Apple Calendar works because iCloud exposes standard discovery at the pasted URL). Add _google_caldav_events_url() to map a recognised Google principal URL to its events collection, and route both discovery-less fallbacks through _open_url_as_calendar() so Google syncs hit /events while other servers' URLs are used unchanged. Fixes #2507 * fix(caldav): also map Google's legacy www.google.com/calendar/dav principal URL Some Google accounts authenticate against the older CalDAV endpoint (https://www.google.com/calendar/dav//user) rather than the newer apidata.googleusercontent.com/caldav/v2 form (reported on #2507). Both have the same principal-vs-events split, so map the legacy /user URL to its /events collection as well. The legacy branch is gated on the /calendar/dav/ path so an unrelated www.google.com URL ending in /user is left untouched. --- src/caldav_sync.py | 50 ++++++- tests/test_caldav_google_principal_url.py | 162 ++++++++++++++++++++++ 2 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 tests/test_caldav_google_principal_url.py diff --git a/src/caldav_sync.py b/src/caldav_sync.py index b139dbbb1..578e37041 100644 --- a/src/caldav_sync.py +++ b/src/caldav_sync.py @@ -166,6 +166,52 @@ def _find_existing_event(db, pending, uid_val, calendar_id): ).first() +def _google_caldav_events_url(url: str) -> str | None: + """Map a Google CalDAV *principal* URL to its event-collection URL. + + Google serves the principal at ``…/user`` but events live under ``…/events`` + — the ``/user`` resource holds no VEVENTs. The `caldav` library's + principal→home-set discovery does not reliably enumerate calendars from + Google's ``/user`` endpoint, so the sync falls into the "treat the URL as a + single calendar" fallback below. Pointed at ``/user`` that fallback issues + every calendar-query REPORT against the principal, which returns a clean but + empty 200 for all date ranges — the calendar shows no events even though + auth succeeded (issue #2507). + + Both Google CalDAV endpoint forms are handled, since some accounts only + authenticate against one of them: + - newer: ``https://apidata.googleusercontent.com/caldav/v2//user`` + - legacy: ``https://www.google.com/calendar/dav//user`` + + Returns the events URL for a recognised Google principal URL, else None so + the caller keeps the original URL unchanged. + """ + parts = urlparse(url) + host = (parts.hostname or "").lower() + path = parts.path.rstrip("/") + if not path.endswith("/user"): + return None + is_google = ( + host.endswith("googleusercontent.com") # newer /caldav/v2 form + or (host in ("www.google.com", "google.com") and "/calendar/dav/" in path) # legacy form + ) + if not is_google: + return None + new_path = path[: -len("/user")] + "/events" + return urlunparse(parts._replace(path=new_path)) + + +def _open_url_as_calendar(client, url: str): + """Open ``url`` as a single calendar collection. + + Used when principal discovery yields no calendars. Google's principal URL + is not an event collection, so map it to the events URL first + (see ``_google_caldav_events_url``); other servers' URLs are used as-is. + """ + target = _google_caldav_events_url(url) or url + return client.calendar(url=target) + + def _sync_blocking(owner: str, url: str, username: str, password: str) -> dict: """The actual sync — synchronous, intended to run in a threadpool. Returns counts: {calendars, events, deleted, errors}.""" @@ -192,14 +238,14 @@ def _sync_blocking(owner: str, url: str, username: str, password: str) -> dict: except Exception as e: logger.info(f"CalDAV principal discovery failed, trying URL as calendar: {e}") try: - calendars = [client.calendar(url=url)] + calendars = [_open_url_as_calendar(client, url)] except Exception as e2: result["errors"].append(f"Could not open URL as calendar: {e2}") return result if not calendars: try: - calendars = [client.calendar(url=url)] + calendars = [_open_url_as_calendar(client, url)] except Exception as e: result["errors"].append(f"No calendars and URL fallback failed: {e}") return result diff --git a/tests/test_caldav_google_principal_url.py b/tests/test_caldav_google_principal_url.py new file mode 100644 index 000000000..ce9cefed8 --- /dev/null +++ b/tests/test_caldav_google_principal_url.py @@ -0,0 +1,162 @@ +"""Google Calendar over CalDAV must surface events, not come back empty (#2507). + +Google's CalDAV principal lives at ``.../caldav/v2//user`` but events are +served from ``.../caldav/v2//events``. When the `caldav` library's +principal discovery yields no calendars for Google's ``/user`` endpoint, +``_sync_blocking`` fell back to ``client.calendar(url=url)`` — i.e. it queried +the principal URL itself, which returns a clean but empty 200 for every date +range. Auth succeeded, the calendar stayed empty. + +These tests inject a fake ``caldav`` module that mimics Google's behaviour +(principal discovery returns no calendars; the ``/user`` collection holds no +events; the ``/events`` collection holds one VEVENT) and assert the sync now +maps the principal URL to its events collection and pulls the event. No live +Google account is required. +""" +import sys +import tempfile +import types +from datetime import datetime, timedelta + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import NullPool + +import core.database as cdb +from core.database import CalendarCal, CalendarEvent +from src import caldav_sync + +_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False) +_ENGINE = create_engine( + f"sqlite:///{_TMPDB.name}", + connect_args={"check_same_thread": False}, + poolclass=NullPool, +) +cdb.Base.metadata.create_all(_ENGINE) +_TS = sessionmaker(bind=_ENGINE, autoflush=False, autocommit=False) + +_GOOGLE_PRINCIPAL = "https://apidata.googleusercontent.com/caldav/v2/me@gmail.com/user" +_GOOGLE_EVENTS = "https://apidata.googleusercontent.com/caldav/v2/me@gmail.com/events" + + +def _ics_one_event(): + # An event inside the sync window (now-90d .. now+365d). + dt = datetime.utcnow() + timedelta(days=2) + stamp = dt.strftime("%Y%m%dT%H%M%SZ") + return ( + "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "BEGIN:VEVENT\r\n" + "UID:evt-1@google\r\n" + f"DTSTART:{stamp}\r\n" + f"DTEND:{stamp}\r\n" + "SUMMARY:Standup\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" + ) + + +class _FakeObj: + def __init__(self, data): + self.data = data + + +class _FakeCalendar: + def __init__(self, url): + self.url = url + self.name = "Primary" + + def date_search(self, start, end, expand=False): + # Google's /user principal holds no events; the /events collection does. + if str(self.url).rstrip("/").endswith("/events"): + return [_FakeObj(_ics_one_event())] + return [] + + +class _FakePrincipal: + def calendars(self): + # Simulate Google's /user endpoint yielding no calendars from discovery. + return [] + + +class _FakeClient: + def __init__(self, url=None, username=None, password=None): + self.url = url + + def principal(self): + return _FakePrincipal() + + def calendar(self, url=None): + return _FakeCalendar(url) + + +def _install_fake_caldav(monkeypatch): + fake = types.ModuleType("caldav") + fake.DAVClient = _FakeClient + err = types.ModuleType("caldav.lib.error") + + class AuthorizationError(Exception): + pass + + class NotFoundError(Exception): + pass + + err.AuthorizationError = AuthorizationError + err.NotFoundError = NotFoundError + lib = types.ModuleType("caldav.lib") + lib.error = err + fake.lib = lib + monkeypatch.setitem(sys.modules, "caldav", fake) + monkeypatch.setitem(sys.modules, "caldav.lib", lib) + monkeypatch.setitem(sys.modules, "caldav.lib.error", err) + monkeypatch.setattr(caldav_sync, "SessionLocal", _TS, raising=False) + monkeypatch.setattr(cdb, "SessionLocal", _TS, raising=False) + + +def _clear_db(): + db = _TS() + try: + db.query(CalendarEvent).delete() + db.query(CalendarCal).delete() + db.commit() + finally: + db.close() + + +def test_maps_google_principal_url_to_events_collection(): + assert caldav_sync._google_caldav_events_url(_GOOGLE_PRINCIPAL) == _GOOGLE_EVENTS + # Trailing slash tolerated. + assert caldav_sync._google_caldav_events_url(_GOOGLE_PRINCIPAL + "/") == _GOOGLE_EVENTS + # Non-Google or non-principal URLs are left untouched (None => caller keeps URL). + assert caldav_sync._google_caldav_events_url("https://calendar.example.com/dav") is None + assert caldav_sync._google_caldav_events_url(_GOOGLE_EVENTS) is None + + +def test_maps_legacy_google_calendar_dav_url(): + # Google's older endpoint (some accounts authenticate only against this one). + legacy_user = "https://www.google.com/calendar/dav/me@gmail.com/user" + legacy_events = "https://www.google.com/calendar/dav/me@gmail.com/events" + assert caldav_sync._google_caldav_events_url(legacy_user) == legacy_events + assert caldav_sync._google_caldav_events_url(legacy_user + "/") == legacy_events + # A non-CalDAV www.google.com /user path must NOT be rewritten. + assert caldav_sync._google_caldav_events_url("https://www.google.com/accounts/user") is None + + +def test_google_sync_pulls_events_instead_of_empty(monkeypatch): + _install_fake_caldav(monkeypatch) + _clear_db() + + result = caldav_sync._sync_blocking("alice", _GOOGLE_PRINCIPAL, "me@gmail.com", "app-pw") + + # The fix routes discovery-less Google sync to the /events collection, so + # the VEVENT is pulled. Pre-fix this queried /user and returned 0 events. + assert result["events"] == 1, result + assert not result["errors"], result["errors"] + + db = _TS() + try: + ev = db.query(CalendarEvent).filter(CalendarEvent.uid == "evt-1@google").first() + assert ev is not None and ev.summary == "Standup" + finally: + db.close()