feat(calendar): make external mutations durable

This commit is contained in:
2026-07-20 16:58:45 +02:00
parent 371a00aff5
commit a6cd64e3f9
15 changed files with 4726 additions and 152 deletions

View File

@@ -9,7 +9,8 @@ from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate users/accounts tables
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVObject, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVWriteResult, parse_multistatus
from govoplan_calendar.backend.db.models import CalendarEvent, CalendarSyncCredential
from govoplan_calendar.backend.db.models import CalendarEvent, CalendarOutboxOperation, CalendarSyncCredential
from govoplan_calendar.backend.outbox import dispatch_calendar_outbox
from govoplan_calendar.backend.schemas import CalendarCalDavSourceCreateRequest, CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarEventUpdateRequest
from govoplan_calendar.backend.service import (
CALDAV_INTERNAL_CREDENTIAL_PREFIX,
@@ -226,6 +227,18 @@ END:VCALENDAR</C:calendar-data>
with self.assertRaisesRegex(CalDAVError, "absolute HTTP"):
CalDAVClient(collection_url="/remote.php/dav")
def test_object_url_rejects_off_origin_and_cross_collection_hrefs(self) -> None:
client = CalDAVClient(collection_url="https://dav.example.test/cal")
with self.assertRaisesRegex(CalDAVError, "collection origin"):
client.object_url("https://evil.example.test/steal.ics")
with self.assertRaisesRegex(CalDAVError, "collection path"):
client.object_url("https://dav.example.test/other/steal.ics")
self.assertEqual(
client.object_url("/cal/event.ics"),
"https://dav.example.test/cal/event.ics",
)
class CalDAVSyncTests(unittest.TestCase):
def setUp(self) -> None:
@@ -392,36 +405,39 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual(len(provider.requests), 1)
self.assertEqual(provider.requests[0].event_kind, "calendar.sync.ok")
def test_create_and_update_event_puts_caldav_resource_with_etag(self) -> None:
def test_create_and_update_event_coalesce_to_committed_outbox_state(self) -> None:
session = self.Session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
create_caldav_source(session, tenant_id="tenant-1", user_id=None, payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal"))
session.commit()
fake = FakeCalDAVClient(put_etags=['"etag-1"', '"etag-2"'])
with patch("govoplan_calendar.backend.service.caldav_client_for_source", return_value=fake):
event = create_event(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarEventCreateRequest(
calendar_id=calendar.id,
uid="event-1@example.test",
summary="Planning",
start_at=datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc),
end_at=datetime(2026, 7, 8, 10, 0, tzinfo=timezone.utc),
),
)
update_event(session, tenant_id="tenant-1", user_id=None, event_id=event.id, payload=CalendarEventUpdateRequest(summary="Updated"))
fake = FakeCalDAVClient(put_etags=['"etag-1"'])
event = create_event(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarEventCreateRequest(
calendar_id=calendar.id,
uid="event-1@example.test",
summary="Planning",
start_at=datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc),
end_at=datetime(2026, 7, 8, 10, 0, tzinfo=timezone.utc),
),
)
update_event(session, tenant_id="tenant-1", user_id=None, event_id=event.id, payload=CalendarEventUpdateRequest(summary="Updated"))
self.assertEqual(fake.puts, [])
session.commit()
self.assertEqual(len(fake.puts), 2)
operations = session.query(CalendarOutboxOperation).all()
self.assertCountEqual([operation.status for operation in operations], ["superseded", "pending"])
dispatch_calendar_outbox(session, tenant_id="tenant-1", client_factory=lambda _session, _source: fake)
self.assertEqual(len(fake.puts), 1)
self.assertTrue(fake.puts[0]["create"])
self.assertEqual(fake.puts[1]["etag"], '"etag-1"')
self.assertFalse(fake.puts[1]["create"])
self.assertIn("SUMMARY:Updated", fake.puts[0]["ics"])
self.assertEqual(event.source_kind, "caldav")
self.assertEqual(event.etag, '"etag-2"')
self.assertEqual(event.etag, '"etag-1"')
self.assertIn("SUMMARY:Updated", event.raw_ics or "")
def test_update_event_reports_remote_etag_conflict(self) -> None:
@@ -433,10 +449,22 @@ class CalDAVSyncTests(unittest.TestCase):
session.add(event)
session.commit()
fake = FakeCalDAVClient(fail_precondition=True)
with patch("govoplan_calendar.backend.service.caldav_client_for_source", return_value=fake):
with self.assertRaisesRegex(CalendarError, "changed remotely"):
update_event(session, tenant_id="tenant-1", user_id=None, event_id=event.id, payload=CalendarEventUpdateRequest(summary="Updated"))
fake = FakeCalDAVClient(
fail_precondition=True,
objects={event.source_href: recurring_resource()},
)
update_event(session, tenant_id="tenant-1", user_id=None, event_id=event.id, payload=CalendarEventUpdateRequest(summary="Updated"))
session.commit()
result = dispatch_calendar_outbox(
session,
tenant_id="tenant-1",
client_factory=lambda _session, _source: fake,
)
self.assertEqual(result["failed"], 1)
operation = session.query(CalendarOutboxOperation).one()
self.assertEqual(operation.status, "conflict")
self.assertIn("changed remotely", operation.last_error or "")
def test_delete_one_component_puts_remaining_resource_instead_of_deleting_object(self) -> None:
session = self.Session()
@@ -450,9 +478,9 @@ class CalDAVSyncTests(unittest.TestCase):
session.commit()
fake = FakeCalDAVClient(put_etags=['"etag-2"'])
with patch("govoplan_calendar.backend.service.caldav_client_for_source", return_value=fake):
delete_event(session, tenant_id="tenant-1", event_id=override.id)
delete_event(session, tenant_id="tenant-1", event_id=override.id)
session.commit()
dispatch_calendar_outbox(session, tenant_id="tenant-1", client_factory=lambda _session, _source: fake)
self.assertEqual(len(fake.puts), 1)
self.assertEqual(fake.deletes, [])