diff --git a/docs/explanation/adapters.rst b/docs/explanation/adapters.rst index ae28cff..1c1c890 100644 --- a/docs/explanation/adapters.rst +++ b/docs/explanation/adapters.rst @@ -85,17 +85,17 @@ A member that comes back without data, as python-caldav returns a 404 entry, or .. _caldav-fingerprint: -Why the fingerprint leaves out three properties +Why the fingerprint leaves out what servers set ----------------------------------------------- -Tested servers rewrite what they store: they add ``DTSTAMP``, reorder properties and parameters, fold lines at other points, and reorder the parts of a recurrence rule, which may come in any order (:rfc:`5545#section-3.3.10`). A server sets ``DTSTAMP`` and ``LAST-MODIFIED`` itself (:rfc:`5545#section-3.8.7.2`, :rfc:`5545#section-3.8.7.3`), and ``PRODID`` names the product that created the object (:rfc:`5545#section-3.7.3`). So the canonical form unfolds the lines, sorts the components, the properties and parameters within each, and the parts of each recurrence rule, and leaves those three properties out. A server's copy of your own write then fingerprints the same as what you sent, on every tested server. ``UID`` is kept, since it's the logical key, and so is everything else, because any other difference can be a real edit. Calendar data is UTF-8 unless a charset says otherwise (:rfc:`5545#section-3.1.4`): python-caldav hands the adapter what a server sent as UTF-8, and the adapter refuses an edit that isn't. +Tested servers rewrite what they store: they add ``DTSTAMP``, reorder properties and parameters, fold lines at other points, and reorder the parts of a recurrence rule, which may come in any order (:rfc:`5545#section-3.3.10`). A server sets ``DTSTAMP`` and ``LAST-MODIFIED`` itself (:rfc:`5545#section-3.8.7.2`, :rfc:`5545#section-3.8.7.3`), and ``PRODID`` names the product that created the object (:rfc:`5545#section-3.7.3`). A server that schedules an event adds a ``SCHEDULE-STATUS`` parameter to its attendees and organizer (:rfc:`6638#section-7.3`), and keeps no ``SCHEDULE-FORCE-SEND`` parameter (:rfc:`6638#section-7.2`). So the canonical form unfolds the lines, sorts the components, the properties and parameters within each, and the parts of each recurrence rule, and leaves out those three properties and those two parameters. A server's copy of your own write then fingerprints the same as what you sent, on every tested server. The price is that an edit changing only those properties or parameters isn't pushed; a client shouldn't change ``SCHEDULE-STATUS`` anyway, and a server doesn't keep ``SCHEDULE-FORCE-SEND``. ``UID`` is kept, since it's the logical key, and so is everything else, because any other difference can be a real edit. Calendar data is UTF-8 unless a charset says otherwise (:rfc:`5545#section-3.1.4`): python-caldav hands the adapter what a server sent as UTF-8, and the adapter refuses an edit that isn't. Why writes are conditional -------------------------- Updates and deletes send ``If-Match`` with the base ETag (:rfc:`9110#section-13.1.1`), so the server applies them only if the member still has that ETag, and creates send ``If-None-Match: *`` (:rfc:`9110#section-13.1.2`), so a create never replaces an existing member. A mismatch gets status 412, and the edit stays pending for the next :term:`ingest`. ``If-Match`` compares ETags strongly, so a weak one (``W/``) never matches; an edit to a member whose stored ETag is weak waits, without being sent, until a sync stores a strong one, and :meth:`~calendaring_sync.adapters.base.SyncAdapter.waiting` lists such edits. The ETag comes from the ``DAV:getetag`` property of the server's answer, not from an HTTP header a proxy can weaken, so edits wait only on a server that reports a weak ETag itself. -Creates and updates use python-caldav's ``save()`` with ``raw_data``, which sends the bytes you recorded unchanged, and ``precondition="etag"``, which sends the stored ETag. Updates also pass ``increase_seqno=False``: ``save()`` increments an event's ``SEQUENCE`` revision number by default, and though it leaves the bytes passed as ``raw_data`` alone, the flag says outright that the server receives the edit you recorded. Creates pass ``no_overwrite=True``, which sends ``If-None-Match: *``. Deletes use ``delete(if_match=True, precondition="etag")``. A 404 on an update or a delete means the member is gone; a tested server answers a delete of a gone member with 412 instead, which leaves the delete pending until the next ingest reports the removal. +Creates and updates use python-caldav's ``save()`` with ``raw_data``, which sends the bytes you recorded unchanged, and ``precondition="etag"``, which sends the stored ETag. Updates also pass ``increase_seqno=False``: ``save()`` increments an event's ``SEQUENCE`` revision number by default, and though it leaves the bytes passed as ``raw_data`` alone, the flag says outright that the server receives the edit you recorded. Creates pass ``no_overwrite=True``, which sends ``If-None-Match: *``. Deletes use ``delete(if_match=True, precondition="etag")``. A 404 or a 410 on an update or a delete means the member is gone (:rfc:`9110#section-15.5.5`, :rfc:`9110#section-15.5.11`); a tested server answers a delete of a gone member with 412 instead, which leaves the delete pending until the next ingest reports the removal. A create uses an href of unreserved characters only (:rfc:`3986#section-2.3`), a UUID and ``.ics``, so it can't come back spelled differently. A server that already holds the UID answers with an error element, ``CALDAV:no-uid-conflict`` (:rfc:`4791#section-5.3.2.1`) or ``CALDAV:unique-scheduling-object-resource`` (:rfc:`6638#section-3.2.4.1`); python-caldav raises ``UidConflictError`` for either, whatever the status, so the adapter never mistakes it for a plain precondition failure. For a create or an update, that's a :term:`conflict` with the object that holds the UID, named when the error element holds its ``DAV:href`` and that href is in the calendar; otherwise the core finds the holder by its UID. @@ -103,6 +103,11 @@ Unless the client is configured with the server's profile, python-caldav searche A server MUST NOT return a strong ETag for data it stored differently (:rfc:`4791#section-5.3.4`), but tested servers do, so an applied push doesn't keep the ETag it got back. +Why a write refused with a 4xx is a conflict +-------------------------------------------- + +A 4xx status says the client seems to have erred (:rfc:`9110#section-15.5`), so the same write would get the same answer. Tested servers answer that way for a calendar you may only read, with a bare 403 or one naming ``DAV:need-privileges`` (:rfc:`3744#section-7.1.1`), and for content a calendar won't store, with a 400, a 415, or a 403 naming a precondition such as ``CALDAV:supported-calendar-component`` (:rfc:`4791#section-1.3`). So a write refused with a 4xx becomes a :term:`conflict` the server didn't allow, which holds the status, the precondition names and the server's explanation as its reason. A member that's gone and a mismatch (412) aren't refusals, as above, and a few 4xx statuses mean something else and are raised instead, leaving the push :term:`in flight` for the next sync: a login (401, 407), and a request worth repeating later (408, :rfc:`9110#section-15.5.9`; 425, :rfc:`8470#section-5.2`; 429, :rfc:`6585#section-4`). A 507 is a conflict too: a write that got it "MUST NOT be repeated until it is requested by a separate user action" (:rfc:`4918#section-11.5`), and your resolution is that action. Any other 5xx is raised. + JMAP ==== @@ -187,7 +192,7 @@ Pages are counted by position, so an event destroyed while the listing runs shif Why every create carries its own UID ------------------------------------ -A server generates a ``uid`` only when a create omits it (draft-ietf-jmap-calendars-33 section 5.9). The adapter refuses a create without one, and a change that would leave a create it hasn't sent yet without one, so a create whose result was lost is recognized when it comes back under a server-assigned id. A create the server refuses because another event holds its ``uid`` (``invalidProperties`` naming ``uid``) becomes a :term:`conflict`; the server doesn't name that event, so the core finds it by the logical key. A tested server refuses an instance whose ``uid`` another instance in the same calendar holds by naming ``calendarIds`` instead, as it does for a calendar that doesn't exist, so the adapter counts that refusal as the same conflict only when the scope holds another event with that ``uid`` in one of the event's calendars, and raises it otherwise. Both tested servers keep an event's ``uid`` when an update sends another one, so a change can't take a ``uid`` either. +A server generates a ``uid`` only when a create omits it (draft-ietf-jmap-calendars-33 section 5.9). The adapter refuses a create without one, and a change that would leave a create it hasn't sent yet without one, so a create whose result was lost is recognized when it comes back under a server-assigned id. A create the server refuses because another event holds its ``uid`` (``invalidProperties`` naming ``uid``) becomes a :term:`conflict`; the server doesn't name that event, so the core finds it by the logical key. A tested server refuses an instance whose ``uid`` another instance in the same calendar holds by naming ``calendarIds`` instead, as it does for a calendar that doesn't exist, so the adapter counts that refusal as the same conflict only when the scope holds another event with that ``uid`` in one of the event's calendars, and otherwise as a write the server won't take. Both tested servers keep an event's ``uid`` when an update sends another one, so a change can't take a ``uid`` either. Why writes use ifInState ------------------------ @@ -196,6 +201,11 @@ A ``/set`` with ``ifInState``, the state the client expects, fails with ``stateM That state covers the whole account, so a rejection means something changed, not necessarily this object. The rest of the sync's pushes would be rejected too, so they aren't sent: every edit stays pending for the next sync, which ingests the change first. Another client's delete moves the state as well, so a delete of an event someone else deleted is rejected before the server would say it's gone; the next ingest reports the removal. When a server does answer ``notFound``, the push ended gone: a change becomes a :term:`conflict` and a delete is done. +Why some SetErrors are conflicts +-------------------------------- + +A ``/set`` refuses one record with a SetError (:rfc:`8620#section-5.3`). Those that say the same write would be refused again make it a :term:`conflict` the server didn't allow, with the error's type and the properties it names as its reason: ``forbidden`` (a write a permissions policy refuses, as a tested server answers for a calendar shared with you to read), ``overQuota``, ``tooLarge``, ``invalidPatch``, ``singleton``, ``invalidProperties`` other than a taken ``uid``, and ``noSupportedScheduleMethods`` (draft-ietf-jmap-calendars-33 section 5.9.2), as does an account that's read-only (``accountReadOnly``, :rfc:`8620#section-3.6.2`). Such a refusal is about the one event, and leaves the state where it was, so the rest of the sync's pushes still go out. Any other error is raised, such as ``rateLimit``, which may pass, and ``serverFail``. + Why the adapter syncs events only --------------------------------- diff --git a/docs/explanation/design.rst b/docs/explanation/design.rst index 9a33ad4..d6b49c8 100644 --- a/docs/explanation/design.rst +++ b/docs/explanation/design.rst @@ -180,7 +180,7 @@ Every :term:`record` is in one of five states at a time: synced, pending, in fli in_flight --> synced: applied in_flight --> removed: delete done in_flight --> pending: rejected - in_flight --> conflict: gone, or UID taken + in_flight --> conflict: gone, UID taken, or not allowed conflict --> synced: resolved: server's copy conflict --> pending: resolved: push it conflict --> removed: resolved: removal @@ -228,6 +228,9 @@ The table lists every transition with its conditions, including these, which the * - in flight - Refused because another object holds its logical key - Conflict + * - in flight + - Not allowed by the server + - Conflict * - in flight - Unknown, or the process stopped - Settled by the next complete ingest @@ -239,7 +242,7 @@ The table lists every transition with its conditions, including these, which the - Pending again, unless another object in the scope still holds the key * - conflict - Resolved - - Synced, pending, or removed, depending on the resolution; a create the server refused is deleted from the store when you discard it or push it as an update to the object holding its logical key + - Synced, pending, or removed, depending on the resolution; a create the server refused is deleted from the store when you keep the server's copy or delete it, or push it as an update to the object holding its logical key * - removed - Remote change: the server holds an object at that id again - Synced with the new content, as the same record @@ -281,7 +284,7 @@ The core classifies each change by looking only at the :term:`synced copy`, the * - ``CONFLICT`` - Both sides acted; a conflict record is stored, of one :class:`~calendaring_sync.objects.conflict.ConflictKind` -Later remote changes update the record's :term:`server's copy`. The conflict resolves itself when the server reaches your edit, goes back to the synced copy, or removes an object you deleted too. An edit refused for its logical key goes back to pending once the object holding that key no longer holds it. +Later remote changes update the record's :term:`server's copy`. The conflict resolves itself when the server reaches your edit, goes back to the synced copy, or removes an object you deleted too. An edit the server refused holds no copy of its own: for a change or a delete, the server's copy is the synced copy, so only another copy, or a removal, is news, and the server reporting the synced copy again leaves the refusal open. An edit refused for its logical key goes back to pending once the object holding that key no longer holds it. One the server didn't allow stays open until you resolve it, since nothing tells the client that the server would take it now, so only you know when sending it again makes sense. The core stores conflicts instead of raising them: an exception for one object would abort every other change in the sync, and a :term:`resolver` called during a sync would run again on every retry. So you resolve them when you choose, outside any sync, each with a :data:`~calendaring_sync.objects.resolution.Resolution`: one with :meth:`~calendaring_sync.replica.Replica.resolve`, or every open one through your :term:`resolver` with :meth:`~calendaring_sync.replica.Replica.resolve_conflicts`. Anything but the server's copy becomes a pending edit over the server's current copy, so its push is checked against the server's current version. :meth:`~calendaring_sync.replica.Replica.resolve` refuses a record whose conflict changed since you read it, so a decision is never applied to news you haven't seen. diff --git a/docs/how-to/conflicts.rst b/docs/how-to/conflicts.rst index 7b69ff5..c82c42d 100644 --- a/docs/how-to/conflicts.rst +++ b/docs/how-to/conflicts.rst @@ -29,3 +29,5 @@ Resolve every conflict with a function :end-before: # end: merge :meth:`~calendaring_sync.replica.Replica.resolve_conflicts` calls your function with each open conflict, and ``None`` leaves one open. It returns a :class:`~calendaring_sync.objects.resolution.ResolvedConflicts` saying which conflicts it resolved, left open, or found changed while your function ran. A :class:`~calendaring_sync.objects.resolution.Merge` needs the fields the adapter reads from content, which :meth:`~calendaring_sync.adapters.base.Adapter.edit_fields` returns. :attr:`Record.conflict ` holds the :term:`server's copy`, and :class:`~calendaring_sync.objects.conflict.ConflictKind` says what each side did. + +An edit the server won't take as you sent it, such as one to a calendar you may only read, is a conflict of kind :attr:`~calendaring_sync.objects.conflict.ConflictKind.NOT_ALLOWED`. Its :attr:`~calendaring_sync.objects.conflict.Conflict.reason` holds the server's explanation, to show to the person who made the edit. It stays open until you resolve it; :class:`~calendaring_sync.objects.resolution.KeepLocal` sends the edit again, for once the server would take it. diff --git a/docs/how-to/errors.rst b/docs/how-to/errors.rst index 37bd457..eaa955c 100644 --- a/docs/how-to/errors.rst +++ b/docs/how-to/errors.rst @@ -15,4 +15,4 @@ Every error calendaring-sync raises on purpose derives from :class:`~calendaring :start-after: # start: sync :end-before: # end: sync -The client library's own errors, such as a refused login, reach you unchanged. Each adapter's class description names them. +Errors from the client library, such as a refused login, reach you unchanged, and each adapter's class description names them. A server refusing one edit isn't among them: that edit becomes a :term:`conflict`, as :doc:`conflicts` explains. diff --git a/docs/how-to/write-an-adapter.rst b/docs/how-to/write-an-adapter.rst index 403d37d..7883e4e 100644 --- a/docs/how-to/write-an-adapter.rst +++ b/docs/how-to/write-an-adapter.rst @@ -29,7 +29,7 @@ Read changes Send edits ========== -:meth:`~calendaring_sync.adapters.base.Adapter._send` maps each answer your protocol gives to a :data:`~calendaring_sync.objects.push.PushOutcome`: here a version mismatch is :class:`~calendaring_sync.objects.push.Rejected`, and an object the server doesn't have is :class:`~calendaring_sync.objects.push.Gone`. Any other error propagates. +:meth:`~calendaring_sync.adapters.base.Adapter._send` maps each answer your protocol gives to a :data:`~calendaring_sync.objects.push.PushOutcome`: here a version mismatch is :class:`~calendaring_sync.objects.push.Rejected`, and an object the server doesn't have is :class:`~calendaring_sync.objects.push.Gone`. Map a refusal about rights or content to :class:`~calendaring_sync.objects.push.NotAllowed`, with the server's explanation as its reason, so the edit becomes a conflict instead of stopping every sync. Any other error propagates. Test it ======= diff --git a/docs/reference/glossary.rst b/docs/reference/glossary.rst index 9144cdb..3417fec 100644 --- a/docs/reference/glossary.rst +++ b/docs/reference/glossary.rst @@ -29,7 +29,7 @@ These are the sync terms calendaring-sync's pages use. :doc:`../explanation/desi To mark a :term:`pending edit` :term:`in flight` before it's pushed, in one step that fails if another process got there first. :ref:`design-states` shows a record's states and what moves it between them. conflict - An object where your :term:`pending edit` and the server disagree: both sides changed it, one side deleted it while the other edited it, or the server refused your create or change because another object holds its :term:`logical key`. calendaring-sync reports it and leaves the decision to you. + An object where your :term:`pending edit` and the server disagree: both sides changed it, one side deleted it while the other edited it, the server refused your create or change because another object holds its :term:`logical key`, or the server won't take your edit as you sent it. calendaring-sync reports it and leaves the decision to you. conflict record What the core stores for one :term:`conflict`: the :term:`synced copy`, your :term:`local edit`, the :term:`server's copy`, and the kind of conflict. It stays open until you resolve it or it resolves itself. diff --git a/docs/reference/guarantees.rst b/docs/reference/guarantees.rst index ef8047e..fbda1c5 100644 --- a/docs/reference/guarantees.rst +++ b/docs/reference/guarantees.rst @@ -33,6 +33,8 @@ Guarantees - :ref:`design-server-answer` * - An object the server removed while you edited it is always a :term:`conflict`, never resolved for you. - :ref:`design-conflicts` + * - An edit the server won't take as it was sent becomes a :term:`conflict`, so it never stops a sync, or the edits after it, and isn't sent again until you resolve it. + - :ref:`design-conflicts` * - A conflict that changed since you read it can't be resolved, so a decision never applies to changes you haven't seen. - :ref:`design-conflicts` * - A server's copy of your own write isn't reported as a change, however the server rewrote it. diff --git a/docs/reference/server-compatibility.rst b/docs/reference/server-compatibility.rst index 8ade2a2..e50d075 100644 --- a/docs/reference/server-compatibility.rst +++ b/docs/reference/server-compatibility.rst @@ -13,7 +13,7 @@ Each table lists how the tested servers behave where sync depends on it, and wha CalDAV ====== -The CalDAV adapter was checked against Radicale 3.8.3, Xandikos 0.4.8, Cyrus 3.13.7, Stalwart 0.16.21 and Baïkal 0.10.1 between 2026-10-10 and 2026-10-12. Its integration tests ran against the same five on 2026-10-23, with python-caldav 3.6.0 and Xandikos 0.4.10, and the rows they added are marked with that date. They last ran on 2026-10-30, against Radicale 3.9.0, Xandikos 0.4.10, Cyrus 3.13.8, Stalwart 1.0.1 and Baïkal 0.10.1, with python-caldav 3.7.0. Entries marked (tester) come from `caldav-server-tester `_, which ran these checks against 22 servers in October 2026; python-caldav publishes its results as compatibility hints (`python-caldav/caldav#822 `_). +The CalDAV adapter was checked against Radicale 3.8.3, Xandikos 0.4.8, Cyrus 3.13.7, Stalwart 0.16.21 and Baïkal 0.10.1 between 2026-10-10 and 2026-10-12. Its integration tests ran against the same five on 2026-10-23, with python-caldav 3.6.0 and Xandikos 0.4.10, and the rows they added are marked with that date. They last ran on 2026-11-07, against Radicale 3.9.0, Xandikos 0.4.10, Cyrus 3.13.8, Stalwart 1.0.1 and Baïkal 0.10.1, with python-caldav 3.7.0. Entries marked (tester) come from `caldav-server-tester `_, which ran these checks against 22 servers in October 2026; python-caldav publishes its results as compatibility hints (`python-caldav/caldav#822 `_). .. list-table:: :header-rows: 1 @@ -76,6 +76,22 @@ The CalDAV adapter was checked against Radicale 3.8.3, Xandikos 0.4.8, Cyrus 3.1 - Cyrus, Stalwart: store what was sent - CCS, Infomaniak: reorder properties; SOGo adds ``CLASS``; Zimbra and Bedework 5 add properties; all return a strong ETag (tester) - Never keeps the ETag a write returns; its :term:`fingerprint` ignores the rewrite + * - Event with an organizer and attendees, created and changed (:rfc:`6638#section-7.2`, :rfc:`6638#section-7.3`) (2026-11-07) + - - Cyrus 3.13.8: adds ``SCHEDULE-STATUS`` to the attendees it schedules (1.1 for one it sent a request to, 3.8 for another user of the server it may not deliver to), and drops ``SCHEDULE-FORCE-SEND`` + - Baïkal 0.10.1: adds ``SCHEDULE-STATUS`` 1.1 to the attendee marked ``SCHEDULE-FORCE-SEND``, and drops that parameter + - Stalwart 1.0.1, Xandikos 0.4.10: drop ``SCHEDULE-FORCE-SEND`` + - Radicale 3.9.0: stores both as sent + - All: keep ``SEQUENCE`` and ``DTSTAMP`` as sent + - Its :term:`fingerprint` leaves both parameters out, so your own write isn't news + * - Write to a calendar you may only read (:rfc:`3744#section-7.1.1`) (2026-11-07) + - - Radicale 3.9.0 (``owner_write`` rights): 403 with a plain-text explanation + - Cyrus 3.13.8 (no rights): 403 with ``need-privileges`` + - Stalwart 1.0.1 (shared to read): 403 with an empty body + - A :term:`conflict` the server didn't allow, with the status and explanation as its reason + * - Content a calendar made for events won't store (:rfc:`4791#section-5.2.3`) (2026-11-07) + - - An event without ``DTSTART``: Radicale 3.9.0 400; Baïkal 0.10.1 415; Xandikos 0.4.10, Cyrus 3.13.8, Stalwart 1.0.1 store it + - A task: Xandikos, Cyrus, Stalwart 403 with ``supported-calendar-component``; Baïkal 403; Radicale stores it + - A conflict the server didn't allow, as above * - Character set of calendar data (:rfc:`5545#section-3.1.4`: UTF-8 by default) - - Xandikos: ``text/calendar`` with no charset - Others: ``charset=utf-8`` @@ -115,7 +131,7 @@ Conditional writes behaved as :rfc:`9110#section-13.1.1` and :rfc:`9110#section- JMAP ==== -The JMAP adapter was checked against Cyrus 3.13.7 and Stalwart 0.16.21 between 2026-10-10 and 2026-10-12. Its integration tests ran against Cyrus 3.13.7 and Stalwart 0.16.27 on 2026-10-25, with calendaring-jmap before its 2.0.0 release, and the rows they added are marked with that date. They last ran on 2026-11-04, against Cyrus 3.13.8 and Stalwart 1.0.1, with calendaring-jmap 2.0.1. +The JMAP adapter was checked against Cyrus 3.13.7 and Stalwart 0.16.21 between 2026-10-10 and 2026-10-12. Its integration tests ran against Cyrus 3.13.7 and Stalwart 0.16.27 on 2026-10-25, with calendaring-jmap before its 2.0.0 release, and the rows they added are marked with that date. They last ran on 2026-11-07, against Cyrus 3.13.8 and Stalwart 1.0.1, with calendaring-jmap 2.0.1. .. list-table:: :header-rows: 1 @@ -163,7 +179,7 @@ The JMAP adapter was checked against Cyrus 3.13.7 and Stalwart 0.16.21 between 2 * - Create without ``version`` (draft-ietf-jmap-calendars-33 section 5.9: the server sets it) - - Cyrus: refused with ``invalidProperties`` - Stalwart: accepted - - Include ``version`` in events you create + - A :term:`conflict` the server didn't allow; include ``version`` in events you create * - Create with a ``uid`` already in the account (draft-ietf-jmap-calendars-33 section 1.4.1) - Both: ``invalidProperties`` on ``uid`` - Stored as a :term:`conflict record`, refused for its UID @@ -171,7 +187,7 @@ The JMAP adapter was checked against Cyrus 3.13.7 and Stalwart 0.16.21 between 2 - - Cyrus: accepted, as is one with the same ``uid`` and no ``recurrenceId``, which the draft doesn't allow (2026-10-29) - Stalwart 1.0.1: in another calendar, accepted; in the same calendar, ``invalidProperties`` on ``calendarIds``, as for a calendar that doesn't exist (2026-10-29) - Stalwart 0.16.27: ``invalidProperties`` on ``uid`` - - Stored as a conflict record where it's refused; a refusal on ``calendarIds`` counts only when another event with that ``uid`` is in one of the event's calendars, and is raised otherwise + - Stored as a conflict record where it's refused; a refusal on ``calendarIds`` counts only when another event with that ``uid`` is in one of the event's calendars, and is a conflict the server didn't allow otherwise * - Update sending another ``uid`` (2026-10-25) - - Both: accepted, and the event keeps its own ``uid`` - Cyrus: afterwards no longer refuses a create with the ``uid`` that was sent, even once both events are deleted @@ -179,7 +195,10 @@ The JMAP adapter was checked against Cyrus 3.13.7 and Stalwart 0.16.21 between 2 * - Event in two calendars (``calendarIds`` with two entries) (2026-10-25) - - Cyrus: ``invalidProperties`` on ``calendarIds`` - Stalwart: stored in both - - Raises the refusal; the push stays in flight until the next sync settles it + - A conflict the server didn't allow, where it's refused + * - Write to a calendar shared with you to read (draft-ietf-jmap-calendars-33 section 5.9) (2026-11-07) + - Stalwart 1.0.1: ``forbidden`` for each create, update and destroy, the state unchanged; the session marks the account ``isReadOnly``, yet ``/set`` answers per event, not with ``accountReadOnly`` + - A conflict the server didn't allow, with ``forbidden`` as its reason; the sync's other pushes still go out * - Update or destroy of an event that's gone - Both: ``notFound`` - A change becomes a conflict and a delete is done; with ``ifInState``, another client's delete is refused for the moved state first, and the next ingest reports the removal diff --git a/news/+not-allowed.feature.rst b/news/+not-allowed.feature.rst new file mode 100644 index 0000000..7550d50 --- /dev/null +++ b/news/+not-allowed.feature.rst @@ -0,0 +1 @@ +A push the server won't take as it was sent, such as a write to a calendar you may only read or content the calendar won't store, ends :class:`~calendaring_sync.objects.push.NotAllowed` and becomes a conflict of kind :attr:`~calendaring_sync.objects.conflict.ConflictKind.NOT_ALLOWED`, with the server's explanation in :attr:`Conflict.reason `, instead of stopping every sync; each adapter maps its protocol's refusals to it. diff --git a/src/calendaring_sync/__init__.py b/src/calendaring_sync/__init__.py index 663396d..b95efa2 100644 --- a/src/calendaring_sync/__init__.py +++ b/src/calendaring_sync/__init__.py @@ -55,6 +55,7 @@ Applied, Gone, LogicalKeyTaken, + NotAllowed, PushOutcome, Rejected, Unknown, @@ -105,6 +106,7 @@ "MemoryKeeper", "MemoryStore", "Merge", + "NotAllowed", "ObjectIdAssignedError", "OpenConflictError", "OtherScopeError", diff --git a/src/calendaring_sync/adapters/caldav.py b/src/calendaring_sync/adapters/caldav.py index d87a648..50fbca8 100644 --- a/src/calendaring_sync/adapters/caldav.py +++ b/src/calendaring_sync/adapters/caldav.py @@ -44,7 +44,14 @@ from calendaring_sync.objects.change import Change, Changed, Removed, Unchanged from calendaring_sync.objects.change_set import ChangeSet from calendaring_sync.objects.edit import EditFields, EditKind, LocalEdit -from calendaring_sync.objects.push import Applied, Gone, LogicalKeyTaken, PushOutcome, Rejected +from calendaring_sync.objects.push import ( + Applied, + Gone, + LogicalKeyTaken, + NotAllowed, + PushOutcome, + Rejected, +) from calendaring_sync.objects.record import Record from calendaring_sync.objects.scope import Scope from calendaring_sync.replica import Replica @@ -63,6 +70,9 @@ # 3.7.3), left out of the fingerprint so a server's copy of your own write # fingerprints the same. _SERVER_SET = frozenset({"DTSTAMP", "LAST-MODIFIED", "PRODID"}) +# Parameters a server that schedules adds to, or takes off, the organizer +# and attendees of your own write (RFC 6638 sections 7.2 and 7.3). +_SERVER_SET_PARAMETERS = frozenset({"SCHEDULE-STATUS", "SCHEDULE-FORCE-SEND"}) # Recurrence rules, whose parts may come in any order (RFC 5545 section 3.3.10). _RECURRENCE_RULES = frozenset({"RRULE", "EXRULE"}) # What the canonical form's outermost level is called; calendar data has @@ -80,7 +90,19 @@ _TOKEN_REFUSAL_FEATURE = "sync-token.invalid-token" # The status a server gives the collection when it cuts a listing short. _TRUNCATED = 507 -_NOT_FOUND = 404 +# The statuses saying the object a write was for is gone (RFC 9110 sections +# 15.5.5 and 15.5.11). +_GONE = frozenset({404, 410}) +# A refused write says the client erred (RFC 9110 section 15.5), so the same +# write would be refused again, except where the status says something else: +# the object is gone, the version moved (section 15.5.13), a login is needed +# (sections 15.5.2 and 15.5.8), or the request is worth repeating later +# (section 15.5.9, RFC 8470 section 5.2, RFC 6585 section 4). +_CLIENT_ERRORS = range(400, 500) +_NOT_A_REFUSAL = _GONE | {401, 407, 408, 412, 425, 429} +# No room to store a write, which mustn't go out again until you ask for it +# again (RFC 4918 section 11.5). +_INSUFFICIENT_STORAGE = 507 # How python-caldav's server profiles describe a server whose refusal of a # token can't be relied on, so its scopes need regular full listings. _UNRELIABLE_TOKEN_REFUSAL = frozenset({"broken", "ungraceful"}) @@ -99,10 +121,10 @@ def _fields(content: bytes) -> _Fields: The fingerprint is SHA-256 of a canonical form: content lines unfolded, components sorted, properties and parameters sorted within each, the - parts of each recurrence rule sorted, the properties a server sets left - out, and every other value as sent. The logical key is the ``UID`` of - the first object: a component in the calendar other than a time zone, - so not one inside an object, such as an alarm. + parts of each recurrence rule sorted, the properties and parameters a + server sets left out, and every other value as sent. The logical key is + the ``UID`` of the first object: a component in the calendar other than + a time zone, so not one inside an object, such as an alarm. Raises: ValueError: If the content isn't UTF-8 or isn't iCalendar data. @@ -136,7 +158,13 @@ def _fields(content: bytes) -> _Fields: uid = str(vText.from_ical(value)) if name in _RECURRENCE_RULES: value = ";".join(sorted(value.split(";"))) - parameters = tuple(sorted((key.upper(), str(item)) for key, item in params.items())) + parameters = tuple( + sorted( + (key.upper(), str(item)) + for key, item in params.items() + if key.upper() not in _SERVER_SET_PARAMETERS + ) + ) stack[-1][1].append((name, parameters, value)) if len(stack) != 1 or not stack[0][2]: raise ValueError("Calendar data must hold whole components") @@ -156,6 +184,30 @@ def _server_fields(content: bytes) -> _Fields: return _Fields(hashlib.sha256(content).hexdigest(), None) +def _not_allowed(error: dav_error.DAVError) -> NotAllowed: + """The outcome of a write the server won't take as it was sent. + + That's a 4xx other than those in ``_NOT_A_REFUSAL``, or a 507. The + reason holds the status, the names of any precondition elements, and + the server's explanation where it's plain text. + + Raises: + DAVError: ``error`` itself, if it's no such refusal. + """ + status = error.status + if status is None or not ( + (status in _CLIENT_ERRORS and status not in _NOT_A_REFUSAL) + or status == _INSUFFICIENT_STORAGE + ): + raise error + reason = " ".join([str(status), *error.preconditions]) + body = error.body.decode("utf-8", "replace") if isinstance(error.body, bytes) else error.body + text = " ".join((body or "").split()) + if text and not text.startswith("<"): + reason = f"{reason}: {text}" + return NotAllowed(reason) + + def _chunks(items: Sequence[str], size: int) -> Iterator[Sequence[str]]: for start in range(0, len(items), size): yield items[start : start + size] @@ -387,6 +439,8 @@ def _send(self, claimed: Record) -> Steps[PushOutcome]: return LogicalKeyTaken(self._holder(exc.href)) except dav_error.ConsistencyError: return Rejected() + except dav_error.DAVError as exc: + return _not_allowed(exc) return Applied() if edit.kind is EditKind.DELETE: try: @@ -395,6 +449,8 @@ def _send(self, claimed: Record) -> Steps[PushOutcome]: return Gone() except dav_error.ETagMismatchError: return Rejected() + except dav_error.DAVError as exc: + return Gone() if exc.status in _GONE else _not_allowed(exc) return Applied() try: yield resource.save(raw_data=edit.content, precondition="etag", increase_seqno=False) @@ -402,10 +458,8 @@ def _send(self, claimed: Record) -> Steps[PushOutcome]: return LogicalKeyTaken(self._holder(exc.href)) except (dav_error.ETagMismatchError, dav_error.ScheduleTagMismatchError): return Rejected() - except dav_error.PutError as exc: - if exc.status == _NOT_FOUND: - return Gone() - raise + except dav_error.DAVError as exc: + return Gone() if exc.status in _GONE else _not_allowed(exc) return Applied() def _holder(self, href: str | None) -> str | None: @@ -433,8 +487,11 @@ class CalDAVAdapter(_CalDAVFlow, SyncAdapter): The calendar is one :term:`scope`. The content :meth:`create` and :meth:`change` take is iCalendar data in UTF-8, and a create's must hold an object with a ``UID``; anything else raises - :class:`ValueError`. A failure python-caldav reports, such as a refused - login, reaches you as its ``caldav.lib.error.DAVError``, unchanged. + :class:`ValueError`. A write the server won't take as it was sent + becomes a :term:`conflict` of kind + :attr:`~calendaring_sync.objects.conflict.ConflictKind.NOT_ALLOWED`. + Any other failure python-caldav reports, such as a refused login, reaches + you as its ``caldav.lib.error.DAVError``, unchanged. Args: replica: Where the sync state is kept. diff --git a/src/calendaring_sync/adapters/jmap.py b/src/calendaring_sync/adapters/jmap.py index fc7bb8a..c9e8e68 100644 --- a/src/calendaring_sync/adapters/jmap.py +++ b/src/calendaring_sync/adapters/jmap.py @@ -37,7 +37,14 @@ from calendaring_sync.objects.change import Change, Changed, Removed from calendaring_sync.objects.change_set import ChangeSet from calendaring_sync.objects.edit import EditFields, EditKind, LocalEdit -from calendaring_sync.objects.push import Applied, Gone, LogicalKeyTaken, PushOutcome, Rejected +from calendaring_sync.objects.push import ( + Applied, + Gone, + LogicalKeyTaken, + NotAllowed, + PushOutcome, + Rejected, +) from calendaring_sync.objects.record import Record, RecordState from calendaring_sync.objects.scope import Scope from calendaring_sync.replica import Replica @@ -140,6 +147,21 @@ # Errors that refuse the state a sync asked from, so the scope is listed again: # the RFC's own, and the one a tested server gives for a state it can't read. _REFUSED_STATE = frozenset({"cannotCalculateChanges", "invalidArguments"}) +# Errors refusing a write as it was sent, which the same write would get again +# (RFC 8620 sections 3.6.2 and 5.3, draft-ietf-jmap-calendars-33 section 5.9.2). +# Others, such as rateLimit and serverFail, may pass, or are a bug, and are raised. +_NOT_ALLOWED = frozenset( + { + "forbidden", + "accountReadOnly", + "overQuota", + "tooLarge", + "invalidPatch", + "invalidProperties", + "singleton", + "noSupportedScheduleMethods", + } +) @dataclass(frozen=True) @@ -258,20 +280,44 @@ def _content(event: dict[str, Any]) -> bytes: return json.dumps(event, separators=(",", ":"), ensure_ascii=False).encode("utf-8") -def _refusal(kind: EditKind, error: JMAPMethodError) -> PushOutcome | None: - """The outcome a server's refusal of a push means, or ``None`` if it's no refusal of one.""" +def _refusal( + kind: EditKind, error: JMAPMethodError, *, uid_held: bool = False +) -> PushOutcome | None: + """The outcome a server's refusal of a push means, or ``None`` if it's no refusal of one. + + ``uid_held`` says that another event in one of the edit's calendars + holds its uid, for a refusal naming ``calendarIds``. + """ if error.error_type == _STATE_MISMATCH: return Rejected() if error.error_type == _NOT_FOUND and kind is not EditKind.CREATE: return Gone() - if _names(error, _UID) and kind is not EditKind.DELETE: + taken = _names(error, _UID) or (uid_held and _names(error, _CALENDAR_IDS)) + if taken and kind is not EditKind.DELETE: return LogicalKeyTaken() + if error.error_type in _NOT_ALLOWED: + # The type and the properties it names are the explanation + # calendaring-jmap gives. + return NotAllowed(" ".join([error.error_type, *_named(error)])) return None +def _named(error: JMAPMethodError) -> list[str]: + """The properties ``error`` names, or none if the server sent anything but a list of strings. + + RFC 8620 section 5.3 makes them a ``String[]``, but calendaring-jmap + passes on whatever the server sent (calendaring-jmap#94), and a string + would match any part of it. + """ + names = error.properties + if isinstance(names, list) and all(isinstance(name, str) for name in names): + return names + return [] + + def _names(error: JMAPMethodError, member: str) -> bool: """Whether ``error`` refuses an event for ``member`` (``invalidProperties`` naming it).""" - return error.error_type == _INVALID_PROPERTIES and member in (error.properties or ()) + return error.error_type == _INVALID_PROPERTIES and member in _named(error) class _JMAPFlow(Adapter): @@ -451,14 +497,12 @@ def _send(self, claimed: Record) -> Steps[PushOutcome]: ) outcome = Applied() except JMAPMethodError as exc: - refused = _refusal(edit.kind, exc) - if ( - refused is None - and edit.kind is not EditKind.DELETE + held = ( + edit.kind is not EditKind.DELETE and _names(exc, _CALENDAR_IDS) and (yield from self._uid_held(edit, claimed.local_key)) - ): - refused = LogicalKeyTaken() + ) + refused = _refusal(edit.kind, exc, uid_held=held) if refused is None: raise # A refused write changes nothing, so the state stays where it was. @@ -492,9 +536,11 @@ class JMAPAdapter(_JMAPFlow, SyncAdapter): JSON. A create's must have a ``uid`` and at least one calendar in ``calendarIds``, and so must a change to a create not sent yet; anything else raises :class:`ValueError`. The server picks a created - event's id, and :meth:`waiting` always returns an empty list. A - failure calendaring-jmap reports, such as a refused login, reaches you - as its + event's id, and :meth:`waiting` always returns an empty list. A write + the server won't take as it was sent becomes a :term:`conflict` of kind + :attr:`~calendaring_sync.objects.conflict.ConflictKind.NOT_ALLOWED`. Any + other failure calendaring-jmap reports, such as a refused login, + reaches you as its :external+calendaring_jmap:py:class:`~calendaring_jmap.error.JMAPError`, unchanged. diff --git a/src/calendaring_sync/objects/conflict.py b/src/calendaring_sync/objects/conflict.py index 95d7aa4..948086d 100644 --- a/src/calendaring_sync/objects/conflict.py +++ b/src/calendaring_sync/objects/conflict.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: 2026 calendaring-sync contributors # SPDX-License-Identifier: AGPL-3.0-or-later -"""The :term:`server's copy` in a :term:`conflict`, kept on its :term:`record` until resolved.""" +"""What the server has or said in a :term:`conflict`, kept on its :term:`record` until resolved.""" from __future__ import annotations @@ -29,11 +29,19 @@ class ConflictKind(StrEnum): #: object holds its :term:`logical key`. Once that object stops holding #: it, the edit is pending again. LOGICAL_KEY_TAKEN = "logical_key_taken" + #: The server won't take your create, change or delete as you sent it, + #: for example because you may only read the object, or because it + #: won't store such content. It stays open until you resolve it. + NOT_ALLOWED = "not_allowed" @dataclass(frozen=True) class Conflict: - """The server's copy of an object, kept where it conflicts with your edit. + """What the server has or said about an object, kept where it conflicts with your edit. + + For most conflicts that's the :term:`server's copy`. For an edit the + server refused, it holds no copy, only what the server said: the + object holding the logical key, or its reason. Together with its record's synced copy and :attr:`Record.edit `, it makes up the @@ -61,9 +69,12 @@ class Conflict: remote_logical_key: The :term:`logical key` of the server's copy, if it has one. opened_at: When the conflict was first stored. - holder: For a create refused because another object holds its + holder: For an edit refused because another object holds its :term:`logical key`, that object's :term:`object id` when the server named it; ``None`` otherwise. + reason: For an edit the server didn't allow, the server's + explanation as the :term:`adapter` reports it, to show to a + person; ``None`` otherwise, or when the server gave none. """ kind: ConflictKind @@ -74,6 +85,7 @@ class Conflict: remote_logical_key: str | None opened_at: datetime holder: str | None = None + reason: str | None = None @classmethod def from_change( diff --git a/src/calendaring_sync/objects/push.py b/src/calendaring_sync/objects/push.py index e6e7ab4..8014cc2 100644 --- a/src/calendaring_sync/objects/push.py +++ b/src/calendaring_sync/objects/push.py @@ -37,10 +37,15 @@ def _deleted(record: Record, now: datetime) -> _Settled | None: def _in_conflict( - record: Record, kind: ConflictKind, now: datetime, holder: str | None = None + record: Record, + kind: ConflictKind, + now: datetime, + *, + holder: str | None = None, + reason: str | None = None, ) -> _Settled: - """A conflict whose server has no copy of the object, opened by a push's outcome.""" - conflict = replace(Conflict.from_change(kind, Removed(), now), holder=holder) + """A conflict that holds no copy of the server's object, opened by a push's outcome.""" + conflict = replace(Conflict.from_change(kind, Removed(), now), holder=holder, reason=reason) opened = replace(record, state=RecordState.CONFLICT, conflict=conflict) return _Settled(opened, FeedEntryKind.CONFLICT_OPENED) @@ -162,7 +167,44 @@ def _check(self, kind: EditKind, *, has_object_id: bool) -> None: raise ValueError("A delete can't be refused for its logical key") def _settled(self, record: Record, now: datetime) -> _Settled | None: - return _in_conflict(record, ConflictKind.LOGICAL_KEY_TAKEN, now, self.holder) + return _in_conflict(record, ConflictKind.LOGICAL_KEY_TAKEN, now, holder=self.holder) + + +@checked_when_built +@dataclass(frozen=True) +class NotAllowed(_Outcome): + """The server won't take the edit as it was sent, so sending it again unchanged won't help. + + For example, you may only read the object, or the server won't store + such content. The edit becomes a :term:`conflict`, which stays open + until you resolve it: keep the server's copy, send new content, delete + the object, or send the edit again once the server would take it. + + Attributes: + reason: The server's explanation, to show to a person, or ``None`` + if it gave none. + + Raises: + TypeError: If the reason isn't a string or ``None``. + LoneSurrogateError: If the reason holds a lone surrogate. + + Examples: + An :term:`adapter` reports a change to an object you may only read, + with what the server said, so your application can tell the person + why their edit didn't go out: + + >>> from calendaring_sync import NotAllowed + >>> NotAllowed("You may only read this calendar").reason + 'You may only read this calendar' + """ + + reason: str | None = None + + def __post_init__(self) -> None: + check_text(reason=self.reason) + + def _settled(self, record: Record, now: datetime) -> _Settled | None: + return _in_conflict(record, ConflictKind.NOT_ALLOWED, now, reason=self.reason) @dataclass(frozen=True) @@ -177,5 +219,5 @@ def _settled(self, record: Record, now: datetime) -> _Settled | None: return None -#: Any one of the five. -PushOutcome: TypeAlias = Applied | Rejected | Gone | LogicalKeyTaken | Unknown +#: Any one of the six. +PushOutcome: TypeAlias = Applied | Rejected | Gone | LogicalKeyTaken | NotAllowed | Unknown diff --git a/src/calendaring_sync/objects/record.py b/src/calendaring_sync/objects/record.py index 47e82d9..748aa6a 100644 --- a/src/calendaring_sync/objects/record.py +++ b/src/calendaring_sync/objects/record.py @@ -21,6 +21,9 @@ _T = TypeVar("_T") +# The conflicts that are a refusal of your edit, so they hold no copy of the server's object. +_REFUSALS = frozenset({ConflictKind.LOGICAL_KEY_TAKEN, ConflictKind.NOT_ALLOWED}) + class RecordState(StrEnum): """The five states a record can be in.""" @@ -192,15 +195,29 @@ def is_create(self) -> bool: @property def is_refused(self) -> bool: - """Whether the server refused this record's create or change for its :term:`logical key`.""" + """Whether the server refused this record's edit. + + That's an edit :attr:`refused for its logical key + `, or any + edit the server didn't allow. The + conflict then holds no copy of the server's object. + """ + return self.conflict is not None and self.conflict.kind in _REFUSALS + + @property + def is_refused_for_key(self) -> bool: + """Whether the server refused this record's create or change for its :term:`logical key`. + + Another object holds that key, and once it stops holding it, the + edit is pending again. + """ return self.conflict is not None and self.conflict.kind is ConflictKind.LOGICAL_KEY_TAKEN @property - def is_refused_change(self) -> bool: - """Whether the server refused this record's change for its :term:`logical key`. + def is_refused_existing(self) -> bool: + """Whether the server refused this record's change or delete of an object it has. - The server then still holds the synced copy, and the conflict holds - no copy of its own. + The server then still holds the synced copy. """ return self.is_refused and not self.is_create @@ -209,8 +226,8 @@ def server_version(self) -> str | None: """The :term:`version` of the server's copy, ``None`` if the store knows none. That's the conflict's while the record is in conflict, but the synced - one for a change the server refused, and ``None`` once the server - removed the object. + one for a change or delete the server refused, and ``None`` once the + server removed the object. """ return self._on_server(self.version, lambda conflict: conflict.remote_version, None) @@ -240,6 +257,6 @@ def _on_server(self, synced: _T, in_conflict: Callable[[Conflict], _T], gone: _T """ if self.state is RecordState.REMOVED: return gone - if self.conflict is not None and not self.is_refused_change: + if self.conflict is not None and not self.is_refused_existing: return in_conflict(self.conflict) return synced diff --git a/src/calendaring_sync/objects/resolution.py b/src/calendaring_sync/objects/resolution.py index 86db738..c97c15d 100644 --- a/src/calendaring_sync/objects/resolution.py +++ b/src/calendaring_sync/objects/resolution.py @@ -24,12 +24,12 @@ def _on_server(record: Record) -> bool: """Whether the server has the object the record's conflict is about.""" - return cast(Conflict, record.conflict).remote_content is not None or record.is_refused_change + return cast(Conflict, record.conflict).remote_content is not None or record.is_refused_existing def _over_server_copy(record: Record, now: datetime) -> Record: """The record synced to the server's current copy, so the next push goes out against it.""" - if record.is_refused_change: + if record.is_refused_existing: # The server kept the synced copy. return replace(record, state=RecordState.SYNCED, edit=None, conflict=None) return record.synced_to(cast(Conflict, record.conflict), now) @@ -64,9 +64,10 @@ def _holder_edit(self, record: Record) -> LocalEdit | None: class KeepServer(_Resolution): """Keep the :term:`server's copy`, and drop your :term:`local edit`. - Where the server has the object, the record takes the server's copy. - Where it doesn't, because the server removed the object or refused your - create, the record is removed, or deleted if the server never had it. + Where the server has the object, the record takes the server's copy, + which for an edit the server refused is the synced copy. Where it + doesn't, because the server removed the object or refused your create, + the record is removed, or deleted if the server never had it. """ def _resolve(self, record: Record, now: datetime) -> _Resolved: @@ -80,12 +81,15 @@ class KeepLocal(_Resolution): """Push your :term:`local edit` as it is, over the server's current copy. Where the server removed the object, a change goes out as a create. An - edit the server refused because another object holds its - :term:`logical key` can't go out unchanged, so this refuses it. + edit the server didn't allow goes out again, for when the server would + now take it, such as once you may write to the object. An edit the + server refused because another object holds its :term:`logical key` + can't go out unchanged, so this refuses it; it's pending again by + itself once that object stops holding the key. """ def _check(self, record: Record) -> None: - if record.is_refused: + if record.is_refused_for_key: raise ValueError("An edit refused for its logical key can't be pushed again unchanged") def _resolve(self, record: Record, now: datetime) -> _Resolved: @@ -156,9 +160,9 @@ class Delete(_Resolution): """Delete the object, whatever the server's copy holds. Where the server has the object, the delete goes out over its current - copy, and for a change the server refused for its :term:`logical key`, - that's the object the change was for. Where the server doesn't have it, - the record is removed, or deleted if the server never had it. + copy, and for a change or delete the server refused, that's the object + the edit was for. Where the server doesn't have it, the record is + removed, or deleted if the server never had it. """ def _resolve(self, record: Record, now: datetime) -> _Resolved: @@ -178,7 +182,7 @@ class UpdateHolder(_Resolution): """ def _check(self, record: Record) -> None: - if not record.is_refused: + if not record.is_refused_for_key: raise ValueError("Only an edit refused for its logical key goes to the key's holder") def _resolve(self, record: Record, now: datetime) -> _Resolved: diff --git a/src/calendaring_sync/replica.py b/src/calendaring_sync/replica.py index 5a82d7d..fa97f64 100644 --- a/src/calendaring_sync/replica.py +++ b/src/calendaring_sync/replica.py @@ -411,7 +411,7 @@ def _apply( record = _claimed_create(change, claimed) in_flight = record is not None and record.state is RecordState.IN_FLIGHT live = _live(record) - if live is not None and self._refresh_conflict(live, change): + if live is not None and self._refresh_conflict(live, change, now): return edit = None if live is None else live.edit synced = live if live is not None and live.content is not None else None @@ -433,15 +433,21 @@ def _apply( ): self._keep_edit(live, change, now, was_conflict=was_conflict, in_flight=in_flight) - def _refresh_conflict(self, live: Record, change: Changed | Removed) -> bool: + def _refresh_conflict(self, live: Record, change: Changed | Removed, now: datetime) -> bool: """Keep a conflict open when the server reports nothing new; say whether it did. - A refused change holds no copy of the server's object, so whatever - the server reports about it is news, a removal included. A report - of the same data only moves the server copy's version. + A report of the same data as the server's copy only moves that + copy's version. For a change or delete the server refused, the + server's copy is the synced copy, so a removal or other data is + news, and the same data leaves the refusal as it is. """ - if live.conflict is None or live.is_refused_change: + if live.conflict is None: return False + if live.is_refused_existing: + if not (isinstance(change, Changed) and same_data(change, live)): + return False + self._store.update_record(live.with_copy(change, now)) + return True if not same_remote(live.conflict, change): return False if isinstance(change, Changed): @@ -547,7 +553,7 @@ def _lift_refusals(self, scope_id: str, freed: set[str], now: datetime) -> None: return for record in self._store.scope_records(scope_id, states=_CONFLICT): key = record.edit.logical_key if record.edit else None - if not record.is_refused or key is None or key not in freed: + if not record.is_refused_for_key or key is None or key not in freed: continue holders = [ other diff --git a/src/calendaring_sync/store/base.py b/src/calendaring_sync/store/base.py index d682b04..c5b3769 100644 --- a/src/calendaring_sync/store/base.py +++ b/src/calendaring_sync/store/base.py @@ -53,6 +53,7 @@ ConflictKind.DELETED_LOCALLY: (frozenset({EditKind.DELETE}), True), ConflictKind.BOTH_CREATED: (frozenset({EditKind.CREATE}), True), ConflictKind.LOGICAL_KEY_TAKEN: (frozenset({EditKind.CREATE, EditKind.CHANGE}), False), + ConflictKind.NOT_ALLOWED: (frozenset(EditKind), False), } @@ -105,9 +106,12 @@ def _check_record(record: Record) -> None: if server_has_it == removed: need = "needs" if server_has_it else "can't hold" raise RecordValueError(f"A {conflict.kind} conflict {need} the server's copy") - # Only a refused edit names the object holding its logical key. + # Only an edit refused for its logical key names the object holding it, + # and only one the server didn't allow gives the server's reason. if conflict.holder is not None and conflict.kind is not ConflictKind.LOGICAL_KEY_TAKEN: raise RecordValueError(f"A {conflict.kind} conflict can't name a holder") + if conflict.reason is not None and conflict.kind is not ConflictKind.NOT_ALLOWED: + raise RecordValueError(f"A {conflict.kind} conflict can't give a reason") def _check_unique(current: StorageTransaction, record: Record) -> None: diff --git a/src/calendaring_sync/store/sqlite.py b/src/calendaring_sync/store/sqlite.py index 71a0f8d..537ff8d 100644 --- a/src/calendaring_sync/store/sqlite.py +++ b/src/calendaring_sync/store/sqlite.py @@ -260,6 +260,7 @@ def _marks(columns: tuple[_Column, ...]) -> str: conflict_remote_logical_key TEXT, conflict_opened_at TEXT, conflict_holder TEXT, + conflict_reason TEXT, claims INTEGER NOT NULL )""", f"""CREATE UNIQUE INDEX {_RECORDS}_by_object_id ON {_RECORDS} (scope, object_id)""", diff --git a/src/calendaring_sync/tests/_caldav_scenarios.py b/src/calendaring_sync/tests/_caldav_scenarios.py index ec042e7..962b48e 100644 --- a/src/calendaring_sync/tests/_caldav_scenarios.py +++ b/src/calendaring_sync/tests/_caldav_scenarios.py @@ -29,12 +29,19 @@ from caldav.davclient import DAVClient from caldav.testing import RadicaleServer, XandikosServer -from calendaring_sync import MemoryStore, RecordState, Replica, StateStore +from calendaring_sync import ( + FeedEntryKind, + KeepServer, + MemoryStore, + RecordState, + Replica, + StateStore, +) from calendaring_sync.adapters.caldav import AsyncCalDAVAdapter, CalDAVAdapter from calendaring_sync.error import IncompleteListingError, ScopeGoneError from calendaring_sync.objects.conflict import ConflictKind from calendaring_sync.tests import _scenarios -from calendaring_sync.tests._content import ics +from calendaring_sync.tests._content import ICS_START, ics from calendaring_sync.tests._scenarios import ( REPLAY, Login, @@ -76,11 +83,21 @@ class Server: #: Whether the server refuses a wrong password; embedded ones and #: Cyrus' fixture let anyone in. checks_passwords: bool = False + #: The account's own calendar address, which a server schedules from. + address: str = "user1@example.com" + #: Whether the server refuses a task in a calendar made for events only; + #: one that stores it refuses an event without a start instead. + refuses_tasks: bool = True SERVERS = by_name( - Server("radicale", "/user1/", "http://example.invalid/never-issued"), - Server("xandikos", "/sometestuser/calendars/", "http://example.invalid/never-issued"), + Server("radicale", "/user1/", "http://example.invalid/never-issued", refuses_tasks=False), + Server( + "xandikos", + "/sometestuser/calendars/", + "http://example.invalid/never-issued", + address="sometestuser@example.com", + ), Server( "cyrus", "/dav/calendars/user/user1/", @@ -93,6 +110,7 @@ class Server: "http://example.invalid/never-issued", login=("http://localhost:8809", "testuser@example.org", "testcaldav"), checks_passwords=True, + address="testuser@example.org", ), # Baïkal takes a well-formed token past its newest as valid, and # answers it with no changes; only another form is refused. @@ -103,6 +121,7 @@ class Server: delete_gone_is_404=False, login=("http://127.0.0.1:8811", "test", "test"), checks_passwords=True, + address="test@example.org", ), ) _EMBEDDED = {"radicale": RadicaleServer, "xandikos": XandikosServer} @@ -157,6 +176,10 @@ def remove(self, uid: str) -> None: self._member(uid).delete() +# The method and body marker of a delete's push, which has no body. +_DELETE = ("DELETE", "") + + def edit_a_changed_behind(env: Env) -> int: """:func:`edit_a`, with the second client changing ``a`` right before the push.""" key = edit_a(env) @@ -169,10 +192,15 @@ def rejected(env: Env) -> None: rejected_then_conflict(env, edit_a_changed_behind(env), ConflictKind.BOTH_CHANGED) +def delete_a_removed_behind(env: Env) -> int: + """:func:`_scenarios.delete_a_removed_behind`, their delete right before the push.""" + return _scenarios.delete_a_removed_behind(env, _DELETE) + + def delete_gone(env: Env) -> None: """:func:`_scenarios.delete_gone`; a server that answers 404 has the record removed at once.""" first = RecordState.REMOVED if env.server.delete_gone_is_404 else RecordState.PENDING - _scenarios.delete_gone(env, ("DELETE", ""), first) + _scenarios.delete_gone(env, _DELETE, first) def uid_taken(env: Env) -> None: @@ -223,6 +251,65 @@ def fetch_after_delete(env: Env) -> None: assert env.record("a").state is RecordState.REMOVED +# The summary of content a server won't store, which marks its push. +_NOT_STORED = "not stored" + + +def _not_stored(env: Env, name: str) -> bytes: + """Content the server won't store in a calendar for events. + + That's a task, or an event without a start where the server stores tasks. + """ + content = env.content(name, _NOT_STORED) + if env.server.refuses_tasks: + return content.replace(b"VEVENT", b"VTODO") + return content.replace(ICS_START.encode(), b"") + + +def not_allowed(env: Env) -> None: + """A change and a create the server won't store become conflicts, and aren't sent again.""" + changed = env.synced("a") + env.change(changed, _not_stored(env, "a")) + created = env.create(_not_stored(env, "c")) + env.sync() + for key in (changed, created): + conflict = env.at(key).conflict + assert conflict is not None and conflict.kind is ConflictKind.NOT_ALLOWED + assert conflict.reason + env.never("PUT", f"SUMMARY:{_NOT_STORED}", "An edit the server didn't allow went out again") + env.sync() + assert env.conflict_kind(changed) is env.conflict_kind(created) is ConflictKind.NOT_ALLOWED + env.replica.resolve(env.at(changed), KeepServer()) + env.replica.resolve(env.at(created), KeepServer()) + assert env.summary("a") == "one" + assert env.replica.record(created) is None + + +def own_write_with_attendees(env: Env) -> None: + """An event you organize comes back as your own write. + + That holds whatever the server adds to it to schedule it. + """ + env.sync() + address = env.server.address + people = ( + f"ORGANIZER:mailto:{address}", + f"ATTENDEE;PARTSTAT=ACCEPTED:mailto:{address}", + "ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:someone@example.net", + ) + key = env.create(ics(env.uid("m"), "meeting", *people)) + head = env.replica.feed_head() + # Each push, then the sync that reads the server's copy of it. + env.sync() + env.sync() + env.change(key, ics(env.uid("m"), "meeting moved", *people)) + env.sync() + env.sync() + kinds = [entry.kind for entry in env.replica.read_feed(head) if entry.local_key == key] + assert kinds == [FeedEntryKind.PUSHED, FeedEntryKind.PUSHED] + assert env.at(key).state is RecordState.SYNCED + + SCENARIOS: dict[str, Callable[[Env], None]] = by_name( reading, pushing, @@ -234,6 +321,8 @@ def fetch_after_delete(env: Env) -> None: listing, listing_cut_short, fetch_after_delete, + not_allowed, + own_write_with_attendees, key="__name__", ) @@ -247,11 +336,14 @@ def fetch_after_delete(env: Env) -> None: ("radicale", "invalid_token"), ("radicale", "scope_gone"), ("radicale", "listing"), + ("radicale", "not_allowed"), ("xandikos", "reading"), ("xandikos", "invalid_token"), ("xandikos", "fetch_after_delete"), ("cyrus", "reading"), ("cyrus", "invalid_token"), + ("cyrus", "not_allowed"), + ("cyrus", "own_write_with_attendees"), ("stalwart", "reading"), ("stalwart", "listing_cut_short"), ("baikal", "delete_gone"), @@ -278,6 +370,17 @@ def adapter_class(asynchronous: bool) -> type[CalDAVAdapter] | type[AsyncCalDAVA return AsyncCalDAVAdapter if asynchronous else CalDAVAdapter +# What every scenario's calendar is made for: events only (RFC 4791 section 5.3.1). +_EVENTS_ONLY = ( + '' + '' + "" + '' + "" + "" +) + + def calendar_path(server: Server, scenario: str) -> str: return f"{server.home}calendaring-sync-{scenario.replace('_', '-')}/" @@ -306,7 +409,7 @@ def environment( other = Calendar(client=other_client, url=url) # Start from an empty calendar; one left from an earlier recording goes. other_client.request(url, "DELETE") - other_client.request(url, "MKCALENDAR") + other_client.request(url, "MKCALENDAR", _EVENTS_ONLY, {"Content-Type": "application/xml"}) replica = Replica(MemoryStore() if store is None else store) client = caldav_client(asynchronous, url=base_url, username=username, password=password, **auth) client.session = tape.session(client.session, asynchronous=asynchronous) diff --git a/src/calendaring_sync/tests/_content.py b/src/calendaring_sync/tests/_content.py index 039464a..800028d 100644 --- a/src/calendaring_sync/tests/_content.py +++ b/src/calendaring_sync/tests/_content.py @@ -7,6 +7,9 @@ from typing import Any +#: The start of every event :func:`ics` builds, one content line. +ICS_START = "DTSTART:20261101T100000Z\r\n" + def ics(uid: str, summary: str, *lines: str) -> bytes: """A small event in iCalendar form, with ``lines`` added to its ``VEVENT``.""" @@ -14,7 +17,7 @@ def ics(uid: str, summary: str, *lines: str) -> bytes: return ( "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//calendaring-sync tests//EN\r\n" f"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20261001T000000Z\r\n" - f"DTSTART:20261101T100000Z\r\nSUMMARY:{summary}\r\n{added}END:VEVENT\r\nEND:VCALENDAR\r\n" + f"{ICS_START}SUMMARY:{summary}\r\n{added}END:VEVENT\r\nEND:VCALENDAR\r\n" ).encode() diff --git a/src/calendaring_sync/tests/_helpers.py b/src/calendaring_sync/tests/_helpers.py index 9711c35..83f4e6e 100644 --- a/src/calendaring_sync/tests/_helpers.py +++ b/src/calendaring_sync/tests/_helpers.py @@ -23,6 +23,7 @@ FeedEntryKind, LocalEdit, LogicalKeyTaken, + NotAllowed, PushOutcome, Record, RecordState, @@ -49,6 +50,8 @@ _FIELDS: dict[str, Any] = {"containers": frozenset({"x", "y"}), "logical_key": "u"} # A string no store can keep: a lone surrogate. _BAD_TEXT = "a\ud800b" +# The reason the server gives for an edit it doesn't allow. +_NOT_ALLOWED = "403 Forbidden" # Building change sets and syncing them. @@ -268,11 +271,17 @@ def _in_conflict( ) -> int: """Open a conflict of ``kind`` in a seeded scope; return its local key. - A conflict about a create is at the id n, and a refused edit, the - ``refused`` kind of edit, names b as the object holding its logical key u. + A conflict about a create is at the id n. A refused edit is the + ``refused`` kind of edit: one refused for its logical key u names b as + the object holding it, and one not allowed gives ``_NOT_ALLOWED`` as + the reason. """ if kind is ConflictKind.LOGICAL_KEY_TAKEN: return _refused_create(replica, "b", since, token, local=refused) + if kind is ConflictKind.NOT_ALLOWED: + key, _ = _edit_locally(replica, refused) + _push(replica, key, NotAllowed(_NOT_ALLOWED), since, token) + return key local, remote = _OPENED_BY[kind] key, object_id = _edit_locally(replica, local, object_id) _sync(replica, _page(since, token, {object_id: _remote(remote)})) diff --git a/src/calendaring_sync/tests/_jmap_scenarios.py b/src/calendaring_sync/tests/_jmap_scenarios.py index c79197c..1c18f97 100644 --- a/src/calendaring_sync/tests/_jmap_scenarios.py +++ b/src/calendaring_sync/tests/_jmap_scenarios.py @@ -85,6 +85,9 @@ class Server: needs_version: bool = False #: Whether it keeps an event in two calendars. keeps_two_calendars: bool = False + #: A second user of its fixture, whom the first can share a calendar + #: with; ``None`` where the fixture has none. + reader: Login | None = None SERVERS = by_name( @@ -102,6 +105,7 @@ class Server: instances_share_uid=False, checks_passwords=True, keeps_two_calendars=True, + reader=(f"http://localhost:8809{SESSION_PATH}", "reader@example.org", "readerpw"), ), ) @@ -362,6 +366,27 @@ def adapter_class(asynchronous: bool) -> type[JMAPAdapter] | type[AsyncJMAPAdapt return AsyncJMAPAdapter if asynchronous else JMAPAdapter +def adapter_for( + replica: Replica, + login: Login, + account: str, + tape: Tape, + *, + asynchronous: bool, + **options: Any, +) -> tuple[Any, Callable[[], Any]]: + """An adapter of ``replica`` on ``account``, through a new client logged in as ``login``. + + It comes with how to close that client. The client goes through + ``tape``, and ``options`` go to the adapter. + """ + url, username, password = login + client = jmap_client(asynchronous, url=url, username=username, password=password) + attach(tape, client, asynchronous=asynchronous) + adapter = adapter_class(asynchronous)(replica, client, account_id=account, **options) + return adapter, client.aclose if asynchronous else client.close + + def attach(tape: Tape, client: Any, *, asynchronous: bool = False) -> None: """Route a calendaring-jmap client's requests through the tape, its session's too.""" through_tape = tape.session(niquests.Session()) @@ -411,13 +436,11 @@ def environment( account = other.get_account_id() calendar = other.get_calendars(account_id=account)[0].id _clear(other, account) - client = jmap_client(asynchronous, url=url, username=username, password=password) - attach(tape, client, asynchronous=asynchronous) replica = Replica(MemoryStore() if store is None else store) - adapter = adapter_class(asynchronous)(replica, client, account_id=account) + adapter, close = adapter_for(replica, login, account, tape, asynchronous=asynchronous) with stepper(asynchronous) as run_step: try: yield Env(server, scenario, tape, adapter, run_step, other, account, calendar) finally: - run_step(client.aclose() if asynchronous else client.close()) + run_step(close()) other.close() diff --git a/src/calendaring_sync/tests/_scenarios.py b/src/calendaring_sync/tests/_scenarios.py index 22e389f..ce9455a 100644 --- a/src/calendaring_sync/tests/_scenarios.py +++ b/src/calendaring_sync/tests/_scenarios.py @@ -231,16 +231,25 @@ def pushing(env: Env) -> None: assert env.at(key).state is RecordState.REMOVED -def delete_gone(env: Env, request: tuple[str, str], first: RecordState) -> None: - """A delete of an object someone else deleted ends with the record removed. +def delete_a_removed_behind(env: Env, request: tuple[str, str]) -> int: + """Sync ``a``, delete it here, and return its key; the second client deletes it first. - The second client deletes it right before the first request whose method - and body marker are ``request``; the sync sending it leaves the record - ``first``, and the next one removes it. + It does so right before the first request whose method and body marker + are ``request``. """ key = env.synced("a") env.replica.delete(key) env.tape.before(*request, lambda: env.remove("a")) + return key + + +def delete_gone(env: Env, request: tuple[str, str], first: RecordState) -> None: + """A delete of an object someone else deleted ends with the record removed. + + The sync sending it, after the second client's delete, leaves the record + ``first``, and the next one removes it. + """ + key = delete_a_removed_behind(env, request) env.sync() assert env.at(key).state is first env.sync() diff --git a/src/calendaring_sync/tests/data/caldav/cyrus-not_allowed.json b/src/calendaring_sync/tests/data/caldav/cyrus-not_allowed.json new file mode 100644 index 0000000..38b4bd9 --- /dev/null +++ b/src/calendaring_sync/tests/data/caldav/cyrus-not_allowed.json @@ -0,0 +1,144 @@ +{ + "server": "cyrus", + "scenario": "not_allowed", + "caldav": "3.7.0", + "recorded": "2026-11-07", + "exchanges": [ + { + "method": "DELETE", + "path": "/dav/calendars/user/user1/calendaring-sync-not-allowed/", + "status": 204, + "headers": {}, + "body": "", + "request": "" + }, + { + "method": "MKCALENDAR", + "path": "/dav/calendars/user/user1/calendaring-sync-not-allowed/", + "status": 201, + "headers": {}, + "body": "", + "request": "" + }, + { + "method": "PUT", + "path": "/dav/calendars/user/user1/calendaring-sync-not-allowed/a.ics", + "status": 201, + "headers": { + "etag": "\"2d1467c25eff5bddd0c25e6ad9df46df3104cb1b\"" + }, + "body": "", + "request": "" + }, + { + "method": "PROPFIND", + "path": "/dav/calendars/user/user1/calendaring-sync-not-allowed/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n /dav/calendars/user/user1/calendaring-sync-not-allowed/\n \n \n data:,1794033840-433\n \n HTTP/1.1 200 OK\n \n \n\n", + "request": "" + }, + { + "method": "PROPFIND", + "path": "/dav/calendars/user/user1/calendaring-sync-not-allowed/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n /dav/calendars/user/user1/calendaring-sync-not-allowed/\n \n \n \"1794033840-1-1\"\n \n HTTP/1.1 200 OK\n \n \n \n /dav/calendars/user/user1/calendaring-sync-not-allowed/a.ics\n \n \n \"2d1467c25eff5bddd0c25e6ad9df46df3104cb1b\"\n \n HTTP/1.1 200 OK\n \n \n\n", + "request": "" + }, + { + "method": "REPORT", + "path": "/dav/calendars/user/user1/calendaring-sync-not-allowed/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n /dav/calendars/user/user1/calendaring-sync-not-allowed/a.ics\n \n \n \n \"2d1467c25eff5bddd0c25e6ad9df46df3104cb1b\"\n \n HTTP/1.1 200 OK\n \n \n \n \n \n HTTP/1.1 404 Not Found\n \n \n\n", + "request": "" + }, + { + "method": "PROPFIND", + "path": "/dav/calendars/user/user1/calendaring-sync-not-allowed/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n /dav/calendars/user/user1/calendaring-sync-not-allowed/\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n HTTP/1.1 200 OK\n \n \n\n", + "request": "" + }, + { + "method": "REPORT", + "path": "/dav/calendars/user/user1/calendaring-sync-not-allowed/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n data:,1794033840-433\n\n", + "request": "" + }, + { + "method": "PUT", + "path": "/dav/calendars/user/user1/calendaring-sync-not-allowed/a.ics", + "status": 403, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n\n", + "request": "" + }, + { + "method": "REPORT", + "path": "/dav/calendars/user/user1/calendaring-sync-not-allowed/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n\n", + "request": "" + }, + { + "method": "REPORT", + "path": "/dav/calendars/user/user1/calendaring-sync-not-allowed/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n\n", + "request": "" + }, + { + "method": "REPORT", + "path": "/dav/calendars/user/user1/calendaring-sync-not-allowed/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n\n", + "request": "" + }, + { + "method": "PUT", + "path": "/dav/calendars/user/user1/calendaring-sync-not-allowed/00000000-0000-0000-0000-000000000001.ics", + "status": 403, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n\n", + "request": "" + }, + { + "method": "REPORT", + "path": "/dav/calendars/user/user1/calendaring-sync-not-allowed/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n data:,1794033840-433\n\n", + "request": "" + } + ] +} diff --git a/src/calendaring_sync/tests/data/caldav/cyrus-own_write_with_attendees.json b/src/calendaring_sync/tests/data/caldav/cyrus-own_write_with_attendees.json new file mode 100644 index 0000000..1e3e89b --- /dev/null +++ b/src/calendaring_sync/tests/data/caldav/cyrus-own_write_with_attendees.json @@ -0,0 +1,166 @@ +{ + "server": "cyrus", + "scenario": "own_write_with_attendees", + "caldav": "3.7.0", + "recorded": "2026-11-07", + "exchanges": [ + { + "method": "DELETE", + "path": "/dav/calendars/user/user1/calendaring-sync-own-write-with-attendees/", + "status": 404, + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "body": "\n\n \n 404 Not Found\n \n \n

Not Found

\n

Mailbox does not exist

\n
\n
Cyrus-HTTP/3.13.8-115-gb2ee17d30 Cyrus-SASL/2.1.28 LibXML/2.9.14 Jansson/2.14 OpenSSL/3.0.22 Nghttp2/1.52.0 Wslay/1.0.1-DEV Zlib/1.2.13 Xapian/2.1.0 LibiCal/4.0 ICU4C/78.2 SQLite/3.40.1 Server at localhost Port 8802
\n \n\n", + "request": "" + }, + { + "method": "MKCALENDAR", + "path": "/dav/calendars/user/user1/calendaring-sync-own-write-with-attendees/", + "status": 201, + "headers": {}, + "body": "", + "request": "" + }, + { + "method": "PROPFIND", + "path": "/dav/calendars/user/user1/calendaring-sync-own-write-with-attendees/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n /dav/calendars/user/user1/calendaring-sync-own-write-with-attendees/\n \n \n data:,1794033990-437\n \n HTTP/1.1 200 OK\n \n \n\n", + "request": "" + }, + { + "method": "PROPFIND", + "path": "/dav/calendars/user/user1/calendaring-sync-own-write-with-attendees/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n /dav/calendars/user/user1/calendaring-sync-own-write-with-attendees/\n \n \n \"1794033990-0-0\"\n \n HTTP/1.1 200 OK\n \n \n\n", + "request": "" + }, + { + "method": "PROPFIND", + "path": "/dav/calendars/user/user1/calendaring-sync-own-write-with-attendees/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n /dav/calendars/user/user1/calendaring-sync-own-write-with-attendees/\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n HTTP/1.1 200 OK\n \n \n\n", + "request": "" + }, + { + "method": "REPORT", + "path": "/dav/calendars/user/user1/calendaring-sync-own-write-with-attendees/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n data:,1794033990-437\n\n", + "request": "" + }, + { + "method": "REPORT", + "path": "/dav/calendars/user/user1/calendaring-sync-own-write-with-attendees/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n\n", + "request": "" + }, + { + "method": "REPORT", + "path": "/dav/calendars/user/user1/calendaring-sync-own-write-with-attendees/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n\n", + "request": "" + }, + { + "method": "REPORT", + "path": "/dav/calendars/user/user1/calendaring-sync-own-write-with-attendees/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n\n", + "request": "" + }, + { + "method": "PUT", + "path": "/dav/calendars/user/user1/calendaring-sync-own-write-with-attendees/00000000-0000-0000-0000-000000000001.ics", + "status": 201, + "headers": { + "schedule-tag": "\"da39a3ee5e6b4b0d3255bfef95601890afd80709\"" + }, + "body": "", + "request": "" + }, + { + "method": "REPORT", + "path": "/dav/calendars/user/user1/calendaring-sync-own-write-with-attendees/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n /dav/calendars/user/user1/calendaring-sync-own-write-with-attendees/00000000-0000-0000-0000-000000000001.ics\n \n \n \"217f6a12027d17b40328a817388ea680168bd6a2\"\n \n HTTP/1.1 200 OK\n \n \n data:,1794033990-441\n\n", + "request": "" + }, + { + "method": "REPORT", + "path": "/dav/calendars/user/user1/calendaring-sync-own-write-with-attendees/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n /dav/calendars/user/user1/calendaring-sync-own-write-with-attendees/00000000-0000-0000-0000-000000000001.ics\n \n \n \n \"217f6a12027d17b40328a817388ea680168bd6a2\"\n \"3ad857f811056e61fdfd2b6ebc4959adec0369d2\"\n \n HTTP/1.1 200 OK\n \n \n\n", + "request": "" + }, + { + "method": "REPORT", + "path": "/dav/calendars/user/user1/calendaring-sync-own-write-with-attendees/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n data:,1794033990-441\n\n", + "request": "" + }, + { + "method": "PUT", + "path": "/dav/calendars/user/user1/calendaring-sync-own-write-with-attendees/00000000-0000-0000-0000-000000000001.ics", + "status": 204, + "headers": { + "schedule-tag": "\"7a31057b6e44ebcd7fe910d407d98e8ab97ad835\"" + }, + "body": "", + "request": "" + }, + { + "method": "REPORT", + "path": "/dav/calendars/user/user1/calendaring-sync-own-write-with-attendees/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n /dav/calendars/user/user1/calendaring-sync-own-write-with-attendees/00000000-0000-0000-0000-000000000001.ics\n \n \n \"dc450f05e1572dbe7734fa745e9503226d71370e\"\n \n HTTP/1.1 200 OK\n \n \n data:,1794033990-445\n\n", + "request": "" + }, + { + "method": "REPORT", + "path": "/dav/calendars/user/user1/calendaring-sync-own-write-with-attendees/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n /dav/calendars/user/user1/calendaring-sync-own-write-with-attendees/00000000-0000-0000-0000-000000000001.ics\n \n \n \n \"dc450f05e1572dbe7734fa745e9503226d71370e\"\n \"3ad857f811056e61fdfd2b6ebc4959adec0369d2\"\n \n HTTP/1.1 200 OK\n \n \n\n", + "request": "" + } + ] +} diff --git a/src/calendaring_sync/tests/data/caldav/radicale-not_allowed.json b/src/calendaring_sync/tests/data/caldav/radicale-not_allowed.json new file mode 100644 index 0000000..85473de --- /dev/null +++ b/src/calendaring_sync/tests/data/caldav/radicale-not_allowed.json @@ -0,0 +1,149 @@ +{ + "server": "radicale", + "scenario": "not_allowed", + "caldav": "3.7.0", + "recorded": "2026-11-07", + "exchanges": [ + { + "method": "DELETE", + "path": "/user1/calendaring-sync-not-allowed/", + "status": 404, + "headers": { + "content-type": "text/plain; charset=utf-8" + }, + "body": "The requested resource could not be found.", + "request": "" + }, + { + "method": "MKCALENDAR", + "path": "/user1/calendaring-sync-not-allowed/", + "status": 201, + "headers": {}, + "body": "", + "request": "" + }, + { + "method": "PUT", + "path": "/user1/calendaring-sync-not-allowed/a.ics", + "status": 201, + "headers": { + "etag": "\"66c9e04a974ab1680ff2d305d490ad4eeb998d5506aa186ade1acf1f3fe2e183\"" + }, + "body": "", + "request": "" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-not-allowed/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-not-allowed/http://radicale.org/ns/sync/01b4f6ee4f2315490bc3c8b16805af2c908aba1d9d518bccd20b9839959e62f7HTTP/1.1 200 OK", + "request": "" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-not-allowed/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-not-allowed/\"c76bf8a47baf5c8a28feb8d6de3fa91cbdc03d03a9d8a0373addb636ce12eb76\"HTTP/1.1 200 OK/user1/calendaring-sync-not-allowed/a.ics\"66c9e04a974ab1680ff2d305d490ad4eeb998d5506aa186ade1acf1f3fe2e183\"HTTP/1.1 200 OK", + "request": "" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-not-allowed/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-not-allowed/a.icsBEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//calendaring-sync tests//EN\r\nBEGIN:VEVENT\r\nUID:not_allowed-a\r\nDTSTART:20261101T100000Z\r\nDTSTAMP:20261001T000000Z\r\nSUMMARY:one\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n\"66c9e04a974ab1680ff2d305d490ad4eeb998d5506aa186ade1acf1f3fe2e183\"HTTP/1.1 200 OKHTTP/1.1 404 Not Found", + "request": "" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-not-allowed/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-not-allowed/HTTP/1.1 200 OK", + "request": "" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-not-allowed/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\nhttp://radicale.org/ns/sync/01b4f6ee4f2315490bc3c8b16805af2c908aba1d9d518bccd20b9839959e62f7", + "request": "" + }, + { + "method": "PUT", + "path": "/user1/calendaring-sync-not-allowed/a.ics", + "status": 400, + "headers": { + "content-type": "text/plain; charset=utf-8" + }, + "body": "Bad Request", + "request": "" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-not-allowed/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n", + "request": "" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-not-allowed/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n", + "request": "" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-not-allowed/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n", + "request": "" + }, + { + "method": "PUT", + "path": "/user1/calendaring-sync-not-allowed/00000000-0000-0000-0000-000000000001.ics", + "status": 400, + "headers": { + "content-type": "text/plain; charset=utf-8" + }, + "body": "Bad Request", + "request": "" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-not-allowed/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\nhttp://radicale.org/ns/sync/01b4f6ee4f2315490bc3c8b16805af2c908aba1d9d518bccd20b9839959e62f7", + "request": "" + } + ] +} diff --git a/src/calendaring_sync/tests/test_caldav.py b/src/calendaring_sync/tests/test_caldav.py index d5b1b8b..63aa36a 100644 --- a/src/calendaring_sync/tests/test_caldav.py +++ b/src/calendaring_sync/tests/test_caldav.py @@ -18,7 +18,7 @@ from contextlib import AbstractContextManager, nullcontext from dataclasses import replace from functools import partial -from typing import Any +from typing import Any, cast from unittest.mock import patch import pytest @@ -26,6 +26,7 @@ from caldav.lib import error as dav_error from calendaring_sync import ( + Conflict, ConflictKind, OtherScopeError, RecordState, @@ -55,6 +56,7 @@ adapter_class, caldav_client, calendar_path, + delete_a_removed_behind, edit_a_changed_behind, ) from calendaring_sync.tests._helpers import _SCOPE, _WAIT, _assert_adapter_refuses @@ -367,11 +369,40 @@ class TestFingerprint: "\r\n", id="LAST-MODIFIED", ), + # A scheduling server adds the status of what it sent to each + # attendee, and keeps no request to send (RFC 6638 sections 7.3, 7.2). + pytest.param( + _with( + "ATTENDEE;CN=Ana;ROLE=CHAIR:mailto:ana@example.org", + "ATTENDEE;CN=Ana;ROLE=CHAIR;SCHEDULE-STATUS=3.8:mailto:ana@example.org", + ), + "\r\n", + id="SCHEDULE-STATUS added", + ), + pytest.param( + _with( + "ATTENDEE;CN=Ana;ROLE=CHAIR:mailto:ana@example.org", + "ATTENDEE;CN=Ana;ROLE=CHAIR;SCHEDULE-FORCE-SEND=REQUEST:mailto:ana@example.org", + ), + "\r\n", + id="SCHEDULE-FORCE-SEND dropped", + ), ], ) def test_what_a_server_rewrites_keeps_the_fingerprint(self, lines, newline): assert _fields(_ical(lines, newline)) == _fields(_ical(_EVENT)) + def test_the_organizers_schedule_status_keeps_the_fingerprint(self): + # A server adds it to the organizer of an attendee's reply too (RFC 6638 section 7.3). + organizer = "ORGANIZER:mailto:bo@example.org" + sent = _with("UID:event-1", "UID:event-1", organizer) + stored = _with( + "UID:event-1", + "UID:event-1", + 'ORGANIZER;SCHEDULE-STATUS="2.0,2.4":mailto:bo@example.org', + ) + assert _fields(_ical(stored)) == _fields(_ical(sent)) + @pytest.mark.parametrize( "lines", [ @@ -392,6 +423,13 @@ def test_what_a_server_rewrites_keeps_the_fingerprint(self, lines, newline): _with("SUMMARY:Planning", "SUMMARY:Planning", "LOCATION:Room 1"), id="property" ), pytest.param(_with("TZOFFSETTO:+0100", "TZOFFSETTO:+0000"), id="time zone"), + pytest.param( + _with( + "ATTENDEE;CN=Ana;ROLE=CHAIR:mailto:ana@example.org", + "ATTENDEE;CN=Ana;ROLE=CHAIR;PARTSTAT=ACCEPTED:mailto:ana@example.org", + ), + id="an attendee's answer", + ), ], ) def test_an_edit_changes_the_fingerprint(self, lines): @@ -655,6 +693,44 @@ def test_a_listing_error_other_than_a_cut_is_raised(self, store, asynchronous): assert env.replica.records(env.adapter.scope_id) == [] +# Each kind of edit, in the recording whose exchange 9 is its push. +_REFUSED_EDITS = [ + ("change", "radicale-rejected", edit_a_changed_behind), + ("create", "radicale-pushing", create_c), + ("delete", "radicale-delete_gone", delete_a_removed_behind), +] +_DAV_ERROR = '{}' +# How tested servers refuse a write they won't take as it was sent, and the +# reason the adapter reads from each answer. +_REFUSALS = [ + ( + "no rights, as Radicale says it", + 403, + "Access to the requested resource forbidden.", + "403: Access to the requested resource forbidden.", + ), + ("no rights, as Stalwart says it", 403, "", "403"), + ( + "no rights, as Cyrus says it", + 403, + _DAV_ERROR.format(""), + "403 {DAV:}need-privileges", + ), + ( + "a component the calendar doesn't take", + 403, + _DAV_ERROR.format(""), + "403 {urn:ietf:params:xml:ns:caldav}supported-calendar-component", + ), + ("content the server can't read", 400, "Bad Request", "400: Bad Request"), + ("a conflict without a UID", 409, "", "409"), + ("a media type the server doesn't take", 415, "", "415"), + ("no room left", 507, "", "507"), +] +_REFUSAL_ANSWERS = [(what, status, body) for what, status, body, _ in _REFUSALS] +_REFUSAL_REASONS = {(status, body): reason for _, status, body, reason in _REFUSALS} + + class TestPushing: """Sending pending edits, and how each push ends.""" @@ -703,15 +779,60 @@ def test_an_edit_another_process_claimed_isnt_sent(self, store, asynchronous): @pytest.mark.parametrize( "recording, edit, status, body, state, conflict, raised", [ + *( + pytest.param( + recording, + edit, + status, + body, + RecordState.CONFLICT, + ConflictKind.NOT_ALLOWED, + None, + id=f"{name}, {what}", + ) + for name, recording, edit in _REFUSED_EDITS + for what, status, body in _REFUSAL_ANSWERS + ), + *( + pytest.param( + "radicale-rejected", + edit_a_changed_behind, + status, + "", + RecordState.IN_FLIGHT, + None, + raised, + id=f"change, {status} raised", + ) + for status, raised in ( + (401, dav_error.AuthorizationError), + (408, dav_error.PutError), + (429, dav_error.DAVError), + (503, dav_error.PutError), + ) + ), + *( + pytest.param( + "radicale-rejected", + edit_a_changed_behind, + status, + "", + RecordState.CONFLICT, + ConflictKind.REMOVED_REMOTELY, + None, + id=f"change, gone with {status}", + ) + for status in (404, 410) + ), pytest.param( - "radicale-rejected", - edit_a_changed_behind, - 404, + "radicale-delete_gone", + delete_a_removed_behind, + 410, "", - RecordState.CONFLICT, - ConflictKind.REMOVED_REMOTELY, + RecordState.REMOVED, + None, None, - id="change, gone", + id="delete, gone with 410", ), pytest.param( "radicale-rejected", @@ -759,7 +880,7 @@ def test_an_edit_another_process_claimed_isnt_sent(self, store, asynchronous): def test_how_a_refused_push_ends( self, store, asynchronous, recording, edit, status, body, state, conflict, raised ): - # Exchange 9 is the push, in both recordings. + # Exchange 9 is the push, in every recording the table names. tape = _tape(recording, keep=10) _edit(tape, 9, status=status, body=body) with _replaying(tape, store, asynchronous) as env: @@ -768,6 +889,8 @@ def test_how_a_refused_push_ends( env.sync() record = env.at(key) assert (record.state, record.conflict and record.conflict.kind) == (state, conflict) + if conflict is ConflictKind.NOT_ALLOWED: + assert cast(Conflict, record.conflict).reason == _REFUSAL_REASONS[(status, body)] # The session ended, after an error too, so the scope takes another. with _session(env.replica, env.adapter.scope_id): pass diff --git a/src/calendaring_sync/tests/test_edits.py b/src/calendaring_sync/tests/test_edits.py index 1d55faa..0e5d639 100644 --- a/src/calendaring_sync/tests/test_edits.py +++ b/src/calendaring_sync/tests/test_edits.py @@ -19,8 +19,10 @@ FeedEntryKind, LocalEdit, LogicalKeyTaken, + NotAllowed, OpenConflictError, PushInFlightError, + PushOutcome, RecordState, Removed, RemovedRecordError, @@ -43,6 +45,7 @@ _conflicted, _content, _create, + _edit_locally, _feed, _in_conflict, _insert, @@ -51,7 +54,6 @@ _page, _pending, _push, - _refused_create, _remote, _seeded, _seeded_edit, @@ -61,16 +63,18 @@ ) -def _refused_edit(replica: Replica, refused: EditKind) -> int: - """Have the server refuse an edit for b's logical key u, at T2 to T3; return its key. +def _refused_edit(replica: Replica, refused: EditKind, outcome: PushOutcome | None = None) -> int: + """Have the server refuse an edit holding the logical key u, at T2 to T3; return its key. - A create gets the id n, and a change goes to a. + It's refused with ``outcome``, by default because b holds u. A create + gets the id n, and a change goes to a. """ if refused is EditKind.CREATE: - return _refused_create(replica, "b", "T2", "T3") - key = _key(replica) - _change(replica, key, "fl", logical_key="u") - _push(replica, key, LogicalKeyTaken("b"), "T2", "T3") + key, _ = _edit_locally(replica, "create", logical_key="u") + else: + key = _key(replica) + _change(replica, key, "fl", logical_key="u") + _push(replica, key, LogicalKeyTaken("b") if outcome is None else outcome, "T2", "T3") return key @@ -260,32 +264,85 @@ def test_a_version_only_change_moves_the_version_under_an_edit(self, replica): class TestRefusedEdits: - """An edit the server refused because another object holds its key.""" + """An edit the server refused: another object holds its key, or the server doesn't allow it.""" @pytest.mark.parametrize( - "following, expected, feed", + "kind, refused, following, expected, feed", [ + *( + row + for kind in (ConflictKind.LOGICAL_KEY_TAKEN, ConflictKind.NOT_ALLOWED) + for row in ( + ( + kind, + "change", + "fx", + _conflicted(EditKind.CHANGE, ConflictKind.BOTH_CHANGED), + [FeedEntryKind.CONFLICT_UPDATED], + ), + ( + kind, + "change", + "removed", + _conflicted(EditKind.CHANGE, ConflictKind.REMOVED_REMOTELY), + [FeedEntryKind.CONFLICT_UPDATED], + ), + (kind, "change", "fl", _SYNCED, [FeedEntryKind.CONFLICT_RESOLVED]), + # The synced copy again, at a new version: nothing new, so it stays refused. + (kind, "change", "fa", _conflicted(EditKind.CHANGE, kind), []), + ) + ), ( + ConflictKind.NOT_ALLOWED, + "delete", "fx", - _conflicted(EditKind.CHANGE, ConflictKind.BOTH_CHANGED), + _conflicted(EditKind.DELETE, ConflictKind.DELETED_LOCALLY), [FeedEntryKind.CONFLICT_UPDATED], ), ( + ConflictKind.NOT_ALLOWED, + "delete", "removed", - _conflicted(EditKind.CHANGE, ConflictKind.REMOVED_REMOTELY), - [FeedEntryKind.CONFLICT_UPDATED], + _REMOVED, + [FeedEntryKind.CONFLICT_RESOLVED], + ), + ( + ConflictKind.NOT_ALLOWED, + "delete", + "fa", + _conflicted(EditKind.DELETE, ConflictKind.NOT_ALLOWED), + [], ), - ("fl", _SYNCED, [FeedEntryKind.CONFLICT_RESOLVED]), ], ) - def test_a_refused_change_meets_the_next_report(self, replica, following, expected, feed): - # The refusal holds no copy of the server's object, so any report is news. - key = _in_conflict(_seeded(replica), kind=ConflictKind.LOGICAL_KEY_TAKEN, refused="change") + def test_a_refused_change_or_delete_meets_the_next_report( + self, replica, kind, refused, following, expected, feed + ): + # The refusal holds no copy of the server's object, so only another + # copy than the synced one, or a removal, is news. + key = _in_conflict(_seeded(replica), kind=kind, refused=refused) # The server still holds the synced copy, so a listing needn't fetch it. assert replica.known_versions(_SCOPE)["a"] == replica.record(key).version head = replica.feed_head() _sync(replica, _page("T2", "T3", {"a": _remote(following, "v3")})) - _assert_ended(replica, key, head, expected, feed) + record = _assert_ended(replica, key, head, expected, feed) + if following == "fa": + # Only the version moved, so the next listing doesn't fetch it again. + assert record is not None and record.version == "v3" + + def test_a_freed_key_leaves_an_edit_the_server_didnt_allow(self, replica): + _sync(_seeded(replica), _page("T1", "T2", {"b": _changed("fb", "v2", logical_key="u")})) + # The edit holds the key b frees, which would lift a refusal for that key. + key = _refused_edit(replica, EditKind.CHANGE, NotAllowed()) + head = replica.feed_head() + _sync(replica, _page("T3", "T4", {"b": Removed()})) + _assert_ended( + replica, + key, + head, + _conflicted(EditKind.CHANGE, ConflictKind.NOT_ALLOWED), + [FeedEntryKind.REMOVED], + ) @pytest.mark.parametrize("refused", [EditKind.CREATE, EditKind.CHANGE]) @pytest.mark.parametrize( diff --git a/src/calendaring_sync/tests/test_jmap.py b/src/calendaring_sync/tests/test_jmap.py index 4b71fde..56364d9 100644 --- a/src/calendaring_sync/tests/test_jmap.py +++ b/src/calendaring_sync/tests/test_jmap.py @@ -31,6 +31,7 @@ Gone, LocalEdit, LogicalKeyTaken, + NotAllowed, OtherScopeError, PushOutcome, Record, @@ -287,19 +288,39 @@ def _uid_held( # What each refusal of a push means, by edit kind: Rejected, Gone, -# LogicalKeyTaken, or None for an error that isn't one and is raised. -_REFUSALS: dict[tuple[str, tuple[str, ...] | None], dict[str, type[PushOutcome] | None]] = { +# LogicalKeyTaken, NotAllowed, or None for an error that isn't one and is raised. +_Expected = dict[str, type[PushOutcome] | None] +_EVERY_KIND_NOT_ALLOWED: _Expected = { + "create": NotAllowed, + "change": NotAllowed, + "delete": NotAllowed, +} +_EVERY_KIND_RAISED: _Expected = {"create": None, "change": None, "delete": None} +_REFUSALS: dict[tuple[str, tuple[str, ...] | None], _Expected] = { ("stateMismatch", None): {"create": Rejected, "change": Rejected, "delete": Rejected}, ("notFound", None): {"create": None, "change": Gone, "delete": Gone}, ("invalidProperties", ("uid",)): { "create": LogicalKeyTaken, "change": LogicalKeyTaken, - "delete": None, + "delete": NotAllowed, }, - ("invalidProperties", ("title",)): {"create": None, "change": None, "delete": None}, - ("invalidPatch", ("uid",)): {"create": None, "change": None, "delete": None}, - ("invalidProperties", None): {"create": None, "change": None, "delete": None}, - ("serverFail", None): {"create": None, "change": None, "delete": None}, + ("invalidProperties", ("title",)): _EVERY_KIND_NOT_ALLOWED, + ("invalidProperties", ("calendarIds",)): _EVERY_KIND_NOT_ALLOWED, + ("invalidProperties", None): _EVERY_KIND_NOT_ALLOWED, + ("invalidPatch", ("uid",)): _EVERY_KIND_NOT_ALLOWED, + ("forbidden", None): _EVERY_KIND_NOT_ALLOWED, + ("accountReadOnly", None): _EVERY_KIND_NOT_ALLOWED, + ("overQuota", None): _EVERY_KIND_NOT_ALLOWED, + ("tooLarge", None): _EVERY_KIND_NOT_ALLOWED, + ("singleton", None): _EVERY_KIND_NOT_ALLOWED, + ("noSupportedScheduleMethods", None): _EVERY_KIND_NOT_ALLOWED, + ("rateLimit", None): _EVERY_KIND_RAISED, + ("serverFail", None): _EVERY_KIND_RAISED, + ("serverUnavailable", None): _EVERY_KIND_RAISED, + ("serverPartialFail", None): _EVERY_KIND_RAISED, + ("invalidArguments", None): _EVERY_KIND_RAISED, + ("unknownMethod", None): _EVERY_KIND_RAISED, + ("willDestroy", None): _EVERY_KIND_RAISED, } @@ -755,9 +776,9 @@ def test_a_create_whose_answer_was_lost_is_recognized_by_its_uid(self, store, as pytest.param( "change", ("invalidProperties", ["title"]), - RecordState.IN_FLIGHT, + RecordState.CONFLICT, + ConflictKind.NOT_ALLOWED, None, - JMAPMethodError, id="change, other property", ), pytest.param( @@ -766,26 +787,53 @@ def test_a_create_whose_answer_was_lost_is_recognized_by_its_uid(self, store, as pytest.param( "delete", ("invalidProperties", ["uid"]), - RecordState.IN_FLIGHT, + RecordState.CONFLICT, + ConflictKind.NOT_ALLOWED, None, - JMAPMethodError, id="delete, uid named", ), pytest.param( "create", ("invalidProperties", ["calendarIds"]), - RecordState.IN_FLIGHT, + RecordState.CONFLICT, + ConflictKind.NOT_ALLOWED, None, - JMAPMethodError, id="create, calendar named, no event holds its uid", ), pytest.param( "delete", ("invalidProperties", ["calendarIds"]), + RecordState.CONFLICT, + ConflictKind.NOT_ALLOWED, + None, + id="delete, calendar named", + ), + *( + pytest.param( + kind, + ("forbidden", None), + RecordState.CONFLICT, + ConflictKind.NOT_ALLOWED, + None, + id=f"{kind}, forbidden", + ) + for kind in ("create", "change", "delete") + ), + pytest.param( + "change", + "accountReadOnly", + RecordState.CONFLICT, + ConflictKind.NOT_ALLOWED, + None, + id="change, account read-only", + ), + pytest.param( + "create", + ("rateLimit", None), RecordState.IN_FLIGHT, None, JMAPMethodError, - id="delete, calendar named", + id="create, rate limited", ), ], ) @@ -794,6 +842,11 @@ def test_how_a_refused_push_ends( ): record = _refused_push(store, asynchronous, kind, refusal, raised) assert (record.state, record.conflict and record.conflict.kind) == (state, conflict) + if conflict is ConflictKind.NOT_ALLOWED: + # The reason is the error's type and the properties it names. + error_type, named = (refusal, None) if isinstance(refusal, str) else refusal + reason = " ".join([error_type, *(named or ())]) + assert cast(Conflict, record.conflict).reason == reason def test_a_refusal_naming_the_calendar_is_a_taken_uid_where_an_event_holds_it( self, store, asynchronous @@ -872,6 +925,17 @@ def test_every_refusal_of_every_edit_kind(self, error, kind): outcome = _refusal(kind, refused) assert (None if outcome is None else type(outcome)) is expected + @pytest.mark.parametrize( + "properties", + ["uid", [1], [{"x": 1}], ["uid", 2]], + ids=["a string", "a number", "an object", "a string and a number"], + ) + def test_properties_that_arent_strings_name_nothing(self, properties): + # Against RFC 8620 section 5.3, so a server sending them makes the + # refusal one that names no property, not a taken uid. + refused = JMAPMethodError(error_type="invalidProperties", properties=properties) + assert _refusal(EditKind.CREATE, refused) == NotAllowed("invalidProperties") + class TestScenarios: """Steps the scenarios share with the integration tests.""" diff --git a/src/calendaring_sync/tests/test_jmap_integration.py b/src/calendaring_sync/tests/test_jmap_integration.py index 74cdb4b..f07c00d 100644 --- a/src/calendaring_sync/tests/test_jmap_integration.py +++ b/src/calendaring_sync/tests/test_jmap_integration.py @@ -28,9 +28,9 @@ from unittest import mock import pytest -from calendaring_jmap import JMAPAuthError, JMAPClient, JMAPMethodError +from calendaring_jmap import JMAPAuthError, JMAPClient -from calendaring_sync import RecordState +from calendaring_sync import ConflictKind, MemoryStore, Record, RecordState, Replica from calendaring_sync.adapters import jmap as adapter_module from calendaring_sync.adapters.jmap import PAGE_SIZE from calendaring_sync.tests import _examples @@ -84,15 +84,11 @@ def live(request: pytest.FixtureRequest) -> tuple[Server, Login]: _env = partial(live_environment, scenarios) -@contextmanager -def _refused_if(refused: bool, error_type: str) -> Iterator[None]: - """Expect the block to raise the server's ``error_type`` when ``refused``, and nothing else.""" - if not refused: - yield - return - with pytest.raises(JMAPMethodError) as caught: - yield - assert caught.value.error_type == error_type +def _assert_not_allowed(record: Record | None, reason: str) -> None: + """Check the record's edit is a conflict the server didn't allow, with ``reason``.""" + conflict = None if record is None else record.conflict + assert conflict is not None + assert (conflict.kind, conflict.reason) == (ConflictKind.NOT_ALLOWED, reason) def _calls(env: Env, method: str, after: int = 0) -> list[Exchange]: @@ -212,8 +208,7 @@ def test_an_event_in_two_calendars_keeps_both_where_allowed(self, live, asynchro env.sync() both = {env.calendar: True, second: True} key = env.create(env.content("a", "one", calendarIds=both)) - with _refused_if(not server.keeps_two_calendars, "invalidProperties"): - env.sync() + env.sync() if server.keeps_two_calendars: # The next sync stores the server's copy, which keeps both. env.sync() @@ -223,12 +218,41 @@ def test_an_event_in_two_calendars_keeps_both_where_allowed(self, live, asynchro frozenset(both), ) else: - assert env.at(key).state is RecordState.IN_FLIGHT + _assert_not_allowed(env.at(key), "invalidProperties calendarIds") finally: env.other.delete_calendar( second, on_destroy_remove_events=True, account_id=env.account ) + def test_a_write_to_a_calendar_shared_to_read_is_a_conflict(self, live, asynchronous): + server, _ = live + if server.reader is None: + pytest.skip(f"{server.name}'s fixture has no second user to share with") + url, username, password = server.reader + with _env(live, "read_only", asynchronous) as env: + env.put("a", "one") + with JMAPClient(url=url, username=username, password=password) as reader_session: + reader = reader_session.get_account_id() + env.other.share_calendar( + env.calendar, + reader, + {"mayReadItems": True, "mayReadFreeBusy": True}, + owning_account_id=env.account, + ) + replica = Replica(MemoryStore()) + adapter, close = scenarios.adapter_for( + replica, server.reader, env.account, env.tape, asynchronous=asynchronous + ) + try: + env.run(adapter.sync()) + (record,) = replica.records(adapter.scope_id) + content = record.content or b"" + env.run(adapter.change(record.local_key, content.replace(b'"one"', b'"mine"'))) + env.run(adapter.sync()) + _assert_not_allowed(replica.record(record.local_key), "forbidden") + finally: + env.run(close()) + def test_a_create_without_a_version_is_refused_where_needed(self, live, asynchronous): server, _ = live with _env(live, "no_version", asynchronous) as env: @@ -236,13 +260,16 @@ def test_a_create_without_a_version_is_refused_where_needed(self, live, asynchro data = event(env.uid("a"), "one", env.calendar) del data["version"] key = env.create(json.dumps(data).encode()) - with _refused_if(server.needs_version, "invalidProperties"): - env.sync() - expected = RecordState.IN_FLIGHT if server.needs_version else RecordState.SYNCED - assert env.at(key).state is expected + env.sync() + if server.needs_version: + _assert_not_allowed(env.at(key), "invalidProperties version") + else: + assert env.at(key).state is RecordState.SYNCED def test_a_refused_login_is_raised_and_keeps_the_token(self, live, asynchronous): def wrong_login(env: Env, url: str, username: str | None) -> tuple[Any, Any]: + # Not through the tape, whose session fetch would turn the refusal + # into a plain HTTP error before calendaring-jmap could raise its own. client = jmap_client(asynchronous, url=url, username=username, password="not it") adapter = adapter_class(asynchronous)( env.replica, client, account_id=env.account, scope_id=env.adapter.scope_id diff --git a/src/calendaring_sync/tests/test_package.py b/src/calendaring_sync/tests/test_package.py index be133b3..8fbf47e 100644 --- a/src/calendaring_sync/tests/test_package.py +++ b/src/calendaring_sync/tests/test_package.py @@ -28,6 +28,7 @@ LogicalKeyTaken, MemoryStore, Merge, + NotAllowed, RecordState, Replica, Session, @@ -113,6 +114,7 @@ def test_every_entry_point_checks_its_types(self): ChangeSet, Applied, LogicalKeyTaken, + NotAllowed, Merge, EditFields, } == _CHECKED_WHEN_BUILT diff --git a/src/calendaring_sync/tests/test_pushes.py b/src/calendaring_sync/tests/test_pushes.py index c5afcca..28ac638 100644 --- a/src/calendaring_sync/tests/test_pushes.py +++ b/src/calendaring_sync/tests/test_pushes.py @@ -30,6 +30,7 @@ LogicalKeyTaken, LoneSurrogateError, Merge, + NotAllowed, ObjectIdAssignedError, OpenConflictError, OtherScopeError, @@ -57,6 +58,7 @@ from calendaring_sync.tests._helpers import ( _BAD_TEXT, _CLASSIFIED, + _NOT_ALLOWED, _OPENED_BY, _OTHER, _REMOVED, @@ -129,6 +131,21 @@ def _two_conflicts(replica: Replica) -> tuple[int, int]: _conflicted(EditKind.CHANGE, ConflictKind.LOGICAL_KEY_TAKEN), [FeedEntryKind.CONFLICT_OPENED], ), + *( + ( + local.value, + NotAllowed(_NOT_ALLOWED), + _conflicted(local, ConflictKind.NOT_ALLOWED), + [FeedEntryKind.CONFLICT_OPENED], + ) + for local in EditKind + ), + ( + "change", + NotAllowed(), + _conflicted(EditKind.CHANGE, ConflictKind.NOT_ALLOWED), + [FeedEntryKind.CONFLICT_OPENED], + ), ("change", Unknown(), (RecordState.IN_FLIGHT, EditKind.CHANGE, None), []), ] @@ -209,16 +226,28 @@ def _two_conflicts(replica: Replica) -> tuple[int, int]: (ConflictKind.LOGICAL_KEY_TAKEN, "change", _MERGED, _pending(EditKind.CHANGE)), (ConflictKind.LOGICAL_KEY_TAKEN, "change", Delete(), _pending(EditKind.DELETE)), (ConflictKind.LOGICAL_KEY_TAKEN, "change", UpdateHolder(), _SYNCED), + (ConflictKind.NOT_ALLOWED, "create", KeepServer(), None), + (ConflictKind.NOT_ALLOWED, "create", KeepLocal(), _pending(EditKind.CREATE)), + (ConflictKind.NOT_ALLOWED, "create", _MERGED, _pending(EditKind.CREATE)), + (ConflictKind.NOT_ALLOWED, "create", Delete(), None), + (ConflictKind.NOT_ALLOWED, "change", KeepServer(), _SYNCED), + (ConflictKind.NOT_ALLOWED, "change", KeepLocal(), _pending(EditKind.CHANGE)), + (ConflictKind.NOT_ALLOWED, "change", _MERGED, _pending(EditKind.CHANGE)), + (ConflictKind.NOT_ALLOWED, "change", Delete(), _pending(EditKind.DELETE)), + (ConflictKind.NOT_ALLOWED, "delete", KeepServer(), _SYNCED), + (ConflictKind.NOT_ALLOWED, "delete", KeepLocal(), _pending(EditKind.DELETE)), + (ConflictKind.NOT_ALLOWED, "delete", _MERGED, _pending(EditKind.CHANGE)), + (ConflictKind.NOT_ALLOWED, "delete", Delete(), _pending(EditKind.DELETE)), ] -def _first_push_rejected(replica: Replica, guard_covers_scope: bool) -> Any: - """The docs' example adapter, its first push rejected and every later one applied.""" +def _first_push_refused(replica: Replica, guard_covers_scope: bool, first: PushOutcome) -> Any: + """The docs' example adapter, its first push ending ``first`` and every later one applied.""" memory = _examples.load("memory_adapter") # Loaded from its file, so a type checker can't see its classes. example: Any = memory.MemoryAdapter - class FirstPushRejected(example): + class FirstPushRefused(example): def __init__(self) -> None: server = memory.MemoryServer() server.put("a", b"a", if_version=None) @@ -230,10 +259,10 @@ def _send(self, claimed: Record) -> Steps[PushOutcome]: self.sent.append(claimed.local_key) if len(self.sent) == 1: yield from () - return Rejected() + return first return (yield from super()._send(claimed)) - return FirstPushRejected() + return FirstPushRefused() class TestPushes: @@ -261,6 +290,8 @@ def test_settle_records_how_a_push_ended(self, replica, local, outcome, expected ) if isinstance(outcome, LogicalKeyTaken): assert record.conflict is not None and record.conflict.holder == outcome.holder + if isinstance(outcome, NotAllowed): + assert record.conflict is not None and record.conflict.reason == outcome.reason def test_a_guarded_push_waits_for_a_version(self, replica): _seeded(replica) @@ -333,21 +364,29 @@ def test_each_adapter_declares_what_its_server_checks( declared = (flow._guarded_by_version, flow._unique_logical_key, flow._guard_covers_scope) assert declared == (guarded_by_version, unique_logical_key, guard_covers_scope) - @pytest.mark.parametrize("covers_scope", [True, False], ids=["scope", "object"]) + @pytest.mark.parametrize( + "covers_scope, first, stops", + [ + pytest.param(True, Rejected(), True, id="rejected, scope"), + pytest.param(False, Rejected(), False, id="rejected, object"), + # A refusal of one edit says nothing about the others. + pytest.param(True, NotAllowed(_NOT_ALLOWED), False, id="not allowed, scope"), + ], + ) def test_a_rejected_push_stops_the_rest_where_the_check_covers_the_scope( - self, replica, covers_scope + self, replica, covers_scope, first, stops ): - flow = _first_push_rejected(replica, covers_scope) + flow = _first_push_refused(replica, covers_scope, first) flow.sync() changed = _key(replica) flow.change(changed, b"fl") created = flow.create(b"fn") flow.sync() # Where the check covers the scope, the create would be rejected too, so it waits unsent. - assert flow.sent == ([changed] if covers_scope else [changed, created]) + assert flow.sent == ([changed] if stops else [changed, created]) later = replica.record(created) assert later is not None - assert later.state is (RecordState.PENDING if covers_scope else RecordState.SYNCED) + assert later.state is (RecordState.PENDING if stops else RecordState.SYNCED) @pytest.mark.parametrize( "changes, expected", @@ -582,11 +621,10 @@ def test_each_resolution_of_each_conflict(self, replica, kind, refused, resoluti before = replica.record(key) conflict = before.conflict # The server's copy: what its side of the conflict holds, or, for a - # change it refused, the synced copy it kept. - refused_change = kind is ConflictKind.LOGICAL_KEY_TAKEN and refused == "change" + # change or delete it refused, the synced copy it kept. server = ( (before.version, before.content) - if refused_change + if before.is_refused_existing else (conflict.remote_version, conflict.remote_content) ) head = replica.feed_head() @@ -653,7 +691,10 @@ def test_a_refused_create_goes_to_the_key_holder( @pytest.mark.parametrize( "kind, resolution, message", [ - *((kind, UpdateHolder(), "^Only an edit refused") for kind in _OPENED_BY), + *( + (kind, UpdateHolder(), "^Only an edit refused") + for kind in [*_OPENED_BY, ConflictKind.NOT_ALLOWED] + ), (ConflictKind.LOGICAL_KEY_TAKEN, KeepLocal(), "can't be pushed again unchanged$"), (None, KeepServer(), "takes a record in conflict$"), ], diff --git a/src/calendaring_sync/tests/test_store.py b/src/calendaring_sync/tests/test_store.py index 8e0e367..85d1a9e 100644 --- a/src/calendaring_sync/tests/test_store.py +++ b/src/calendaring_sync/tests/test_store.py @@ -32,6 +32,7 @@ MemoryData, MemoryKeeper, MemoryStore, + NotAllowed, RecordState, RecordValueError, Removed, @@ -67,6 +68,7 @@ _CONFLICT, _EDIT, _FIELDS, + _NOT_ALLOWED, _OTHER, _REMOVED, _SCOPE, @@ -86,7 +88,6 @@ _new_scope, _open_session, _page, - _pending, _pushing, _seeded, _snapshot, @@ -197,6 +198,12 @@ def _edit_file(path: Path, sql: str, params: tuple[object, ...]) -> None: conflict=replace(_CONFLICT, kind=ConflictKind.LOGICAL_KEY_TAKEN), ), "a holder on another kind": replace(_CONFLICTED, conflict=replace(_CONFLICT, holder="b")), + "an edit not allowed holding the server's copy": replace( + _CONFLICTED, conflict=replace(_CONFLICT, kind=ConflictKind.NOT_ALLOWED) + ), + "a reason on another kind": replace( + _CONFLICTED, conflict=replace(_CONFLICT, reason=_NOT_ALLOWED) + ), } @@ -379,13 +386,21 @@ def test_scope_records_filter_by_state(self, replica, store, states, expected): assert (every.called or some.called) == (states != frozenset()) assert some.called == bool(states) - @pytest.mark.parametrize("holder", [None, "b"]) - def test_a_refused_create_keeps_its_holder(self, store, holder): + @pytest.mark.parametrize( + "kind, said", + [ + (ConflictKind.LOGICAL_KEY_TAKEN, {"holder": None}), + (ConflictKind.LOGICAL_KEY_TAKEN, {"holder": "b"}), + (ConflictKind.NOT_ALLOWED, {"reason": None}), + (ConflictKind.NOT_ALLOWED, {"reason": _NOT_ALLOWED}), + ], + ) + def test_a_refused_create_keeps_what_the_server_said(self, store, kind, said): record = replace( _CONFLICTED, content=None, edit=replace(_EDIT, kind=EditKind.CREATE), - conflict=replace(_GONE, kind=ConflictKind.LOGICAL_KEY_TAKEN, holder=holder), + conflict=replace(_GONE, kind=kind, **said), ) with _write_with_scope(store): key = store.insert_record(record) @@ -683,16 +698,18 @@ def test_state_survives_reopening(self, db_path, clock): _sync(replica, _page("T2", "T3", changes)) replica.delete(_key(replica, "c")) _sync(replica, _page("T3", "T4", {"d": Removed()})) - # A create refused with the object holding its key, and a push in flight. + # A create refused with the object holding its key, a delete not + # allowed with the server's reason, and a push in flight. with _pushing(replica, "T4", "T5") as session: session.settle(_claim(session, created), LogicalKeyTaken("b")) + session.settle(_claim(session, _key(replica, "c")), NotAllowed(_NOT_ALLOWED)) session.claim(_key(replica)) expected = _snapshot(replica, store) assert [_summary(r) for r in replica.records(_SCOPE)] == [ (RecordState.IN_FLIGHT, EditKind.CHANGE, None), _conflicted(EditKind.CHANGE, ConflictKind.REMOVED_REMOTELY), _conflicted(EditKind.CREATE, ConflictKind.LOGICAL_KEY_TAKEN), - _pending(EditKind.DELETE), + _conflicted(EditKind.DELETE, ConflictKind.NOT_ALLOWED), _REMOVED, ] with SQLiteStore(db_path) as store: diff --git a/tests/docker/stalwart/setup_stalwart.py b/tests/docker/stalwart/setup_stalwart.py index d2766d1..3d944b5 100644 --- a/tests/docker/stalwart/setup_stalwart.py +++ b/tests/docker/stalwart/setup_stalwart.py @@ -7,8 +7,9 @@ lifts its rate limits and its bans for failed logins and abuse (the tests make many requests quickly, and some log in with a wrong password on purpose), reloads its settings so they apply, creates the domain -``example.org`` and the user ``testuser``, and checks that the user can -reach the JMAP session. Run by ``start.sh``; it needs nothing beyond +``example.org``, the user ``testuser`` and a second user ``reader`` that +``testuser`` can share calendars with, and checks that both can reach the +JMAP session. Run by ``start.sh``; it needs nothing beyond Python's standard library. The integration tests load it to change Stalwart's settings through :func:`admin`, :func:`set_singleton` and :func:`reload_settings`. @@ -31,6 +32,8 @@ ADMIN_ACCOUNT = "d333333" DOMAIN = "example.org" USER = ("testuser", "testcaldav") +# A second user, for calendars the first shares with it to read only. +READER = ("reader", "readerpw") # How long to wait for Stalwart, and for the new user to reach JMAP, in seconds. WAIT = 60 # Stalwart's limits on requests, and on failed logins and abuse before a ban, lifted. @@ -121,20 +124,21 @@ def main() -> None: set_singleton("x:Security/set", {"authBanRate": UNLIMITED, "abuseBanRate": UNLIMITED}) reload_settings() domain = _created_or_found("x:Domain/set", {"name": DOMAIN}, "name") + for user, password in (USER, READER): + _created_or_found( + "x:Account/set", + { + "@type": "User", + "name": user, + "domainId": domain, + "credentials": {"0": {"@type": "Password", "secret": password}}, + }, + "name", + ) + session = _wait_for_session(_login(f"{user}@{DOMAIN}", password)) + if "urn:ietf:params:jmap:calendars" not in session.get("capabilities", {}): + raise SystemExit(f"{user}@{DOMAIN} has no JMAP calendars: {session}") user, password = USER - _created_or_found( - "x:Account/set", - { - "@type": "User", - "name": user, - "domainId": domain, - "credentials": {"0": {"@type": "Password", "secret": password}}, - }, - "name", - ) - session = _wait_for_session(_login(f"{user}@{DOMAIN}", password)) - if "urn:ietf:params:jmap:calendars" not in session.get("capabilities", {}): - raise SystemExit(f"{user}@{DOMAIN} has no JMAP calendars: {session}") print(f"Stalwart is ready: {user}@{DOMAIN} / {password} at {SESSION}")