diff --git a/.vscode/cspell.json b/.vscode/cspell.json index 441cfc1a..03325bee 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -127,6 +127,7 @@ "fteu", "ftyp", "gare", + "gatewayws", "generatedcode", "geofence", "guids", @@ -222,6 +223,7 @@ "sharedstreams", "sharingtype", "slomo", + "softwarecapabilityflags", "sonarcube", "sonarlint", "sonarqube", diff --git a/README.md b/README.md index 1653f945..81d736af 100644 --- a/README.md +++ b/README.md @@ -1759,12 +1759,38 @@ enums used elsewhere in this service. `resolve()` only looks; `accept()` joins the event, after which it appears in `events()` with the shared scope. +### Cancelling an event you host + +```python +event = api.invites.event("EVENT-UUID") + +cancelled = api.invites.cancel(event) +print(cancelled.is_cancelled) # True + +# Changed your mind: +api.invites.cancel(cancelled, cancelled=False) +``` + +Guests see the cancellation, so pass an event you really do host. The call +works in both directions, so a cancellation is recoverable. + +Pass an `event` that carries a current record change tag -- one from `event()` +or from a previous `cancel()`, as above. Apple rejects a stale one with an +opaque `502`. + +Cancelling is the only event flag this library can write. Apple stores +`isPublished`, `isPrivate` and `title` as PCS-encrypted fields, and writing +those needs encryption support pyicloud does not have yet, so publishing an +event or renaming it still has to happen in the Invites app. + ### Errors Every call raises a subclass of `InvitesError`: `InvitesAuthError` when the session needs renewing, `InvitesRateLimited` when Apple asks you to slow down, and `InvitesApiError` for everything else. `event()` raises `EventNotFound` for -an unknown event id. +an unknown event id, and `cancel()` raises `InvitesEntitlementError` when the +account is not entitled to edit events -- Apple gates them behind an iCloud +subscription feature. ## Examples diff --git a/pyicloud/services/invites/__init__.py b/pyicloud/services/invites/__init__.py index a2d8d6b6..b9349780 100644 --- a/pyicloud/services/invites/__init__.py +++ b/pyicloud/services/invites/__init__.py @@ -4,6 +4,7 @@ CloudKitInvitesClient, InvitesApiError, InvitesAuthError, + InvitesEntitlementError, InvitesError, InvitesRateLimited, ) @@ -34,6 +35,7 @@ "EventTime", "InvitesApiError", "InvitesAuthError", + "InvitesEntitlementError", "InvitesError", "InvitesRateLimited", "InvitesService", diff --git a/pyicloud/services/invites/client.py b/pyicloud/services/invites/client.py index 50133de0..af2aa8bc 100644 --- a/pyicloud/services/invites/client.py +++ b/pyicloud/services/invites/client.py @@ -47,6 +47,11 @@ CloudKitRateLimited, ) from pyicloud.exceptions import PyiCloudAPIResponseException +from pyicloud.services.invites.entitlement import ( + GATEWAY_BASE_URL, + FeatureAccess, + parse_feature_access, +) LOGGER = logging.getLogger(__name__) DEFAULT_TIMEOUT = (10.0, 60.0) @@ -90,6 +95,15 @@ def __init__(self, message: str, retry_after: float | None = None) -> None: self.retry_after = retry_after +class InvitesEntitlementError(InvitesError): + """The account may not perform this write. + + Apple gates event edits behind a subscription feature and answers an + unproven write with ``502 INTERNAL_ERROR``, which says nothing useful. This + is raised before the request instead. + """ + + class InvitesApiError(InvitesError): """Catch-all API error.""" @@ -174,6 +188,31 @@ def _raise_invites_error(exc: Exception) -> NoReturn: # ----- Records-in-zones scoped wrappers ---------------------------------- + def feature_access(self, feature: str) -> FeatureAccess: + """Ask Apple whether this account may use ``feature``, and for a token. + + Not a CloudKit call: the gateway lives outside the container and is not + in the advertised webservices map, so it is reached directly. + """ + + dsid = self._base_params.get("dsid") + url = f"{GATEWAY_BASE_URL}/accounts/{dsid}/subscriptions/features" + try: + response = self._session.get( + url, + params=self._base_params, + headers={"x-apple-softwarecapabilityflags": feature}, + timeout=self._timeout, + ) + payload = response.json() + except PyiCloudAPIResponseException as exc: + self._raise_invites_error(exc) + except ValueError as exc: + raise InvitesApiError( + "Entitlement gateway returned a non-JSON response" + ) from exc + return parse_feature_access(payload, feature) + def zones_list(self, scope: ScopeLiteral) -> CKZoneListResponse: """List the zones in the given scope. diff --git a/pyicloud/services/invites/entitlement.py b/pyicloud/services/invites/entitlement.py new file mode 100644 index 00000000..c056d63a --- /dev/null +++ b/pyicloud/services/invites/entitlement.py @@ -0,0 +1,83 @@ +"""Subscription entitlements for Invites writes. + +Editing an event is gated behind an iCloud subscription feature. Apple proves +the entitlement with a short-lived token that the write must carry, and without +it every modify comes back ``502 INTERNAL_ERROR`` with nothing to act on -- so +the failure looks like an unsupported operation rather than a missing +credential. + +The token comes from a host that is not in the ``webservices`` map Apple +advertises at login, so unlike every other endpoint in this library it cannot +be resolved and is named here. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +import logging +from typing import Any + +LOGGER = logging.getLogger(__name__) + +#: Not advertised in the webservices map, so unlike every other endpoint +#: here it cannot be resolved from the login response. +GATEWAY_BASE_URL = "https://gatewayws.icloud.com/acsegateway/v4" + +#: The capability an event write requires. +CREATE_EVENT_FEATURE = "apps.rsvp.create-event" + +#: Re-request a token this long before it expires, so a write near the boundary +#: does not race the clock. +EXPIRY_MARGIN_SECONDS = 60.0 + + +@dataclass(frozen=True, slots=True) +class FeatureAccess: + """Whether an account may use a feature, and the token that proves it.""" + + feature_key: str + can_use: bool + access_token: str | None = None + cache_till: datetime | None = None + + def usable_at(self, moment: datetime) -> bool: + """Return whether this grant is still good at ``moment``.""" + + if not self.can_use or not self.access_token: + return False + if self.cache_till is None: + return False + return (self.cache_till - moment).total_seconds() > EXPIRY_MARGIN_SECONDS + + +def _parse_cache_till(value: Any) -> datetime | None: + """Parse Apple's ``cacheTill`` timestamp, tolerating a missing one.""" + + if not isinstance(value, str): + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + LOGGER.debug("invites.entitlement.unparsed_cache_till value=%r", value) + return None + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + + +def parse_feature_access(payload: Any, feature: str) -> FeatureAccess: + """Read one feature's grant out of the gateway's response. + + The response is a list -- one entry per requested feature -- so the wanted + grant is matched on ``featureKey`` rather than taken positionally. + """ + + entries = payload if isinstance(payload, list) else [] + for entry in entries: + if isinstance(entry, dict) and entry.get("featureKey") == feature: + return FeatureAccess( + feature_key=feature, + can_use=bool(entry.get("canUse")), + access_token=entry.get("accessToken"), + cache_till=_parse_cache_till(entry.get("cacheTill")), + ) + return FeatureAccess(feature_key=feature, can_use=False) diff --git a/pyicloud/services/invites/service.py b/pyicloud/services/invites/service.py index 0d12b439..f43eea70 100644 --- a/pyicloud/services/invites/service.py +++ b/pyicloud/services/invites/service.py @@ -12,15 +12,17 @@ Write (Phase 2): - ``InvitesService.rsvp(event, status, ...)`` -> ``Rsvp`` + - ``InvitesService.cancel(event, ...)`` -> ``Event`` -Write operations on ``EventDetails`` (create event, publish, cancel, invite -via link) arrive in later phases per the design doc. +Creating events, publishing them, and inviting via link arrive in later +phases per the design doc. """ from __future__ import annotations from collections.abc import Iterable, Mapping from datetime import datetime, timezone +import json import logging from typing import Any, cast @@ -49,10 +51,12 @@ from .client import ( CloudKitInvitesClient, InvitesApiError, + InvitesEntitlementError, InvitesError, ScopeLiteral, ) from .codecs import decode_integrations, decode_json_bytes +from .entitlement import CREATE_EVENT_FEATURE, FeatureAccess from .models.constants import ( EventDetailsField, InvitesRecordType, @@ -113,6 +117,7 @@ def __init__( base_params, validation_extra=cloudkit_validation_extra, ) + self._feature_access: FeatureAccess | None = None @property def raw(self) -> CloudKitInvitesClient: @@ -293,10 +298,162 @@ def rsvp( ) return self._rsvp_from_modify_response(response, record_name) + def cancel(self, event: Event, *, cancelled: bool = True) -> Event: + """Cancel an event you host, or reinstate one you cancelled. + + Apple accepts both directions, so this is a symmetric switch rather + than a one-way verb -- an accidental cancel is recoverable. + + ``event`` must carry a current record change tag, so pass one from + ``event()`` or from a previous write rather than reusing a stale + object: Apple rejects a stale tag with an opaque ``502``. + """ + + return self._set_event_flag(event, EventDetailsField.IS_CANCELLED, cancelled) + # ------------------------------------------------------------------ # Internal: write helpers # ------------------------------------------------------------------ + def _entitlement_token(self) -> str: + """Return a token proving this account may edit events. + + Cached until shortly before Apple's own ``cacheTill``: the grant lasts + a week, and fetching one per write would add a round trip for nothing. + """ + + now = datetime.now(timezone.utc) + cached = self._feature_access + if cached is not None and cached.usable_at(now): + return cast(str, cached.access_token) + + access = self._raw.feature_access(CREATE_EVENT_FEATURE) + if not access.can_use or not access.access_token: + raise InvitesEntitlementError( + "This account cannot edit events. Apple gates them behind a " + f"subscription feature ({CREATE_EVENT_FEATURE}) and reports " + "it as unavailable here." + ) + self._feature_access = access + return access.access_token + + def _set_event_flag( + self, event: Event, field: EventDetailsField, value: bool + ) -> Event: + """Set one boolean flag on an event, and on its share. + + Two operations in one atomic modify, because the share record keeps its + own copies of these flags and does **not** follow the event: cancelling + only the event leaves the share reading as live to guests. + + The ``hint`` field carries the entitlement token. Without it Apple + answers ``502 INTERNAL_ERROR`` with no explanation at all. + + Only flags the schema stores unencrypted can go through here, and a + read says which those are: a field Apple sends back with + ``isEncrypted: true`` is PCS-protected, and writing it needs a + ciphertext this library cannot produce. ``isCancelled`` carries no such + marker and takes a plain ``INT64``. ``isPublished`` does carry it, and + Apple answers ``BAD_REQUEST -- expected type ENCRYPTED_NUMBER_INT64`` + to every unencrypted encoding, so publishing waits on PCS support. + """ + + if not event.record_change_tag: + raise InvitesApiError( + f"Event {event.event_id!r} has no record change tag; fetch it " + "via InvitesService.event(...) before writing to it." + ) + + hint = json.dumps({"subscriptionAccessToken": self._entitlement_token()}) + flag: dict[str, Any] = {"type": "INT64", "value": int(value)} + scope_str = self._scope_str(event.scope) + zone_id = self._zone_id_req(event.event_id, event.scope) + + operations = [ + CKModifyOperation( + operationType="update", + record=CKWriteRecord( + recordName=(f"{EVENT_DETAILS_RECORD_NAME_PREFIX}{event.event_id}"), + recordType=InvitesRecordType.EventDetails.value, + recordChangeTag=event.record_change_tag, + fields=cast( + CKWriteFields, + { + field.value: flag, + EventDetailsField.HINT.value: { + "type": "STRING", + "value": hint, + }, + }, + ), + ), + ) + ] + + share_tag = self._share_change_tag(scope_str, zone_id) + if share_tag is not None: + operations.append( + CKModifyOperation( + operationType="update", + record=CKWriteRecord( + recordName=SHARE_RECORD_NAME, + recordType=InvitesRecordType.Share.value, + recordChangeTag=share_tag, + fields=cast(CKWriteFields, {field.value: flag}), + ), + ) + ) + + response: CKModifyResponse = self._raw.modify( + scope_str, operations=operations, zone_id=zone_id, atomic=True + ) + return self._event_from_modify_response( + response, + f"{EVENT_DETAILS_RECORD_NAME_PREFIX}{event.event_id}", + event.scope, + ) + + def _share_change_tag( + self, scope_str: ScopeLiteral, zone_id: CKZoneIDReq + ) -> str | None: + """Return the share record's own change tag, or None if unreadable. + + ``EventShare`` does not carry it and an update needs it, so read it + back from the same lookup ``event()`` already uses. A zone whose share + cannot be read still gets its event updated rather than nothing. + """ + + try: + resp = self._raw.lookup(scope_str, [SHARE_RECORD_NAME], zone_id=zone_id) + except InvitesError: + LOGGER.debug("invites.share_change_tag_failed", exc_info=True) + return None + for record in self._records_of(resp): + if record.recordName == SHARE_RECORD_NAME: + return record.recordChangeTag + return None + + def _event_from_modify_response( + self, response: CKModifyResponse, record_name: str, scope: EventScope + ) -> Event: + """Pick the event record out of a modify response and convert it. + + Carries the new change tag so writes can be chained. ``share`` and + ``rsvps`` are unset, as in ``events()``. + """ + + for record in response.records: + if ( + isinstance(record, CKRecord) + and record.recordName == record_name + and record.recordType == InvitesRecordType.EventDetails.value + ): + return self._event_from_record(record, scope=scope) + raise InvitesApiError( + f"Modify response did not return {record_name!r}", + payload=response.model_dump(mode="json"), + ) + @staticmethod def _current_participant_id(event: Event) -> str: """Return the current user's participant_id on ``event``'s share.""" diff --git a/tests/fixtures/invites/README.md b/tests/fixtures/invites/README.md index 9778a856..b1df64db 100644 --- a/tests/fixtures/invites/README.md +++ b/tests/fixtures/invites/README.md @@ -11,15 +11,16 @@ fake or omitted, and stable across test runs. ## Files -| File | Shape | -| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| `events_query_response.json` | `CKQueryResponse` for a `zoneWide` `EventDetails` query (two events: a full one and a minimal open-ended one). | -| `event_lookup_response.json` | `CKLookupResponse` for an `EventDetails` + `cloudkit.zoneshare` lookup against one event's zone. | -| `rsvp_query_response.json` | `CKQueryResponse` for an `RSVP` query against one event's zone (one going RSVP with a plus-one). | -| `rsvp_modify_response.json` | `CKModifyResponse` after toggling an existing RSVP to `Maybe` with no plus-ones (used by Phase 2 write tests). | -| `one_time_link_query_empty_response.json` | `CKQueryResponse` empty record set, for the OneTimeLinkGuestInfo case when no link invitations exist. | -| `resolve_response.json` | Raw JSON from `public/records/resolve` for the owner viewing their own share. | -| `accept_response.json` | Raw JSON from `public/records/accept` for a guest joining via the shortGUID. | +| File | Shape | +| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `events_query_response.json` | `CKQueryResponse` for a `zoneWide` `EventDetails` query (two events: a full one and a minimal open-ended one). | +| `event_lookup_response.json` | `CKLookupResponse` for an `EventDetails` + `cloudkit.zoneshare` lookup against one event's zone. | +| `rsvp_query_response.json` | `CKQueryResponse` for an `RSVP` query against one event's zone (one going RSVP with a plus-one). | +| `rsvp_modify_response.json` | `CKModifyResponse` after toggling an existing RSVP to `Maybe` with no plus-ones (used by Phase 2 write tests). | +| `event_cancel_modify_response.json` | `CKModifyResponse` after cancelling one event: the `EventDetails` and `cloudkit.zoneshare` records both come back with `isCancelled` set. | +| `one_time_link_query_empty_response.json` | `CKQueryResponse` empty record set, for the OneTimeLinkGuestInfo case when no link invitations exist. | +| `resolve_response.json` | Raw JSON from `public/records/resolve` for the owner viewing their own share. | +| `accept_response.json` | Raw JSON from `public/records/accept` for a guest joining via the shortGUID. | ## Encoding notes @@ -32,3 +33,9 @@ base64-encoded JSON. The fixture values base64 to inspectable JSON: - `background`: `{"kind": "image", "visibility": 1, "image": {"cropRect": [...]}}` - `style`: `{"titleFont": 0}` - `integrations`: `{"version": "1", "data": [{"type": "com.apple.widget.weather"}, ...]}` + +`isEncrypted: true` on a read means the server decrypted a PCS field for the +web client. Those fields are declared `ENCRYPTED_*` in the schema and cannot be +written back without PCS, so the fixtures keep the marker where Apple sends it: +`isCancelled` and `blockNewRSVPs` are plain `INT64` and writable, while +`isPublished`, `isPrivate`, and `title` are not. diff --git a/tests/fixtures/invites/event_cancel_modify_response.json b/tests/fixtures/invites/event_cancel_modify_response.json new file mode 100644 index 00000000..fef02b03 --- /dev/null +++ b/tests/fixtures/invites/event_cancel_modify_response.json @@ -0,0 +1,159 @@ +{ + "records": [ + { + "recordName": "EventDetails:EVENT-FIXTURE-AAAA", + "recordType": "EventDetails", + "fields": { + "isCancelled": { + "value": 1, + "type": "INT64" + }, + "notes": { + "value": "Fixture notes for event A.", + "type": "STRING", + "isEncrypted": true + }, + "isPublished": { + "value": 1, + "type": "INT64", + "isEncrypted": true + }, + "isPrivate": { + "value": 0, + "type": "INT64", + "isEncrypted": true + }, + "title": { + "value": "Event Fixture A", + "type": "STRING", + "isEncrypted": true + }, + "blockNewRSVPs": { + "value": 0, + "type": "INT64" + }, + "background": { + "value": "eyJraW5kIjoiaW1hZ2UiLCJ2aXNpYmlsaXR5IjoxLCJpbWFnZSI6eyJjcm9wUmVjdCI6WzAuNSwwLjUsMSwxXX19", + "type": "ENCRYPTED_BYTES" + }, + "style": { + "value": "eyJ0aXRsZUZvbnQiOjB9", + "type": "ENCRYPTED_BYTES" + }, + "place": { + "value": "eyJsYXRpdHVkZSI6NDguODU2NiwibG9uZ2l0dWRlIjoyLjM1MjIsInRpdGxlIjoiRml4dHVyZSBIYWxsIiwic3VidGl0bGUiOiJGaXh0dXJlIENpdHksIEZpeHR1cmVsYW5kIiwiY2l0eSI6IkZpeHR1cmUgQ2l0eSIsInRpbWVab25lSWRlbnRpZmllciI6IkV1cm9wZS9QYXJpcyIsInVybCI6Imh0dHBzOi8vbWFwcy5leGFtcGxlLmNvbS9maXh0dXJlIn0=", + "type": "ENCRYPTED_BYTES" + }, + "time": { + "value": "eyJzdGFydFNpbmNlMTk3MCI6MTc2ODQzNTIwMDAwMCwiZW5kU2luY2UxOTcwIjoxNzY4NDQ2MDAwMDAwLCJpc0FsbERheSI6ZmFsc2UsImlzT3BlbkVuZGVkIjpmYWxzZX0=", + "type": "ENCRYPTED_BYTES" + }, + "integrations": { + "value": "eyJ2ZXJzaW9uIjoiMSIsImRhdGEiOlt7InR5cGUiOiJjb20uYXBwbGUud2lkZ2V0LndlYXRoZXIifSx7InR5cGUiOiJjb20uYXBwbGUud2lkZ2V0LmxvY2F0aW9uIn1dfQ==", + "type": "ENCRYPTED_BYTES" + }, + "maxAttendees": { + "value": 100, + "type": "INT64", + "isEncrypted": true + }, + "maxAdditionalGuestsPerRSVP": { + "value": 2, + "type": "INT64", + "isEncrypted": true + } + }, + "pluginFields": {}, + "recordChangeTag": "fixtureA2", + "created": { + "timestamp": 1768400000000, + "userRecordName": "_FAKE_OWNER", + "deviceID": "FIXTURE-DEVICE-A" + }, + "modified": { + "timestamp": 1768402000000, + "userRecordName": "_FAKE_OWNER", + "deviceID": "FIXTURE-DEVICE-A" + }, + "deleted": false, + "zoneID": { + "zoneName": "EVENT-FIXTURE-AAAA", + "ownerRecordName": "_FAKE_OWNER", + "zoneType": "REGULAR_CUSTOM_ZONE" + } + }, + { + "recordName": "cloudkit.zoneshare", + "recordType": "cloudkit.share", + "fields": { + "cloudkit.title": { + "value": "Event Fixture A", + "type": "STRING" + }, + "isPrivate": { + "value": 0, + "type": "INT64" + }, + "isPublished": { + "value": 1, + "type": "INT64" + }, + "isCancelled": { + "value": 1, + "type": "INT64" + } + }, + "pluginFields": {}, + "recordChangeTag": "shareFixtureA2", + "created": { + "timestamp": 1768400500000, + "userRecordName": "_FAKE_OWNER", + "deviceID": "FIXTURE-DEVICE-A" + }, + "modified": { + "timestamp": 1768400500000, + "userRecordName": "_FAKE_OWNER", + "deviceID": "iCloud" + }, + "deleted": false, + "zoneID": { + "zoneName": "EVENT-FIXTURE-AAAA", + "ownerRecordName": "_FAKE_OWNER", + "zoneType": "REGULAR_CUSTOM_ZONE" + }, + "publicPermission": "READ_WRITE", + "participants": [ + { + "participantId": "PARTICIPANT-FIXTURE-OWNER", + "userIdentity": { + "userRecordName": "_FAKE_OWNER", + "nameComponents": { + "givenName": "Owner", + "familyName": "Fixture" + }, + "lookupInfo": { + "emailAddress": "owner@example.com" + } + }, + "type": "OWNER", + "acceptanceStatus": "ACCEPTED", + "permission": "READ_WRITE" + }, + { + "participantId": "PARTICIPANT-FIXTURE-GUEST", + "userIdentity": { + "userRecordName": "++FAKEGUESTRECORDNAME=", + "nameComponents": {}, + "lookupInfo": { + "emailAddress": "guest@example.com" + } + }, + "type": "PUBLIC_USER", + "acceptanceStatus": "ACCEPTED", + "permission": "READ_WRITE" + } + ], + "shortGUID": "008TESTFIXTUREAAAA" + } + ] +} diff --git a/tests/test_invites.py b/tests/test_invites.py index 1da9aea0..c0e43354 100644 --- a/tests/test_invites.py +++ b/tests/test_invites.py @@ -19,6 +19,7 @@ CKModifyResponse, CKQueryResponse, CKZoneChangesZone, + CKZoneIDReq, CKZoneListResponse, ) from pyicloud.exceptions import PyiCloudAPIResponseException @@ -38,6 +39,8 @@ CloudKitInvitesClient, InvitesApiError, InvitesAuthError, + InvitesEntitlementError, + InvitesError, InvitesRateLimited, ) from pyicloud.services.invites.codecs import ( @@ -45,6 +48,12 @@ decode_json_bytes, encode_json_bytes, ) +from pyicloud.services.invites.entitlement import ( + CREATE_EVENT_FEATURE, + GATEWAY_BASE_URL, + FeatureAccess, + parse_feature_access, +) from pyicloud.services.invites.service import EventNotFound FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "invites" @@ -951,5 +960,337 @@ def test_rsvp_rejects_negative_plus_one_counts(self) -> None: modify_mock.assert_not_called() +def _cancellable_event(scope: EventScope = EventScope.PRIVATE) -> Event: + """Build an Event carrying the change tag a write needs.""" + return Event( + event_id="EVENT-FIXTURE-AAAA", + scope=scope, + time=EventTime(start=datetime(2026, 1, 15, tzinfo=timezone.utc)), + record_change_tag="fixtureA1", + ) + + +class CancelTest(unittest.TestCase): + """Tests for cancelling and reinstating an event.""" + + def setUp(self) -> None: + self.service = InvitesService( + service_root="https://example.com", + session=MagicMock(), + params={}, + ) + self.modify_response = CKModifyResponse.model_validate( + load_invites_fixture("event_cancel_modify_response.json") + ) + self.service._feature_access = FeatureAccess( + feature_key=CREATE_EVENT_FEATURE, + can_use=True, + access_token="token-fixture", + cache_till=datetime(2099, 1, 1, tzinfo=timezone.utc), + ) + + @pytest.fixture(autouse=True) + def _monkeypatch(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Expose pytest's monkeypatch fixture to unittest-style tests.""" + self._monkeypatch = monkeypatch + + def _patch_wire(self, share_tag: str | None = "shareFixtureA1") -> MagicMock: + """Stub the modify call and the share change-tag lookup.""" + modify_mock = MagicMock(return_value=self.modify_response) + self._monkeypatch.setattr(self.service.raw, "modify", modify_mock) + self._monkeypatch.setattr( + self.service, "_share_change_tag", lambda *_: share_tag + ) + return modify_mock + + def test_cancel_sets_the_flag_on_event_and_share(self) -> None: + """cancel() writes isCancelled to the event and its share at once.""" + modify_mock = self._patch_wire() + + result = self.service.cancel(_cancellable_event()) + + self.assertTrue(result.is_cancelled) + self.assertEqual(result.record_change_tag, "fixtureA2") + + call = modify_mock.call_args + self.assertEqual(call.args[0], "private") + self.assertIs(call.kwargs["atomic"], True) + ops = call.kwargs["operations"] + self.assertEqual( + [(op.record.recordName, op.record.recordType) for op in ops], + [ + ("EventDetails:EVENT-FIXTURE-AAAA", "EventDetails"), + ("cloudkit.zoneshare", "cloudkit.share"), + ], + ) + for op in ops: + self.assertEqual(op.operationType, "update") + field = op.record.fields.get("isCancelled") + self.assertIsNotNone(field) + assert field is not None + self.assertEqual(field.value, 1) + self.assertEqual(ops[0].record.recordChangeTag, "fixtureA1") + self.assertEqual(ops[1].record.recordChangeTag, "shareFixtureA1") + + def test_cancel_false_reinstates_the_event(self) -> None: + """cancel(cancelled=False) writes a zero rather than skipping.""" + modify_mock = self._patch_wire() + + self.service.cancel(_cancellable_event(), cancelled=False) + + for op in modify_mock.call_args.kwargs["operations"]: + field = op.record.fields.get("isCancelled") + assert field is not None + self.assertEqual(field.value, 0) + + def test_cancel_sends_the_entitlement_token_as_a_hint(self) -> None: + """The event write carries the token Apple demands, and only there.""" + modify_mock = self._patch_wire() + + self.service.cancel(_cancellable_event()) + + ops = modify_mock.call_args.kwargs["operations"] + hint = ops[0].record.fields.get("hint") + self.assertIsNotNone(hint) + assert hint is not None + self.assertEqual( + json.loads(str(hint.value)), + {"subscriptionAccessToken": "token-fixture"}, + ) + # The share record is not gated on the token and must not carry it. + self.assertNotIn("hint", ops[1].record.fields) + + def test_cancel_writes_the_event_when_the_share_is_unreadable(self) -> None: + """An unreadable share still lets the event itself be cancelled.""" + modify_mock = self._patch_wire(share_tag=None) + + self.service.cancel(_cancellable_event()) + + ops = modify_mock.call_args.kwargs["operations"] + self.assertEqual(len(ops), 1) + self.assertEqual(ops[0].record.recordType, "EventDetails") + + def test_cancel_uses_the_shared_scope_for_a_shared_event(self) -> None: + """A shared event is written through the shared database.""" + modify_mock = self._patch_wire() + self._monkeypatch.setattr( + self.service, "_shared_zone_owner", lambda _: "_FAKE_OWNER" + ) + + self.service.cancel(_cancellable_event(scope=EventScope.SHARED)) + + self.assertEqual(modify_mock.call_args.args[0], "shared") + + def test_cancel_rejects_an_event_without_a_change_tag(self) -> None: + """An event carrying no change tag is refused before any wire call.""" + modify_mock = self._patch_wire() + event = _cancellable_event() + event = event.model_copy(update={"record_change_tag": None}) + + with self.assertRaises(InvitesApiError): + self.service.cancel(event) + + modify_mock.assert_not_called() + + def test_cancel_raises_when_the_event_is_missing_from_the_response(self) -> None: + """A response without the event record is an error, not a silent pass.""" + self._patch_wire() + empty = CKModifyResponse.model_validate({"records": []}) + self._monkeypatch.setattr( + self.service.raw, "modify", MagicMock(return_value=empty) + ) + + with self.assertRaises(InvitesApiError): + self.service.cancel(_cancellable_event()) + + def test_share_change_tag_reads_the_tag_off_the_share_record(self) -> None: + """The share's own change tag comes back from the zone lookup.""" + lookup = CKLookupResponse.model_validate( + load_invites_fixture("event_lookup_response.json") + ) + self._monkeypatch.setattr( + self.service.raw, "lookup", MagicMock(return_value=lookup) + ) + + tag = self.service._share_change_tag( + "private", CKZoneIDReq(zoneName="EVENT-FIXTURE-AAAA") + ) + + self.assertEqual(tag, "shareA1") + + def test_share_change_tag_is_none_when_the_lookup_fails(self) -> None: + """A failed share lookup degrades to None instead of propagating.""" + self._monkeypatch.setattr( + self.service.raw, + "lookup", + MagicMock(side_effect=InvitesApiError("nope")), + ) + + tag = self.service._share_change_tag( + "private", CKZoneIDReq(zoneName="EVENT-FIXTURE-AAAA") + ) + + self.assertIsNone(tag) + + +class FeatureAccessRequestTest(unittest.TestCase): + """Tests for the raw entitlement-gateway call.""" + + def _client(self, session: MagicMock) -> CloudKitInvitesClient: + return CloudKitInvitesClient( + "https://example.com/database/1/container/env", + session, + {"dsid": "12345"}, + ) + + def test_feature_access_asks_the_gateway_for_one_feature(self) -> None: + """The account's own gateway URL is called with the feature header.""" + session = MagicMock() + session.get.return_value.json.return_value = [ + { + "featureKey": CREATE_EVENT_FEATURE, + "canUse": True, + "accessToken": "token-fixture", + "cacheTill": "2100-01-01T00:00:00Z", + } + ] + + access = self._client(session).feature_access(CREATE_EVENT_FEATURE) + + self.assertEqual(access.access_token, "token-fixture") + url = session.get.call_args.args[0] + self.assertEqual( + url, + f"{GATEWAY_BASE_URL}/accounts/12345/subscriptions/features", + ) + self.assertEqual( + session.get.call_args.kwargs["headers"], + {"x-apple-softwarecapabilityflags": CREATE_EVENT_FEATURE}, + ) + + def test_feature_access_wraps_a_non_json_reply(self) -> None: + """An HTML error page from the gateway surfaces as an Invites error.""" + session = MagicMock() + session.get.return_value.json.side_effect = ValueError("not json") + + with self.assertRaises(InvitesApiError): + self._client(session).feature_access(CREATE_EVENT_FEATURE) + + def test_feature_access_translates_transport_errors(self) -> None: + """A transport failure is reported as an Invites error, not a raw one.""" + session = MagicMock() + session.get.side_effect = PyiCloudAPIResponseException("boom", code="500") + + with self.assertRaises(InvitesError): + self._client(session).feature_access(CREATE_EVENT_FEATURE) + + +class EntitlementTokenTest(unittest.TestCase): + """Tests for fetching and caching the event-editing entitlement.""" + + def setUp(self) -> None: + self.service = InvitesService( + service_root="https://example.com", + session=MagicMock(), + params={}, + ) + + @pytest.fixture(autouse=True) + def _monkeypatch(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Expose pytest's monkeypatch fixture to unittest-style tests.""" + self._monkeypatch = monkeypatch + + def _stub_gateway(self, access: FeatureAccess) -> MagicMock: + fetch = MagicMock(return_value=access) + self._monkeypatch.setattr(self.service.raw, "feature_access", fetch) + return fetch + + def test_token_is_fetched_once_while_it_stays_valid(self) -> None: + """A live grant is reused instead of fetched again on every write.""" + fetch = self._stub_gateway( + FeatureAccess( + feature_key=CREATE_EVENT_FEATURE, + can_use=True, + access_token="token-fixture", + cache_till=datetime(2099, 1, 1, tzinfo=timezone.utc), + ) + ) + + self.assertEqual(self.service._entitlement_token(), "token-fixture") + self.assertEqual(self.service._entitlement_token(), "token-fixture") + + fetch.assert_called_once_with(CREATE_EVENT_FEATURE) + + def test_expired_token_is_fetched_again(self) -> None: + """A grant past its cacheTill is replaced rather than reused.""" + fetch = self._stub_gateway( + FeatureAccess( + feature_key=CREATE_EVENT_FEATURE, + can_use=True, + access_token="token-fixture", + cache_till=datetime(2000, 1, 1, tzinfo=timezone.utc), + ) + ) + + self.service._entitlement_token() + self.service._entitlement_token() + + self.assertEqual(fetch.call_count, 2) + + def test_account_without_the_feature_raises_before_writing(self) -> None: + """An account Apple will not let edit events fails with its own error.""" + self._stub_gateway( + FeatureAccess(feature_key=CREATE_EVENT_FEATURE, can_use=False) + ) + + with self.assertRaises(InvitesEntitlementError): + self.service._entitlement_token() + + def test_feature_access_reads_the_gateway_payload(self) -> None: + """The gateway's list payload is parsed into the matching feature.""" + access = parse_feature_access( + [ + {"featureKey": "apps.rsvp.other", "canUse": False}, + { + "featureKey": CREATE_EVENT_FEATURE, + "canUse": True, + "accessToken": "token-fixture", + "cacheTill": "2100-01-01T00:00:00Z", + }, + ], + CREATE_EVENT_FEATURE, + ) + + self.assertTrue(access.can_use) + self.assertEqual(access.access_token, "token-fixture") + self.assertEqual(access.cache_till, datetime(2100, 1, 1, tzinfo=timezone.utc)) + self.assertTrue(access.usable_at(datetime(2026, 1, 1, tzinfo=timezone.utc))) + + def test_feature_access_defaults_to_unusable_when_absent(self) -> None: + """A payload without the feature is treated as no entitlement.""" + access = parse_feature_access([], CREATE_EVENT_FEATURE) + + self.assertFalse(access.can_use) + self.assertIsNone(access.access_token) + + def test_feature_access_survives_an_unparsable_cache_till(self) -> None: + """A grant Apple dates oddly is kept, but never treated as cached.""" + access = parse_feature_access( + [ + { + "featureKey": CREATE_EVENT_FEATURE, + "canUse": True, + "accessToken": "token-fixture", + "cacheTill": 4102444800000, + } + ], + CREATE_EVENT_FEATURE, + ) + + self.assertTrue(access.can_use) + self.assertIsNone(access.cache_till) + self.assertFalse(access.usable_at(datetime(2026, 1, 1, tzinfo=timezone.utc))) + + if __name__ == "__main__": unittest.main()