From ac5ad4b6966df41a50efea4fe46ebded33fe1552 Mon Sep 17 00:00:00 2001 From: mrjarnould Date: Fri, 4 Sep 2026 22:23:00 +0200 Subject: [PATCH] feat(invites): add `cancel()` for events you host Cancelling an event is the first `EventDetails` write this library can do, and it needs two things the read paths never did. Apple gates event edits behind a subscription feature, and a write that cannot prove the entitlement comes back `502 INTERNAL_ERROR` with nothing in it -- indistinguishable from an unsupported operation. The proof is a token from a gateway outside the container, which `entitlement.py` fetches and `cancel()` carries in the record's `hint` field. The grant lasts a week, so it is cached until shortly before Apple's own `cacheTill`. The `cloudkit.share` record keeps its own copy of `isCancelled` and does not follow the event: cancelling only the event leaves the share reading as live to guests. So both records go in one atomic modify. `cancel()` takes `cancelled=True` rather than being one-way, because Apple accepts both directions and an accidental cancel should be recoverable. Publishing does not ship. `isPublished` is a PCS-encrypted field, and Apple answers `BAD_REQUEST -- expected type ENCRYPTED_NUMBER_INT64` to every unencrypted encoding of it, so it waits on PCS support. A read says which flags are writable: the ones Apple sends without `isEncrypted: true`. Verified against a live account on a guest-free event: cancel and reinstate both take effect on the event and its share, the returned event's change tag can be passed straight back, and the token is fetched once across several writes. Co-Authored-By: Claude Opus 5 --- .vscode/cspell.json | 2 + README.md | 28 +- pyicloud/services/invites/__init__.py | 2 + pyicloud/services/invites/client.py | 39 ++ pyicloud/services/invites/entitlement.py | 83 +++++ pyicloud/services/invites/service.py | 161 ++++++++- tests/fixtures/invites/README.md | 25 +- .../invites/event_cancel_modify_response.json | 159 ++++++++ tests/test_invites.py | 341 ++++++++++++++++++ 9 files changed, 828 insertions(+), 12 deletions(-) create mode 100644 pyicloud/services/invites/entitlement.py create mode 100644 tests/fixtures/invites/event_cancel_modify_response.json 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()