mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-17 10:15:27 -04:00
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/<id>/user but events live under .../caldav/v2/<id>/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/<id>/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.
This commit is contained in:
+48
-2
@@ -166,6 +166,52 @@ def _find_existing_event(db, pending, uid_val, calendar_id):
|
|||||||
).first()
|
).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/<id>/user``
|
||||||
|
- legacy: ``https://www.google.com/calendar/dav/<id>/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:
|
def _sync_blocking(owner: str, url: str, username: str, password: str) -> dict:
|
||||||
"""The actual sync — synchronous, intended to run in a threadpool.
|
"""The actual sync — synchronous, intended to run in a threadpool.
|
||||||
Returns counts: {calendars, events, deleted, errors}."""
|
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:
|
except Exception as e:
|
||||||
logger.info(f"CalDAV principal discovery failed, trying URL as calendar: {e}")
|
logger.info(f"CalDAV principal discovery failed, trying URL as calendar: {e}")
|
||||||
try:
|
try:
|
||||||
calendars = [client.calendar(url=url)]
|
calendars = [_open_url_as_calendar(client, url)]
|
||||||
except Exception as e2:
|
except Exception as e2:
|
||||||
result["errors"].append(f"Could not open URL as calendar: {e2}")
|
result["errors"].append(f"Could not open URL as calendar: {e2}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
if not calendars:
|
if not calendars:
|
||||||
try:
|
try:
|
||||||
calendars = [client.calendar(url=url)]
|
calendars = [_open_url_as_calendar(client, url)]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
result["errors"].append(f"No calendars and URL fallback failed: {e}")
|
result["errors"].append(f"No calendars and URL fallback failed: {e}")
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""Google Calendar over CalDAV must surface events, not come back empty (#2507).
|
||||||
|
|
||||||
|
Google's CalDAV principal lives at ``.../caldav/v2/<id>/user`` but events are
|
||||||
|
served from ``.../caldav/v2/<id>/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()
|
||||||
Reference in New Issue
Block a user