diff --git a/specifications/features.md b/specifications/features.md index f5a9abfc3..83763207a 100644 --- a/specifications/features.md +++ b/specifications/features.md @@ -1066,6 +1066,10 @@ The core SDK provides an API for wrapper SDKs to supply Ably with analytics info - `(RSH2c)` (Moved to [`RSH8g`](#RSH8g) ). - `(RSH2d)` (Moved to [`RSH8h`](#RSH8h) ). - `(RSH2e)` (Moved to [`RSH8i`](#RSH8i) ). + - `(RSH2f)` On platforms where changes to the push transport details are delivered to the application rather than to the library (eg an FCM registration token refresh observed by the application, or an ActivityKit token update), `Push#updateToken(token: PushDeviceToken)` (see [`PDT1`](#PDT1)) provides the means for the application to deliver the new details to the library: + - `(RSH2f1)` The provided token is validated: it must carry a supported `transportType` and a non-empty `token`. An invalid token is rejected with an error with code `40000` and status code 400, without any effect on the `LocalDevice` or the Activation State Machine. + - `(RSH2f2)` `updateToken` requires that the device has completed activation, ie that the `LocalDevice` has a `deviceIdentityToken`; otherwise it is rejected with an error with code `40000`, without any effect. (The error should direct the caller to `Push#activate`.) + - `(RSH2f3)` The new details are applied to the `LocalDevice` push `recipient` (for an `apns` token, to the token slot indicated by its `apnsTokenType`, per [`PCP3a`](#PCP3a)) and persisted, and a `GotPushDeviceDetails` event is sent to [the state machine](#RSH3) per [`RSH8g`](#RSH8g). The ensuing registration sync proceeds per [`RSH3d3`](#RSH3d3). ### Activation State Machine @@ -1081,7 +1085,7 @@ The core SDK provides an API for wrapper SDKs to supply Ably with analytics info - `(RSH3a2a)` If the local device has `deviceIdentityToken`, performs a validation of the local DeviceDetails via the following steps. [`RSH3a2b`](#RSH3a2b) onwards then don't apply. - `(RSH3a2a1)` Checks the compatibilty of the present client with the existing registration: if the `LocalDevice` has a non-empty `clientId`, and the present identified client has a different (non-null) `clientId`, then a `SyncRegistrationFailed` event should be fired containing an error with `code` 61002, and skips to [`RSH3a2a4`](#RSH3a2a4). - `(RSH3a2a2)` If a custom `registerCallback` was provided to `Push#activate`, pass it the local `DeviceDetails`. - - `(RSH3a2a3)` Otherwise, makes an asynchronous HTTP PUT request to `/push/deviceRegistrations/:deviceId` using the local `DeviceDetails` with the push details as body. When the registration validation request is complete, a `RegistrationSynced` or `SyncRegistrationFailed` event should be fired. + - `(RSH3a2a3)` Otherwise, performs the registration sync described in [`RSH3d3b`](#RSH3d3b): an asynchronous HTTP PATCH request to `/push/deviceRegistrations/:deviceId` carrying the complete `push.recipient`. (A library may equivalently perform the sync as an HTTP PUT request to the same path using the full local `DeviceDetails` with the push details as body; this is a permitted legacy equivalent, as implemented by some existing libraries.) When the registration validation request is complete, a `RegistrationSynced` or `SyncRegistrationFailed` event should be fired. - `(RSH3a2a4)` Transitions to `WaitingForRegistrationSync`. - `(RSH3a2b)` If the local device does not have `id` or `deviceSecret`, both are generated locally. The `id` must be a unique identifier (e.g. UUID, GUID). The `deviceSecret` must be created using secure random data with sufficient entropy to generate a digest of at least 32 bytes (eg using sha256) and encoding that digest with base64. The local `DeviceDetails` is updated with the resulting `deviceId` and `deviceSecret`. (Note: a previous statement about what to do if the `id` or `deviceSecret` are lost has been removed since it was redundant to [`RSH8j`](#RSH8j).) - `(RSH3a2c)` If the local device has the necessary push details (registration token, etc.), sends a `GotPushDeviceDetails` event. @@ -1125,7 +1129,7 @@ The core SDK provides an API for wrapper SDKs to supply Ably with analytics info - `(RSH3d2d)` Transitions to `WaitingForDeregistration`. - `(RSH3d3)` On event `GotPushDeviceDetails` (note that this will only happen on platforms whose push device details, after first set, can change, e. g. FCM's registration token refresh): - `(RSH3d3a)` If a custom `registerCallback` was provided to `Push#activate`, pass it the local `DeviceDetails` updated with the push details. - - `(RSH3d3b)` Otherwise, make an asynchronous PATCH HTTP request to [/push/deviceRegistrations/:deviceId](/rest-api/#update-device-registration) using the local `DeviceDetails` 's push details as body (but only the changed fields, as described in [the REST endpoint documentation](/rest-api/#update-device-registration)). This operation requires [push device authentication](#push-device-authentication). + - `(RSH3d3b)` Otherwise, make an asynchronous PATCH HTTP request to [/push/deviceRegistrations/:deviceId](/rest-api/#update-device-registration) with the changed fields as body, as described in [the REST endpoint documentation](/rest-api/#update-device-registration). When the push details have changed, the body carries the complete `push.recipient` — including any unchanged token variants, per [`RSH8l2`](#RSH8l2) — together with any other `LocalDevice` attribute that has changed (eg a `clientId` newly set per [`RSH8d`](#RSH8d)). This operation requires [push device authentication](#push-device-authentication). This PATCH is the canonical registration sync: [`RSH3a2a3`](#RSH3a2a3) and [`RSH3f1`](#RSH3f1) perform the same operation. - `(RSH3d3c)` Either way, when the registration is done, a `RegistrationSynced` or `SyncRegistrationFailed` event should be fired. - `(RSH3d3d)` Transitions to `WaitingForRegistrationSync`. - `(RSH3e)` State `WaitingForRegistrationSync`: @@ -1162,7 +1166,7 @@ The core SDK provides an API for wrapper SDKs to supply Ably with analytics info ### Push device authentication - `(RSH6)` In platforms that support receiving push notifications, and have undergone push registration, are capable of authenticating themselves to the Ably server in order that certain push admin operations can be authorized. - - `(RSH6a)` If a device has completed activation and has a `deviceIdentityToken` then push device authentication is performed for a request by adding an `X-Ably-DeviceToken` request header whose value is the `deviceIdentityToken`. This header has always been `X-Ably-DeviceToken`, but has previously been mistakenly documented as `X-Ably-DeviceIdentityToken` in the hope of renaming it to avoid confusion with APNs device token. It was never renamed. + - `(RSH6a)` If a device has completed activation and has a `deviceIdentityToken` then push device authentication is performed for a request by adding an `X-Ably-DeviceToken` request header whose value is the `deviceIdentityToken` — the raw token value, not base64-encoded. (Non-normative: some libraries have historically base64-encoded the header value, which the service tolerates, but this is not conformant.) This header has always been `X-Ably-DeviceToken`, but has previously been mistakenly documented as `X-Ably-DeviceIdentityToken` in the hope of renaming it to avoid confusion with APNs device token. It was never renamed. - `(RSH6b)` If a device has not completed but has a `deviceSecret` then push device authentication is performed for a request by adding an `X-Ably-DeviceSecret` request header whose value is the `deviceSecret`. ### Push channels @@ -1196,11 +1200,16 @@ The core SDK provides an API for wrapper SDKs to supply Ably with analytics info - `(RSH8c)` Following successful registration of a `LocalDevice`, following the procedure in [`RSH3c2a`](#RSH3c2a), the now known `deviceIdentityToken` is set and persisted. - `(RSH8d)` If the `LocalDevice` is created by an unidentified client (see [`RSA7`](#RSA7) ) and therefore has no `clientId` set, but the client subsequently becomes identified (as a result of [`RSA7b2`](#RSA7b2) or [`RSA7b3`](#RSA7b3) ), then the `LocalDevice` `clientId` is set and persisted. - `(RSH8e)` If the `LocalDevice` `clientId` becomes set as a result of [`RSH8d`](#RSH8d), and the `LocalDevice` is already registered (ie the `deviceIdentityToken` is set), and the ActivationStateMachine is in any state other than `NotActivated`, then a `GotPushDeviceDetails` event is sent to [the state machine](#RSH3) once the effects of [`RSH8d`](#RSH8d) are visible, ie. once `LocalDevice` `clientId` is set. - - `(RSH8f)` If the `LocalDevice` is created by an unidentified client (see [`RSA7`](#RSA7) ) and therefore has no `clientId` set, but on receipt of a registration response (see [`RSH3c2`](#RSH3c2) ) the registered device has a non-empty `clientId`, then the `LocalDevice` `clientId` is set with that `clientId`. + - `(RSH8f)` If the `LocalDevice` is created by an unidentified client (see [`RSA7`](#RSA7) ) and therefore has no `clientId` set, but on receipt of a registration response (see [`RSH3c2`](#RSH3c2) ) or a registration-sync response (see [`RSH3d3b`](#RSH3d3b), [`RSH3a2a3`](#RSH3a2a3)) the registered device has a non-empty `clientId`, then the `LocalDevice` `clientId` is set with that `clientId`. (Non-normative: this relies on the sync response returning the updated `DeviceDetails`, which the REST endpoint provides for both the PATCH and legacy PUT forms.) - `(RSH8g)` Whenever any change arises of the push transport details for local device (eg an FCM registration token update triggered by the platform), a `GotPushDeviceDetails` event is sent to [the state machine](#RSH3). - `(RSH8h)` If an attempt to obtain the push transport details for local device (eg an FCM registration token) fails, a `GettingPushDeviceDetailsFailed` event containing the indicated error is sent to [the state machine](#RSH3). - `(RSH8i)` Each time the library is instantiated, if the LocalDevice has push device details (eg an APNS deviceToken), and if the platform supports it, it must verify the validity of those details (eg by requesting a token from the platform and comparing that with the already-known token). If as a result there are updated details, then an update to the Ably server is triggered by sending a `GotPushDeviceDetails` event to [the state machine](#RSH3). - `(RSH8j)` This clause has been replaced by [`RSH8a1`](#RSH8a1). + - `(RSH8l)` In platforms supporting multiple push transport token variants (eg the APNs `default`, `location` and `pushToStart` tokens), the local device's push transport details comprise the set of registered token variants, represented in the `recipient` per [`PCP3a`](#PCP3a): + - `(RSH8l1)` Each registered variant is a `recipient`-related attribute for the purposes of [`RSH8a`](#RSH8a): all registered variants are persisted, and loaded as part of the `LocalDevice` state. + - `(RSH8l2)` The registration, update or removal of any single variant constitutes a change of the push transport details for the local device: [`RSH8g`](#RSH8g) applies, and the ensuing sync (per [`RSH3d3`](#RSH3d3)) carries the complete updated `recipient` in its changed fields, ie including the unchanged variants. + - `(RSH8l3)` The verification described in [`RSH8i`](#RSH8i) applies to each registered variant independently, to the extent that the platform supports verifying tokens of that variant. + - `(RSH8l4)` (Non-normative:) Per-instance tokens — such as the per-activity update tokens of iOS Live Activities — are deliberately not represented in the `LocalDevice`: their lifetime is that of an activity instance, not the device. Live Activity updates are instead delivered over APNs broadcast channels (see [`RSH1e`](#RSH1e)), for which only the device-scoped `pushToStart` token needs registering. ## Types @@ -2058,8 +2067,16 @@ The core SDK provides an API for wrapper SDKs to supply Ably with analytics info - `(PCP1)` details of the push registration for a given device, consisting of the following attributes: - `(PCP2)` errorReason ErrorInfo - (optional) any error information associated with the registration - `(PCP3)` recipient - a map of string key/value pairs containing details of the push transport and address + - `(PCP3a)` For an APNs recipient, in addition to (or instead of) the `deviceToken` attribute, the recipient may contain an `apnsDeviceTokens` attribute: a map from token slot name to device token, carrying the device's APNs token of each registered variant. The well-known slot names are `default` (equivalent to the `deviceToken` attribute; if both are present they must carry the same value), `location` (a location push token), and `pushToStart` (a Live Activity push-to-start token). Slot names are extensible: libraries must treat slot names opaquely, preserving unknown slots when serializing, persisting or syncing the recipient. (Note: this makes the recipient a nested structure, notwithstanding the "string key/value pairs" description in [`PCP3`](#PCP3).) - `(PCP4)` state - the state of the push registration, one of `Active`, `Failing`, `Failed` +#### PushDeviceToken + +- `(PDT1)` A `PushDeviceToken` represents a single push transport token being delivered to the library by the application (see [`RSH2f`](#RSH2f)), consisting of the following attributes: +- `(PDT2)` transportType string - the push transport the token belongs to, one of `fcm`, `apns` or `web` +- `(PDT3)` token string - the token value +- `(PDT4)` apnsTokenType string? - for `apns` tokens only: the token slot the token belongs to, per [`PCP3a`](#PCP3a); when absent, defaults to `default` + ### Client library introspection {#introspection} #### ClientInformation @@ -2792,6 +2809,14 @@ Each type, method, and attribute is labelled with the name of one or more clause deactivate( deregisterCallback: ((ErrorInfo?, deviceId: String?) -> io)? ) => io ErrorInfo? // RSH2b + // Only on platforms where changes to the push transport details are + // delivered to the application rather than to the library: + updateToken(token: PushDeviceToken) => io // RSH2f + + class PushDeviceToken: // PDT1 + transportType: String // PDT2 + token: String // PDT3 + apnsTokenType: String? // PDT4 class PushAdmin: // RSH1 publish(recipient: JsonObject, data: JsonObject) => io // RSH1a diff --git a/uts/README.md b/uts/README.md index 8cc4d9b9d..facd15476 100644 --- a/uts/README.md +++ b/uts/README.md @@ -9,7 +9,8 @@ uts/ ├── rest/ │ ├── unit/ # REST unit tests (mocked HTTP) │ │ ├── helpers/ -│ │ │ └── mock_http.md # Mock HTTP infrastructure spec +│ │ │ ├── mock_http.md # Mock HTTP infrastructure spec +│ │ │ └── mock_push_platform.md # Push platform primitives + mock spec │ │ ├── auth/ # RSA — authentication │ │ ├── channel/ # RSL — channel operations │ │ ├── encoding/ # RSL4/RSL6 — message encoding @@ -43,13 +44,14 @@ uts/ | Category | Count | Description | |----------|-------|-------------| -| REST unit | 40 | Mocked HTTP client tests | -| REST integration | 11 | Ably sandbox tests | +| REST unit | 47 | Mocked HTTP client tests | +| REST integration (direct) | 12 | Ably sandbox tests | +| REST integration (proxy) | 2 | Fault injection via Go proxy | | Realtime unit | 54 | Mocked WebSocket tests | | Realtime integration (direct) | 13 | Direct sandbox tests | | Realtime integration (proxy) | 7 | Fault injection via Go proxy | -| Helper specs | 4 | Mock infrastructure definitions | -| **Total** | **129** | | +| Helper specs | 5 | Mock infrastructure definitions | +| **Total** | **140** | | ## Three Test Tiers diff --git a/uts/docs/completion-status.md b/uts/docs/completion-status.md index f3e39469a..85313d2dc 100644 --- a/uts/docs/completion-status.md +++ b/uts/docs/completion-status.md @@ -298,12 +298,12 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | Spec item | Description | UTS test spec | |-----------|-------------|---------------| | RSH1 | Push#admin object (RSH1a–RSH1c5) | Yes — `rest/unit/push/push_admin_publish.md` (RSH1, RSH1a), `rest/unit/push/push_device_registrations.md` (RSH1b1–RSH1b5), `rest/unit/push/push_channel_subscriptions.md` (RSH1c1–RSH1c5), `rest/integration/push_admin.md` (RSH1a–RSH1c5) | -| RSH2 | Platform-specific push operations (RSH2a–RSH2e) | | -| RSH3 | Activation state machine (RSH3a–RSH3g3) | | -| RSH4–RSH5 | Event queueing and sequential handling | | -| RSH6 | Push device authentication (RSH6a–RSH6b) | | +| RSH2 | Platform-specific push operations (RSH2a–RSH2b, RSH2f) | Yes — `rest/unit/push/push_activation_state_machine.md` (RSH2a, RSH2b), `rest/unit/push/push_update_token.md` (RSH2f1–RSH2f3; pending token-variants spec extension), `rest/integration/push_activation.md` (RSH2a, RSH2b, RSH2f) | +| RSH3 | Activation state machine (RSH3a–RSH3h) | Yes — `rest/unit/push/push_activation_state_machine.md` (RSH3a–RSH3g3b), `rest/unit/push/push_activation_persistence.md` (RSH3h, RSH3a2c), `rest/unit/push/push_update_token.md` (RSH3d3, RSH3e), `rest/integration/push_activation.md` (RSH3a2a3, RSH3a2c, RSH3b3c) | +| RSH4–RSH5 | Event queueing and sequential handling | Yes — `rest/unit/push/push_activation_event_queue.md` (RSH4, RSH5), `rest/unit/push/push_update_token.md` (RSH4 via updateToken) | +| RSH6 | Push device authentication (RSH6a–RSH6b) | Yes — `rest/unit/push/push_device_auth.md` (RSH6a, RSH6b + RSH1b3/RSH1b5/RSH1c3/RSH1c4 own-device clauses); RSH6a also asserted in `rest/unit/push/push_channels.md`, `rest/unit/push/push_activation_state_machine.md`; server acceptance of the RSH6a header verified in `rest/integration/push_activation.md` | | RSH7 | Push channels (RSH7a–RSH7e) | Yes — `rest/unit/push/push_channels.md`, `rest/integration/push_channels.md` | -| RSH8 | LocalDevice (RSH8a–RSH8k2) | | +| RSH8 | LocalDevice (RSH8a–RSH8l4) | Yes — `rest/unit/push/local_device.md` (RSH8, RSH8a, RSH8d–RSH8f, RSH8k), `rest/unit/push/push_activation_persistence.md` (RSH8a1, RSH8b, RSH8c), `rest/unit/push/push_activation_state_machine.md` (RSH8h), `rest/unit/push/push_update_token.md` (RSH8g, RSH8l2; RSH8l pending token-variants spec extension) | --- @@ -368,9 +368,10 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | Spec item | Description | UTS test spec | |-----------|-------------|---------------| -| PCS1–PCS5 | PushChannelSubscription | | -| PCD1–PCD7 | DeviceDetails | | -| PCP1–PCP4 | DevicePushDetails | | +| PCS1–PCS5 | PushChannelSubscription | Yes — `rest/unit/types/push_types.md` | +| PCD1–PCD7 | DeviceDetails | Yes — `rest/unit/types/push_types.md` | +| PCP1–PCP4 | DevicePushDetails | Yes — `rest/unit/types/push_types.md` (PCP3a variant slots: `rest/unit/push/push_update_token.md`; pending token-variants spec extension) | +| PDT1–PDT4 | PushDeviceToken (pending token-variants spec extension) | Partial — `rest/unit/push/push_update_token.md` (PDT4 via variant tests); dedicated type tests to be added with the spec PR | ### Client Library Introspection @@ -420,6 +421,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RSC15l | HTTP 5xx with/without error body parsed correctly | Yes — `rest/integration/proxy/rest_fallback.md` | | RSC15l | HTTP 4xx not retried, error parsed | Yes — `rest/integration/proxy/rest_fallback.md` | | RSL1k4 | Idempotent publish retry deduplication | Pending — `rest/integration/proxy/rest_fallback.md` (needs proxy enhancement) | +| RSH3c3a/RSH3d2c1/RSH3e3d/RSH3g3b/RSH4 | Push activation fault handling (registration failure + retry, deregistration 401/40005 client-side classification, rollback, queued deactivate, sync failure recovery) | Yes — `rest/integration/proxy/push_activation.md` | | RSC10 | Token renewal on HTTP 401 | Yes — `realtime/integration/proxy/rest_faults.md` | | RSC15m/REC2c2 | HTTP 503 error (no fallback, hosts disabled) | Yes — `realtime/integration/proxy/rest_faults.md` | | RTL6 | End-to-end publish and history | Yes — `realtime/integration/proxy/rest_faults.md` | @@ -447,11 +449,11 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | **EventEmitter** (RTE) | 6 | 0 | None | | **Backoff/jitter** (RTB) | 1 | 1 | Full | | **Wrapper SDK** (WP) | 7 | 0 | None | -| **Push notifications** (RSH) | 8 | 1 | Partial | +| **Push notifications** (RSH) | 8 | 8 | Full | | **Plugins** (PC/PT/VD) | 3 | 2 | Partial | | **Data types** | 30 | 12 | Partial | | **Option types** | 8 | 5 | Partial | -| **Push types** | 3 | 0 | None | +| **Push types** | 3 | 3 | Full | | **Introspection** (CR) | 1 | 0 | None | | **Defaults** (DF) | 1 | 0 | None | | **Compatibility** (RSF/RTF) | 2 | 2 | Full | diff --git a/uts/docs/proxy.md b/uts/docs/proxy.md index c52f57cdb..af58f72d9 100644 --- a/uts/docs/proxy.md +++ b/uts/docs/proxy.md @@ -29,7 +29,7 @@ client = Realtime(options: ClientOptions( endpoint: "localhost", # REC1b2: sets both restHost and realtimeHost port: session.proxy_port, tls: false, - useBinaryProtocol: false, # Required: Dart SDK doesn't implement msgpack + useBinaryProtocol: PROTOCOL == "msgpack", # data-path tests run both protocols; pin to false for SDKs without msgpack autoConnect: false # Note: explicit hostname endpoint automatically disables fallback hosts (REC2c2) )) @@ -224,7 +224,7 @@ ClientOptions( endpoint: "localhost", # REC1b2: sets both restHost and realtimeHost to "localhost" port: proxy_port, # The proxy session's assigned port tls: false, # Proxy serves plain HTTP/WS; TLS only upstream - useBinaryProtocol: false, # Required: SDK doesn't implement msgpack + useBinaryProtocol: PROTOCOL == "msgpack", # data-path tests run both protocols (see Protocol Variants in integration-testing.md); pin to false for SDKs without msgpack autoConnect: false # Explicit connect for test control # fallbackHosts: not needed — endpoint="localhost" auto-disables fallbacks (REC2c2) ) @@ -236,7 +236,7 @@ ClientOptions( 2. Tests use `create_proxy_session()` with rules, then connect SDK through the proxy 3. Tests use `AWAIT_STATE` for state assertions and record state changes for sequence verification 4. Tests verify behaviour via SDK state AND proxy event log where useful -5. All tests use `useBinaryProtocol: false` (SDK doesn't implement msgpack) +5. Proxy data-path tests follow the `## Protocol Variants` convention (`useBinaryProtocol: PROTOCOL == "msgpack"`); control-plane tests and SDKs that do not implement msgpack pin to JSON (`useBinaryProtocol: false`). See *Protocol Variants* in `integration-testing.md` — the proxy handles both protocols as of uts-proxy v0.3.0 6. All tests use `endpoint: "localhost"` which auto-disables fallback hosts (REC2c2) 7. Timeouts are generous (10-30s) since real network is involved 8. Each test file provisions a sandbox app in `BEFORE ALL TESTS` and cleans up in `AFTER ALL TESTS` diff --git a/uts/docs/writing-test-specs.md b/uts/docs/writing-test-specs.md index aa403e6b2..01ac363b0 100644 --- a/uts/docs/writing-test-specs.md +++ b/uts/docs/writing-test-specs.md @@ -294,6 +294,18 @@ mock_ws.active_connection.send_to_client(ProtocolMessage( mock_ws.active_connection.simulate_disconnect() ``` +### Push Platform Mock Infrastructure + +For push activation tests (`RSH2`/`RSH3`/`RSH8`), reference the push platform mock: + +```markdown +## Mock Push Platform Infrastructure + +See `uts/rest/unit/helpers/mock_push_platform.md` for the portable push platform primitives (`PushKeyValueStorage`, `requestToken`, `PushPlatformConfig`), the standard `ably.push.*` storage keys, and the `MockPushStorage` mock. +``` + +Key points (full details in the helper spec): the mock platform is installed with `install_push_platform(mock_push_platform)` before client construction, mirroring `install_mock(mock_http)`; `MockPushStorage` offers `dump()` for settled-state assertions, `seed()` for app-restart scenarios, `fail_writes`/`fail_reads` for blanket faults, and an `onOperation` handler for capturing the operation sequence into a local array (and per-key fault injection by raising). The token provider is a plain closure supplied as `requestToken`, counted or gated with local variables. + ## Proxy Integration Tests For detailed proxy infrastructure documentation, see `uts/docs/proxy.md`. @@ -452,7 +464,7 @@ ASSERT client.connection.errorReason.code == 40005 2. Tests use `create_proxy_session()` with rules, then connect SDK through the proxy 3. Tests use `AWAIT_STATE` for state assertions and record state changes for sequence verification 4. Tests verify behaviour via SDK state AND proxy event log where useful -5. All tests use `useBinaryProtocol: false` (SDK doesn't implement msgpack) +5. Proxy data-path tests follow the `## Protocol Variants` convention (`useBinaryProtocol: PROTOCOL == "msgpack"`); control-plane tests and SDKs that do not implement msgpack pin to JSON (`useBinaryProtocol: false`). See *Protocol Variants* in `integration-testing.md` — the proxy handles both protocols as of uts-proxy v0.3.0 6. All tests use `endpoint: "localhost"` which auto-disables fallback hosts (REC2c2) 7. Timeouts are generous (10-30s) since real network is involved 8. Each test file provisions a sandbox app in `BEFORE ALL TESTS` and cleans up in `AFTER ALL TESTS` @@ -1117,66 +1129,20 @@ The error object in `FAILS WITH error` represents the ErrorInfo associated with ## File Organization +The authoritative directory structure and per-category spec-file counts are maintained in `uts/README.md` — update it when adding spec files. In brief: + ``` rest/ - unit/ - helpers/ - mock_http.md # Mock HTTP infrastructure spec - auth/ - auth_callback.md # RSA8c, RSA8d - auth_scheme.md # RSA1-4, RSA4b - authorize.md # RSA10 - token_renewal.md # RSA4b4, RSA14 - client_id.md # RSA7, RSC17 - channel/ - publish.md # RSL1 - history.md # RSL2 - idempotency.md # RSL1k - rest_client.md # RSC7, RSC8, RSC13, RSC18 - fallback.md # RSC15, REC1, REC2 - time.md # RSC16 - stats.md # RSC6 - request.md # RSC19 - batch_publish.md # RSC22, BSP, BPR, BPF - presence/ - rest_presence.md # RSP1, RSP3, RSP4 - encoding/ - message_encoding.md # RSL4, RSL5, RSL6 - types/ - message_types.md # TM2, TM3, TM4 - error_types.md # TI1-5 - token_types.md # TD1-5, TK1-6, TE1-6 - options_types.md # TO3, AO2 - paginated_result.md # TG1-5 - integration/ - auth.md - publish.md - history.md - presence.md - pagination.md - time_stats.md + unit/ # mocked-HTTP tests: auth/, channel/, encoding/, presence/, push/, types/, helpers/, plus top-level *.md + integration/ # sandbox tests (+ integration/proxy/) realtime/ - unit/ - helpers/ - mock_websocket.md # Mock WebSocket infrastructure spec - client/ - realtime_client.md # RTC1, RTC2, RTC15, RTC16 - client_options.md # TO3 (Realtime-specific) - connection/ - connection_failures_test.md - connection_open_failures_test.md - ... - integration/ - proxy/ - connection_open_failures.md # RTN14 tests via proxy - connection_resume.md # RTN15 tests via proxy - heartbeat.md # RTN23 tests via proxy - channel_faults.md # RTL4, RTL5, RTL13, RTL14 via proxy - rest_faults.md # RSC10, RSC15 via proxy - connection_lifecycle_test.md # Direct sandbox tests - ... + unit/ # mocked-WebSocket tests: auth/, channels/, client/, connection/, presence/, helpers/ + integration/ # sandbox tests (+ integration/proxy/) +docs/ # this guide, writing-derived-tests.md, integration-testing.md, proxy.md, completion-status.md ``` +Spec file names are `snake_case.md`. Mock infrastructure (helper) specs live in `unit/helpers/`: `mock_http.md` and `mock_push_platform.md` under `rest/`, `mock_websocket.md` and `mock_vcdiff.md` under `realtime/`. + ## Completion Status Matrix When adding a new test spec, update the completion status matrix at `docs/completion-status.md` to reflect the newly covered spec items. This matrix tracks which spec items have UTS test specs and which do not. @@ -1269,7 +1235,7 @@ ASSERT captured_requests[0].headers["Authorization"] IS NOT null 2. Referencing `mock_http.captured_requests` -- Use local `captured_requests` array 3. Referencing `mock_http.request_count` -- Use local `request_count` variable 4. Not installing mock: Missing `install_mock(mock_http)` -- Always call `install_mock(mock_http)` after creating mock -5. Passing mock to client: `Rest(..., httpClient: mock_http)` -- Mock is installed globally via `install_mock()` +5. Passing mock to client: `Rest(..., httpClient: mock_http)` -- Mock is installed globally via `install_mock()`. The push platform mock follows the same rule via `install_push_platform()` (see `uts/rest/unit/helpers/mock_push_platform.md`) 6. Missing spec requirement summary -- Every test must have `**Spec requirement:**` or table 7. Using fixed WAITs for async operations -- Use polling with timeout or `AWAIT_STATE` 8. Not using unique channel names -- Generate unique names with random component diff --git a/uts/rest/integration/proxy/push_activation.md b/uts/rest/integration/proxy/push_activation.md new file mode 100644 index 000000000..557243a10 --- /dev/null +++ b/uts/rest/integration/proxy/push_activation.md @@ -0,0 +1,667 @@ +# Push Activation Proxy Integration Tests + +Spec points: `RSH3a2c`, `RSH3c3a`, `RSH3d2c1`, `RSH3e3d`, `RSH3f1`, `RSH3g2a`, `RSH3g3b`, `RSH4` + +## Test Type + +Proxy integration test — Ably sandbox via uts-proxy + +## Proxy Infrastructure + +See `uts/docs/proxy.md` for the full proxy infrastructure specification. + +## Corresponding Unit Tests + +- `uts/rest/unit/push/push_activation_state_machine.md` — `rest/unit/RSH3c3a/registration-failure-0`, `rest/unit/RSH3d2c1/deregister-401-succeeds-0`, `rest/unit/RSH3d2c1/deregister-40005-succeeds-1`, `rest/unit/RSH3g3b/deregister-failure-rollback-0` (mocked HTTP verifies the client-side classification and state transitions) +- `uts/rest/unit/push/push_activation_event_queue.md` — `rest/unit/RSH4/activate-queued-during-deregistration-0`, `rest/unit/RSH5/back-to-back-activate-deactivate-ordered-0` (mocked HTTP verifies event queueing and sequential handling) +- `uts/rest/unit/push/push_update_token.md` — `rest/unit/RSH3e3d/update-token-sync-failure-callback-4` (mocked HTTP verifies the fire-and-forget sync failure path) +- `uts/rest/unit/helpers/mock_push_platform.md` — the portable push platform primitives, `ably.push.*` storage keys, and `MockPushStorage` + +## Purpose + +The unit tier fully verifies the Activation State Machine's transitions with +mocked HTTP. What it cannot verify is that the SDK's error classification and +retry behaviour hold up against real HTTP framing, real status lines, and a +real server on the other side of the faulted requests. These tests run the +activation flows against the Ably sandbox through the proxy, injecting faults +on the registration endpoints, and add one assertion the unit tier cannot +make: a **direct** admin client (bypassing the proxy) inspects the server-side +registration, so a test can prove that a faulted DELETE never reached the +server — i.e. that the RSH3d2c1 classification of 401/40005 as `Deregistered` +is purely client-side. + +Only the network is real. As in the unit tier, tests use `MockPushStorage` +and `install_push_platform` — a real platform push service is neither +available nor needed. + +## Notes + +### Pre-seeded ablyChannel recipient (RSH3a2c) + +Tests pre-seed `ably.push.pushRecipient` in storage with an `ablyChannel` +recipient before creating the client: + +```json +{ + "transportType": "ablyChannel", + "channel": "", + "ablyKey": "", + "ablyUrl": "" +} +``` + +Per `RSH3a2c`, a local device that already has push details fires +`GotPushDeviceDetails` directly on `CalledActivate` — so `requestToken` is +never called, and no fake FCM/APNs token has to survive server-side recipient +validation. The `ablyUrl` MUST be the **direct** sandbox URL, not the proxy: +it is consumed server-side (the server publishes notifications to that URL), +so a proxy address would be meaningless outside the test host. + +### Token auth through the proxy + +The proxy serves plain HTTP (`tls: false`), and Basic auth is prohibited over +an insecure connection (RSA1). As in `rest_fallback.md`, clients therefore +authenticate via an `authCallback` whose inner client talks **directly** to +the sandbox (never through the proxy), so token requests are never +intercepted by fault-injection rules. + +### Fault injection style + +Deregistration and sync tests inject their faults **late** (rules added only +after a fully real activation through the proxy), per the guidance in +`uts/docs/integration-testing.md`. The `http_respond` action synthesises the +response without forwarding the request (Approach B) — here that is not a +compromise but the point: because the faulted request never reaches the +server, the direct admin client can prove the classification happened +client-side. The registration-failure and delay tests need their rule at +session creation, since the fault targets the first registration POST itself; +in each case every subsequent interaction passes through to the real server. + +### Timeouts + +All `WITH timeout` and `poll_until` durations are **wall-clock (real) time** +(see `uts/docs/proxy.md` convention 11). Timeouts are generous because a real +network and sandbox are involved. + +### ably-js deviation (RSH3e3d) + +`RSH3e3d` routes sync failures to the `updatedCallback` provided to +`Push#activate`. ably-js currently delivers them to the deprecated +`updateFailedCallback` (`RSH3e3a`) instead; derived ably-js tests must adapt +the callback assertion in `sync-failure-recovery-0` accordingly (see the +Notes of `uts/rest/unit/push/push_update_token.md`). + +## Sandbox Setup + +Tests run against the Ably Sandbox via a programmable proxy. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +App deletion removes any device registrations a test left behind; per-test +cleanup of server-side registrations is best-effort only. + +### Common Cleanup + +```pseudo +AFTER EACH TEST: + IF session IS NOT null: + session.close() + uninstall_push_platform() +``` + +### Shared Helpers + +```pseudo +SANDBOX_URL = "https://sandbox.realtime.ably-nonprod.net" + +FUNCTION token_auth_callback(api_key): + RETURN (params, cb) => { + # A temporary Rest client pointed directly at the sandbox (bypassing the + # proxy) obtains a TokenDetails object; token requests are never + # intercepted by proxy fault-injection rules. + inner_rest = Rest(options: ClientOptions( + key: api_key, + endpoint: SANDBOX_URL + )) + inner_rest.auth.requestToken().then( + (token) => cb(null, token), + (err) => cb(err, null) + ) + } + +# The server-consumable recipient pre-seeded into storage (see Notes). +# ablyUrl is the DIRECT sandbox URL — never the proxy. +FUNCTION ably_channel_recipient(channel_name): + RETURN { + "transportType": "ablyChannel", + "channel": channel_name, + "ablyKey": api_key, + "ablyUrl": SANDBOX_URL + } + +# A push client routed through the proxy, over a fresh MockPushStorage +# pre-seeded with an ablyChannel recipient so that requestToken is never +# called (RSH3a2c). Each test creates its own storage and channel name. +FUNCTION proxy_push_client(session, storage, channel_name): + storage.seed({ + "ably.push.pushRecipient": json_encode(ably_channel_recipient(channel_name)) + }) + install_push_platform(MockPushPlatform( + platform: "android", + formFactor: "phone", + storage: storage, + requestToken: () => RAISE Error("requestToken must not be called — recipient pre-seeded (RSH3a2c)") + )) + RETURN Rest(options: ClientOptions( + authCallback: token_auth_callback(api_key), + endpoint: "localhost", # REC2c2: auto-disables fallback hosts + port: session.proxy_port, + tls: false, + useBinaryProtocol: false + )) + +# An admin client that bypasses the proxy entirely — the source of +# server-side ground truth for these tests. +FUNCTION direct_admin_client(): + RETURN Rest(options: ClientOptions( + key: api_key, + endpoint: SANDBOX_URL, + useBinaryProtocol: false + )) + +# Runs a fully real activation through the proxy (no rules firing) and +# returns the registered device id. +FUNCTION activate_through_proxy(client, storage): + AWAIT client.push.activate() WITH timeout 15s + poll_until_success(() => storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails", timeout: 10s) + RETURN storage.dump()["ably.push.deviceId"] +``` + +--- + +## RSH3d2c1 — deregistration 401 is classified as Deregistered without the DELETE reaching the server + +**Test ID**: `rest/proxy/RSH3d2c1/deregister-401-classified-0` + +**Corresponding unit test**: `rest/unit/RSH3d2c1/deregister-401-succeeds-0` (`push_activation_state_machine.md`) + +| Spec | Requirement | +|------|-------------| +| RSH3d2c1 | `Deregistered` should be fired if the DELETE returns a 2xx status, 401 (unauthorized), or error code 40005 (invalid credentials) | +| RSH3g2a | On `Deregistered`, clears all local `DeviceDetails` | + +A fully real activation registers the device through the proxy. The proxy +then answers the deregistration DELETE with a synthetic 401 (real HTTP +framing and status line) **without forwarding it**. `deactivate()` must +resolve and clear local state — and, the assertion the unit tier cannot make: +the server-side registration still exists, proving the DELETE never reached +the server and the 401→`Deregistered` classification is client-side. + +### Setup + +```pseudo +session = create_proxy_session(endpoint: "nonprod:sandbox") + +channel_name = "push-proxy-RSH3d2c1-401-" + random_string() +storage = MockPushStorage() +client = proxy_push_client(session, storage, channel_name) +admin = direct_admin_client() + +# Real registration through the proxy +device_id = AWAIT activate_through_proxy(client, storage) + +# Server-side ground truth: the registration exists +registered = AWAIT admin.push.admin.deviceRegistrations.get(device_id) +ASSERT registered.id == device_id + +# Late fault injection: only the deregistration DELETE is faulted +session.add_rules([{ + "match": { "type": "http_request", "method": "DELETE", "pathContains": "/push/deviceRegistrations" }, + "action": { + "type": "http_respond", + "status": 401, + "body": { "error": { "message": "unauthorized", "code": 40100, "statusCode": 401 } } + }, + "times": 1, + "comment": "RSH3d2c1: answer the deregistration DELETE with 401 without forwarding it" +}]) +``` + +### Test Steps + +```pseudo +# Resolves despite the 401 — classified as Deregistered +AWAIT client.push.deactivate() WITH timeout 15s +poll_until_success(() => storage.dump()["ably.push.activationState"] == "NotActivated", timeout: 10s) +``` + +### Assertions + +```pseudo +# RSH3g2a — local state cleared +persisted = storage.dump() +ASSERT "ably.push.deviceIdentityToken" NOT IN persisted +ASSERT "ably.push.pushRecipient" NOT IN persisted + +# The DELETE never reached the server: the registration STILL exists. +# The 401 → Deregistered classification is purely client-side. +still_registered = AWAIT admin.push.admin.deviceRegistrations.get(device_id) +ASSERT still_registered.id == device_id + +# The proxy log confirms exactly one DELETE was issued (and answered by the rule) +log = session.get_log() +deletes = log.filter(e => e.type == "http_request" AND e.method == "DELETE" AND e.path CONTAINS "/push/deviceRegistrations") +ASSERT deletes.length == 1 + +# Best-effort cleanup of the orphaned server-side registration +AWAIT admin.push.admin.deviceRegistrations.remove(device_id) # ignore errors +``` + +--- + +## RSH3d2c1 — deregistration error code 40005 is classified as Deregistered without the DELETE reaching the server + +**Test ID**: `rest/proxy/RSH3d2c1/deregister-40005-classified-1` + +**Corresponding unit test**: `rest/unit/RSH3d2c1/deregister-40005-succeeds-1` (`push_activation_state_machine.md`) + +| Spec | Requirement | +|------|-------------| +| RSH3d2c1 | Error code 40005 (invalid credentials) is classified as `Deregistered` | +| RSH3g2a | On `Deregistered`, clears all local `DeviceDetails` | + +As `deregister-401-classified-0`, but the injected fault is an HTTP 400 whose +body carries error code 40005 — exercising the body-code (rather than +status-code) branch of the classification against a real HTTP response. + +### Setup + +```pseudo +session = create_proxy_session(endpoint: "nonprod:sandbox") + +channel_name = "push-proxy-RSH3d2c1-40005-" + random_string() +storage = MockPushStorage() +client = proxy_push_client(session, storage, channel_name) +admin = direct_admin_client() + +device_id = AWAIT activate_through_proxy(client, storage) + +session.add_rules([{ + "match": { "type": "http_request", "method": "DELETE", "pathContains": "/push/deviceRegistrations" }, + "action": { + "type": "http_respond", + "status": 400, + "body": { "error": { "message": "invalid credentials", "code": 40005, "statusCode": 400 } } + }, + "times": 1, + "comment": "RSH3d2c1: answer the deregistration DELETE with 400/40005 without forwarding it" +}]) +``` + +### Test Steps + +```pseudo +# Resolves despite the 40005 — classified as Deregistered +AWAIT client.push.deactivate() WITH timeout 15s +poll_until_success(() => storage.dump()["ably.push.activationState"] == "NotActivated", timeout: 10s) +``` + +### Assertions + +```pseudo +# RSH3g2a — local state cleared +persisted = storage.dump() +ASSERT "ably.push.deviceIdentityToken" NOT IN persisted +ASSERT "ably.push.pushRecipient" NOT IN persisted + +# The DELETE never reached the server: the registration STILL exists +still_registered = AWAIT admin.push.admin.deviceRegistrations.get(device_id) +ASSERT still_registered.id == device_id + +# Best-effort cleanup of the orphaned server-side registration +AWAIT admin.push.admin.deviceRegistrations.remove(device_id) # ignore errors +``` + +--- + +## RSH3d2c1, RSH3g3b — deregistration failure fails deactivate and rolls back; the retry deregisters end-to-end + +**Test ID**: `rest/proxy/RSH3d2c1/deregister-failure-rollback-2` + +**Corresponding unit test**: `rest/unit/RSH3g3b/deregister-failure-rollback-0` (`push_activation_state_machine.md`) + +| Spec | Requirement | +|------|-------------| +| RSH3d2c1 | Status codes other than 2xx/401/40005 fire `DeregistrationFailed` (40198 — a non-retriable 4xx, per the unit spec's convention) | +| RSH3g3a | Makes `Push#deactivate` return with the error | +| RSH3g3b | Transitions to the previous state (`WaitingForNewPushDeviceDetails` here) | + +The first DELETE is answered with a synthetic 400/40198 (`times: 1`, not +forwarded), so `deactivate()` fails and the machine rolls back — verified +both locally (identity token survives) and server-side (registration still +exists). The rule is then consumed, so a second `deactivate()` runs +end-to-end against the real server: local state cleared AND the server-side +registration gone. + +### Setup + +```pseudo +session = create_proxy_session(endpoint: "nonprod:sandbox") + +channel_name = "push-proxy-RSH3g3b-rollback-" + random_string() +storage = MockPushStorage() +client = proxy_push_client(session, storage, channel_name) +admin = direct_admin_client() + +device_id = AWAIT activate_through_proxy(client, storage) + +session.add_rules([{ + "match": { "type": "http_request", "method": "DELETE", "pathContains": "/push/deviceRegistrations" }, + "action": { + "type": "http_respond", + "status": 400, + "body": { "error": { "message": "deregistration rejected", "code": 40198, "statusCode": 400 } } + }, + "times": 1, + "comment": "RSH3g3b: fail only the first deregistration DELETE with a non-retriable 400/40198" +}]) +``` + +### Test Steps and Assertions + +```pseudo +AWAIT client.push.deactivate() WITH timeout 15s FAILS WITH error +ASSERT error.code == 40198 + +# RSH3g3b — still registered locally: the identity token survives the rollback +ASSERT storage.dump()["ably.push.deviceIdentityToken"] IS NOT null + +# ... and server-side: the faulted DELETE was never forwarded +still_registered = AWAIT admin.push.admin.deviceRegistrations.get(device_id) +ASSERT still_registered.id == device_id + +# The rule is consumed — the retry deregisters end-to-end against the real server +AWAIT client.push.deactivate() WITH timeout 15s +poll_until_success(() => storage.dump()["ably.push.activationState"] == "NotActivated", timeout: 10s) + +persisted = storage.dump() +ASSERT "ably.push.deviceIdentityToken" NOT IN persisted +ASSERT "ably.push.pushRecipient" NOT IN persisted + +# Server-side registration is gone +AWAIT admin.push.admin.deviceRegistrations.get(device_id) FAILS WITH error +ASSERT error.statusCode == 404 + +# Two DELETEs were issued: the faulted one and the real one +log = session.get_log() +deletes = log.filter(e => e.type == "http_request" AND e.method == "DELETE" AND e.path CONTAINS "/push/deviceRegistrations") +ASSERT deletes.length == 2 +``` + +--- + +## RSH3c3a — registration failure fails activate; the retry registers against the real server + +**Test ID**: `rest/proxy/RSH3c3a/registration-failure-then-retry-0` + +**Corresponding unit test**: `rest/unit/RSH3c3a/registration-failure-0` (`push_activation_state_machine.md`) + +| Spec | Requirement | +|------|-------------| +| RSH3c3a | On `GettingDeviceRegistrationFailed`, makes `Push#activate` return with the error | +| RSH3c3b | Transitions to `NotActivated` | + +The first registration POST is answered with a synthetic 500/50000 +(`times: 1`), so `activate()` fails and the machine returns to +`NotActivated`. This is necessarily early fault injection — the fault under +test is the registration itself — but the retry then runs fully real, and a +direct admin get confirms the registration exists server-side. +`endpoint: "localhost"` disables fallback hosts (REC2c2) and none are +configured, so the 500 produces exactly one POST attempt. + +### Setup + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [{ + "match": { "type": "http_request", "method": "POST", "pathContains": "/push/deviceRegistrations" }, + "action": { + "type": "http_respond", + "status": 500, + "body": { "error": { "message": "internal error", "code": 50000, "statusCode": 500 } } + }, + "times": 1, + "comment": "RSH3c3a: fail only the first registration POST with a synthetic 500/50000" + }] +) + +channel_name = "push-proxy-RSH3c3a-retry-" + random_string() +storage = MockPushStorage() +client = proxy_push_client(session, storage, channel_name) +admin = direct_admin_client() +``` + +### Test Steps and Assertions + +```pseudo +AWAIT client.push.activate() WITH timeout 15s FAILS WITH error +ASSERT error.code == 50000 +poll_until_success(() => storage.dump()["ably.push.activationState"] == "NotActivated", timeout: 10s) + +# RSH3c3b — from NotActivated the retry runs the full flow against the real server +AWAIT client.push.activate() WITH timeout 15s +poll_until_success(() => storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails", timeout: 10s) +device_id = storage.dump()["ably.push.deviceId"] + +# Server-side ground truth: the retry's registration reached the real server +registered = AWAIT admin.push.admin.deviceRegistrations.get(device_id) +ASSERT registered.id == device_id + +# Two POSTs were issued: the faulted one and the real one +log = session.get_log() +posts = log.filter(e => e.type == "http_request" AND e.method == "POST" AND e.path CONTAINS "/push/deviceRegistrations") +ASSERT posts.length == 2 +``` + +--- + +## RSH4 — deactivate issued during an in-flight registration is queued, then deregisters after activation completes + +**Test ID**: `rest/proxy/RSH4/deactivate-queued-behind-slow-registration-0` + +**Corresponding unit tests**: `rest/unit/RSH4/activate-queued-during-deregistration-0`, `rest/unit/RSH5/back-to-back-activate-deactivate-ordered-0` (`push_activation_event_queue.md`) + +| Spec | Requirement | +|------|-------------| +| RSH4 | An event with no transition defined in the current state is queued, and dequeued after the next transition | +| RSH3c2b | `GotDeviceRegistration` makes `Push#activate` return with no error | +| RSH3d2 | The dequeued `CalledDeactivate` (consumed in `WaitingForNewPushDeviceDetails`) deregisters the device | + +The proxy holds the registration POST for 2 seconds (well under the default +`httpRequestTimeout`, so no timeout fires). Because the recipient is +pre-seeded (RSH3a2c), `activate()` passes straight through to +`WaitingForDeviceRegistration` with the POST in flight — and +`CalledDeactivate` has **no** defined transition there (contrast the RSH5 +unit test, where it arrives in `WaitingForPushDeviceDetails` and RSH3b2 +resolves it immediately with no registration). Per RSH4 it queues: the +activation resolves first when the delayed registration completes, then the +dequeued `CalledDeactivate` deregisters against the real server. The proxy +event log verifies the wire sequence: POST, then DELETE. + +### Setup + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [{ + "match": { "type": "http_request", "method": "POST", "pathContains": "/push/deviceRegistrations" }, + "action": { "type": "http_delay", "delayMs": 2000 }, + "times": 1, + "comment": "RSH4: hold the registration POST for 2s so CalledDeactivate arrives in WaitingForDeviceRegistration" + }] +) + +channel_name = "push-proxy-RSH4-queued-" + random_string() +storage = MockPushStorage() +client = proxy_push_client(session, storage, channel_name) +admin = direct_admin_client() +``` + +### Test Steps + +```pseudo +resolution_order = [] +activation = client.push.activate().then(() => resolution_order.append("activate")) + +# Wait until the registration POST is in flight (visible in the proxy event +# log while the http_delay holds it) +poll_until(() => session.get_log() + .filter(e => e.type == "http_request" AND e.method == "POST" AND e.path CONTAINS "/push/deviceRegistrations") + .length == 1, + timeout: 10s) + +# The id is generated and persisted (RSH3a2b/RSH8b) before the POST is issued, +# so it is stable now. Capture it here: deregistration clears the local +# DeviceDetails (RSH3g2a) and resets the identity so the deregistered id is +# not reused — see the RSH4 worked-example unit test, which asserts a NEW id +# on re-activation. +device_id = storage.dump()["ably.push.deviceId"] + +# CalledDeactivate: no transition defined in WaitingForDeviceRegistration → queued (RSH4) +deactivation = client.push.deactivate().then(() => resolution_order.append("deactivate")) + +AWAIT activation WITH timeout 15s # registration completes; activate resolves first (RSH3c2b) +AWAIT deactivation WITH timeout 15s # dequeued CalledDeactivate → RSH3d2 deregistration +``` + +### Assertions + +```pseudo +# Activation resolved before deactivation +ASSERT resolution_order == ["activate", "deactivate"] + +poll_until_success(() => storage.dump()["ably.push.activationState"] == "NotActivated", timeout: 10s) +persisted = storage.dump() +ASSERT "ably.push.deviceIdentityToken" NOT IN persisted +ASSERT "ably.push.pushRecipient" NOT IN persisted + +# Server-side: the registration was created, then removed +AWAIT admin.push.admin.deviceRegistrations.get(device_id) FAILS WITH error +ASSERT error.statusCode == 404 + +# Wire sequence: the registration POST strictly precedes the deregistration DELETE +log = session.get_log() +reg_requests = log.filter(e => e.type == "http_request" AND e.path CONTAINS "/push/deviceRegistrations") +ASSERT reg_requests.length == 2 +ASSERT reg_requests[0].method == "POST" +ASSERT reg_requests[1].method == "DELETE" +``` + +--- + +## RSH3e3d, RSH3f1 — a failed registration sync is reported via updatedCallback; the next update syncs against the real server + +**Test ID**: `rest/proxy/RSH3e3d/sync-failure-recovery-0` + +**Corresponding unit test**: `rest/unit/RSH3e3d/update-token-sync-failure-callback-4` (`push_update_token.md`) + +| Spec | Requirement | +|------|-------------| +| RSH3e3d | On `SyncRegistrationFailed` (not entered via `CalledActivate`), calls the `updatedCallback` provided to `Push#activate` with the error | +| RSH3e3b | Transitions to `AfterRegistrationSyncFailed` | +| RSH3f1 | In `AfterRegistrationSyncFailed`, `GotPushDeviceDetails` does the same as RSH3a2a (the RSH3d3b PATCH sync) | + +An activated device receives a rotated token via `updateToken`. The proxy +fails the resulting sync PATCH with a synthetic 400/40199 (`times: 1`): the +fire-and-forget sync's failure must surface through the `updatedCallback` +(never through `updateToken`'s return value), leaving the machine in +`AfterRegistrationSyncFailed`. A second `updateToken` then finds the rule +consumed and syncs end-to-end — verified by polling the direct admin get +until the server-side recipient reflects the new token. + +**Known server issue (fixed, pending deploy)**: the second (real) sync PATCH +targets a device whose stored recipient is `ablyChannel`, which a server bug +rejected with 400 `unknown transport type 'ablyChannel'` — fixed by +[ably/realtime#8591](https://github.com/ably/realtime/pull/8591); derived +tests should be skipped with a reason referencing that PR until it is +deployed to the sandbox, then unskipped. Note the rotated token is +necessarily `fcm` (RSH2f1 accepts only fcm/apns), so the successful re-sync +replaces the recipient cross-transport. See also the ably-js +`updatedCallback` deviation in the Notes above. + +### Setup + +```pseudo +session = create_proxy_session(endpoint: "nonprod:sandbox") + +channel_name = "push-proxy-RSH3e3d-sync-" + random_string() +storage = MockPushStorage() +client = proxy_push_client(session, storage, channel_name) +admin = direct_admin_client() + +updated_results = [] +AWAIT client.push.activate(updatedCallback: (err) => updated_results.append(err)) WITH timeout 15s +poll_until_success(() => storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails", timeout: 10s) +device_id = storage.dump()["ably.push.deviceId"] + +# Late fault injection: fail only the first sync PATCH +session.add_rules([{ + "match": { "type": "http_request", "method": "PATCH", "pathContains": "/push/deviceRegistrations" }, + "action": { + "type": "http_respond", + "status": 400, + "body": { "error": { "message": "sync rejected", "code": 40199, "statusCode": 400 } } + }, + "times": 1, + "comment": "RSH3e3d: fail only the first token-rotation sync PATCH with 400/40199" +}]) +``` + +### Test Steps and Assertions + +```pseudo +# updateToken resolves (the sync is fire-and-forget) ... +AWAIT client.push.updateToken(PushDeviceToken(transportType: "fcm", token: "proxy-fcm-token-2")) WITH timeout 10s + +# ... and the sync failure surfaces via the updatedCallback (RSH3e3d) +poll_until(() => updated_results.length == 1, timeout: 10s) +ASSERT updated_results[0] IS NOT null +ASSERT updated_results[0].code == 40199 +poll_until_success(() => storage.dump()["ably.push.activationState"] == "AfterRegistrationSyncFailed", timeout: 10s) + +# RSH3f1 — the next GotPushDeviceDetails re-runs the sync; the rule is +# consumed, so the PATCH reaches the real server +AWAIT client.push.updateToken(PushDeviceToken(transportType: "fcm", token: "proxy-fcm-token-3")) WITH timeout 10s + +# Server-side ground truth: poll the direct admin get until the recipient +# reflects the new token +poll_until(() => { + device = AWAIT admin.push.admin.deviceRegistrations.get(device_id) + RETURN device.push.recipient["transportType"] == "fcm" + AND device.push.recipient["registrationToken"] == "proxy-fcm-token-3" +}, timeout: 15s) + +poll_until_success(() => storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails", timeout: 10s) + +# Two PATCHes were issued: the faulted one and the real one +log = session.get_log() +patches = log.filter(e => e.type == "http_request" AND e.method == "PATCH" AND e.path CONTAINS "/push/deviceRegistrations") +ASSERT patches.length == 2 +``` diff --git a/uts/rest/integration/push_activation.md b/uts/rest/integration/push_activation.md new file mode 100644 index 000000000..565b9d93b --- /dev/null +++ b/uts/rest/integration/push_activation.md @@ -0,0 +1,482 @@ +# Push Activation Integration Tests + +Spec points: `RSH1a`, `RSH2a`, `RSH2b`, `RSH2f`, `RSH3a2a3`, `RSH3a2c`, `RSH3b3c`, `RSH6a`, `RSH8a`, `RSH8c` + +## Test Type +Integration test against Ably sandbox + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. + +### App Provisioning + +Uses `ably-common/test-resources/test-app-setup.json` which provides: +- `keys[0]` — full access (default capability `{"*":["*"]}`) +- `keys[1]` — includes `pushenabled:*` with `push-subscribe` capability (and `pushenabled:admin:*` with `push-admin`) + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + WITH timeout 30s + + app_config = parse_json(response.body) + full_access_key = app_config.keys[0].key_str + push_subscribe_key = app_config.keys[1].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + # Best-effort: catch and ignore timeout errors, sandbox apps auto-expire + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {full_access_key} + WITH timeout 30s +``` + +## Notes + +### Design: the `ablyChannel` test recipient + +The portable `requestToken` primitive (see `uts/rest/unit/helpers/mock_push_platform.md`) can only produce `fcm`/`apns`/`web` tokens, none of which is usable against the sandbox without real platform credentials — the sandbox cannot deliver to a fabricated FCM/APNs token, so an activation driven by `requestToken` could never be verified end to end. + +Instead, these tests use the sandbox's special test-only `ablyChannel` recipient (referenced by `RSH1a`; established by ably-js's `test/support/push_channel_transport.js`). The sandbox accepts device registrations whose `push.recipient` is: + +```pseudo +{ + "transportType": "ablyChannel", + "channel": , + "ablyKey": , + "ablyUrl": # e.g. "https://sandbox.realtime.ably-nonprod.net" +} +``` + +and delivers push publishes to that recipient as messages named `__ably_push__` on the named channel, with the push payload JSON-encoded as a string in the message `data`. + +Tests pre-seed `ably.push.pushRecipient` in `MockPushStorage` with such a recipient. Per `RSH3a2c`, on `CalledActivate` the machine finds the local device already has push details, sends `GotPushDeviceDetails` directly, and never calls `requestToken` — so the platform config's `requestToken` raises `FAIL("requestToken must not be called")`. The registration `POST /push/deviceRegistrations`, the identity-token grant, the `PATCH` sync, deregistration, and push delivery are all exercised against the real server. + +### RSH8a tolerance caveat + +`RSH8a` requires the `LocalDevice` attributes to be populated from persisted state "to the extent that they exist". Seeding only `ably.push.pushRecipient` — with no persisted `id`/`deviceSecret`/`deviceIdentityToken` — is therefore a legitimate partial state that implementations must tolerate: `id` and `deviceSecret` are generated per `RSH3a2b` (or eagerly per the `RSH8k2` note), and activation proceeds. If an SDK's corrupt-state discard (`RSH8a1`) is over-eager and throws away the seeded recipient because `id`/`deviceSecret` are absent, that is a bug these tests are intended to surface — `RSH8a1` applies only when loading `id` or `deviceSecret` *fails*, not when they were never persisted. + +### RSH6a raw-token question — RESOLVED + +`rest/integration/RSH8c/identity-token-usable-0` doubles as the server-acceptance check for the `RSH6a` `X-Ably-DeviceToken` header: `push_subscribe_key` has only `push-subscribe` capability on `pushenabled:*` channels, so the `subscribeDevice()` POST is authorized by valid device authentication, not by the key. Empirically verified (2026-08-07, both ably-dart and ably-js derived runs): the sandbox accepts the **raw** token value — the normative form per `RSH6a` — and also tolerates a base64-encoded value; garbage values are rejected (400, code 40005), proving the header is genuinely validated. SDKs that base64-encode (ably-java, ably-cocoa) therefore work but are non-conformant; `RSH6a` now says this explicitly. + +### Known server issue: registration-update PATCH on `ablyChannel`-recipient devices (fixed, pending deploy) + +A server bug made `PATCH /push/deviceRegistrations/:deviceId` fail with 400 (code 40000, `unknown transport type 'ablyChannel'`) for any device whose **stored** recipient was `ablyChannel`, regardless of the PATCH body and of key vs device auth (while `PUT`/`POST` accepted the recipient, and the identical PATCH against an `fcm`-recipient device succeeded). Root cause: the transport gate's `allowAblyChannel` flag was never carried onto loaded registrations, so the update path re-validated the stored recipient with the flag unset. Fixed by [ably/realtime#8591](https://github.com/ably/realtime/pull/8591). The two tests that exercise the registration sync against an `ablyChannel`-recipient device (`reactivation-validates-0`, `update-token-synced-0` — and `rest/proxy/RSH3e3d/sync-failure-recovery-0` in the proxy spec) are specified against the fixed behaviour; until the fix is deployed to the sandbox, derived tests should be skipped with a reason referencing that PR, then unskipped. + +### Other notes + +- All clients use `useBinaryProtocol: false` and `endpoint: "nonprod:sandbox"`. These are control-plane tests: JSON only, no Protocol Variants. +- Integration tests still use `MockPushStorage` and `install_push_platform` (see `uts/rest/unit/helpers/mock_push_platform.md`) — only the HTTP transport is real. Storage seeding, `dump()`, and the standard `ably.push.*` keys work exactly as in the unit specs. +- Only one push platform is installed at a time (a fresh `install_push_platform` replaces the previous one). Within a test, all push-activation clients are built over the *same* storage, so re-installing via `push_client(storage, ...)` is safe. The `admin_client()` performs only key-authenticated admin operations and does not drive the activation machine. +- Recipient channel names and device registrations are unique per run; device registrations created during tests must be cleaned up. +- Suite timeout: 120 seconds. All `WITH timeout`, `poll_until` and `poll_until_success` values below are wall-clock time. +- `rest/integration/RSH2f/update-token-synced-0` is a **validation-risk test**: see its own notes. + +## Shared Test Setup + +```pseudo +# Storage pre-seeded with an ablyChannel recipient (see Notes). No id/secret/ +# identity token — this is a first-ever activation with known push details. +FUNCTION seeded_storage(recipient_channel: String): + storage = MockPushStorage() + storage.seed({ + "ably.push.pushRecipient": json_encode({ + "transportType": "ablyChannel", + "channel": recipient_channel, + "ablyKey": full_access_key, + "ablyUrl": "https://sandbox.realtime.ably-nonprod.net" + }) + }) + RETURN storage + +# Per RSH3a2c the seeded recipient means requestToken is never consulted. +FUNCTION push_client(storage, key?: String, platform?: String): + install_push_platform(MockPushPlatform( + platform: platform ?? "android", + formFactor: "phone", + storage: storage, + requestToken: () => FAIL("requestToken must not be called: recipient is pre-seeded (RSH3a2c)") + )) + RETURN Rest(options: ClientOptions( + key: key ?? full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: false + )) + +# Separate client for server-side verification via the push admin API. +FUNCTION admin_client(): + RETURN Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: false + )) +``` + +--- + +## RSH2a — activate registers the device with the real server + +**Test ID**: `rest/integration/RSH2a/activate-registers-device-0` + +| Spec | Requirement | +|------|-------------| +| RSH2a | `Push#activate` drives the state machine through a full registration | +| RSH3a2c | With push details already persisted, activation proceeds without calling `requestToken` | +| RSH3b3b | The `LocalDevice` is registered via `POST /push/deviceRegistrations` and the server accepts it | +| RSH8c | The granted `deviceIdentityToken` is set on the local device | + +Tests the full happy-path activation round-trip against the real server: the seeded `ablyChannel` recipient is registered, the server grants a `deviceIdentityToken`, and the registration is visible through the admin API. + +### Setup +```pseudo +recipient_channel = "push-recipient-RSH2a-" + random_id() +storage = seeded_storage(recipient_channel) +client = push_client(storage) +``` + +### Test Steps +```pseudo +AWAIT client.push.activate() WITH timeout 15s +``` + +### Assertions +```pseudo +device = client.device() +ASSERT device.id IS NOT null +ASSERT device.deviceIdentityToken IS NOT null + +# Persistence settles fire-and-forget after activate resolves +poll_until_success( + condition: () => storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails", + interval: 100ms, + timeout: 10s +) + +# Server-side verification via a separate admin client +admin = admin_client() +registration = AWAIT admin.push.admin.deviceRegistrations.get(device.id) WITH timeout 10s +ASSERT registration.id == device.id +ASSERT registration.platform == "android" +ASSERT registration.formFactor == "phone" +ASSERT registration.push.recipient["transportType"] == "ablyChannel" +ASSERT registration.push.recipient["channel"] == recipient_channel +``` + +### Cleanup +```pseudo +AWAIT admin.push.admin.deviceRegistrations.remove(device.id) +``` + +--- + +## RSH8c, RSH6a — persisted deviceIdentityToken is usable by a fresh client + +**Test ID**: `rest/integration/RSH8c/identity-token-usable-0` + +| Spec | Requirement | +|------|-------------| +| RSH8c | The `deviceIdentityToken` granted at registration is persisted and recovered | +| RSH8a | A fresh client hydrates the `LocalDevice` from persisted state | +| RSH6a | Device-authenticated requests carry `X-Ably-DeviceToken`, and the server accepts it | + +Tests that the registration's identity token round-trips through storage: a fresh client over the same storage performs a device-authenticated operation (`channel.push.subscribeDevice()`) without calling `activate()` and without re-registering. The fresh client uses `push_subscribe_key`, whose `push-subscribe` capability authorizes subscribing **only the authenticated device** — so the operation succeeds only if the server accepts the SDK's `X-Ably-DeviceToken` header. **Derived tests must report whether the server accepts the RAW token value** (see Notes: RSH6a raw-token open question). + +### Setup +```pseudo +recipient_channel = "push-recipient-RSH8c-" + random_id() +storage = seeded_storage(recipient_channel) + +# First app run: register the device +client1 = push_client(storage) +AWAIT client1.push.activate() WITH timeout 15s +device_id = client1.device().id +``` + +### Test Steps +```pseudo +# Second app run: fresh client over the same storage, restricted key. +# No activate() call — the LocalDevice must hydrate from storage (RSH8a). +client2 = push_client(storage, key: push_subscribe_key) +channel_name = "pushenabled:test-RSH8c-" + random_id() +channel = client2.channels.get(channel_name) + +AWAIT channel.push.subscribeDevice() WITH timeout 15s +``` + +### Assertions +```pseudo +ASSERT client2.device().id == device_id +ASSERT client2.device().deviceIdentityToken IS NOT null + +# Server-side verification: the subscription exists +admin = admin_client() +result = AWAIT admin.push.admin.channelSubscriptions.list({ + "channel": channel_name, + "deviceId": device_id +}) WITH timeout 10s +ASSERT result.items.length == 1 +ASSERT result.items[0].deviceId == device_id +ASSERT result.items[0].channel == channel_name +``` + +### Cleanup +```pseudo +AWAIT admin.push.admin.channelSubscriptions.remove(PushChannelSubscription( + channel: channel_name, + deviceId: device_id +)) +AWAIT admin.push.admin.deviceRegistrations.remove(device_id) +``` + +--- + +## RSH2b — deactivate deregisters the device from the real server + +**Test ID**: `rest/integration/RSH2b/deactivate-deregisters-0` + +| Spec | Requirement | +|------|-------------| +| RSH2b | `Push#deactivate` drives the state machine through deregistration | +| RSH3g3a | The device is deregistered via `DELETE /push/deviceRegistrations` and the server accepts it | + +Tests that after `deactivate()` resolves, the registration no longer exists server-side. A missing device is reported by the admin `get` as an error with `statusCode` 404 (as in `rest/integration/push_admin.md`). + +### Setup +```pseudo +recipient_channel = "push-recipient-RSH2b-" + random_id() +storage = seeded_storage(recipient_channel) +client = push_client(storage) + +AWAIT client.push.activate() WITH timeout 15s +device_id = client.device().id + +# Confirm the registration exists before deactivating +admin = admin_client() +AWAIT admin.push.admin.deviceRegistrations.get(device_id) WITH timeout 10s +``` + +### Test Steps +```pseudo +AWAIT client.push.deactivate() WITH timeout 15s +``` + +### Assertions +```pseudo +AWAIT admin.push.admin.deviceRegistrations.get(device_id) FAILS WITH error +ASSERT error.statusCode == 404 +``` + +--- + +## RSH3a2a3 — reactivation over registered state syncs against the real server + +**Test ID**: `rest/integration/RSH3a2a3/reactivation-validates-0` + +| Spec | Requirement | +|------|-------------| +| RSH3a2a3 | `activate()` over already-registered state performs the RSH3d3b registration sync (HTTP PATCH, or the permitted PUT legacy equivalent), which must be accepted by the server | +| RSH3a2a | The machine recovered into `WaitingForNewPushDeviceDetails` validates the existing registration rather than re-registering | + +Tests that a second app run's `activate()` — which validates the persisted registration against the real server — resolves, and that the registration survives intact. Integration tests capture no requests: the assertions are purely behavioural (the operation resolves; the server-side registration is unchanged). + +### Setup +```pseudo +recipient_channel = "push-recipient-RSH3a2a3-" + random_id() +storage = seeded_storage(recipient_channel) + +# First app run: register the device +client1 = push_client(storage) +AWAIT client1.push.activate() WITH timeout 15s +device_id = client1.device().id +``` + +### Test Steps +```pseudo +# Second app run: fresh client over the same storage +client2 = push_client(storage) +AWAIT client2.push.activate() WITH timeout 15s +``` + +### Assertions +```pseudo +# The sync (PATCH /push/deviceRegistrations/:deviceId) was accepted: +# activate resolved and the same device is still registered server-side +ASSERT client2.device().id == device_id +ASSERT client2.device().deviceIdentityToken IS NOT null + +admin = admin_client() +registration = AWAIT admin.push.admin.deviceRegistrations.get(device_id) WITH timeout 10s +ASSERT registration.id == device_id +ASSERT registration.push.recipient["transportType"] == "ablyChannel" +ASSERT registration.push.recipient["channel"] == recipient_channel +``` + +### Cleanup +```pseudo +AWAIT admin.push.admin.deviceRegistrations.remove(device_id) +``` + +--- + +## RSH1a — direct publish to the activated device is received end to end + +**Test ID**: `rest/integration/RSH1a/direct-publish-received-0` + +| Spec | Requirement | +|------|-------------| +| RSH1a | `push.admin.publish(recipient, data)` delivers a push notification to a registered device recipient | + +The full end-to-end path: an activated device (with an `ablyChannel` recipient), an admin publish addressed to `{"deviceId": ...}`, and delivery verified by receiving the `__ably_push__` message on the recipient channel. The sandbox delivers the push payload JSON-encoded as a string in the message `data`. + +### Setup +```pseudo +recipient_channel = "push-recipient-RSH1a-" + random_id() +storage = seeded_storage(recipient_channel) +client = push_client(storage) + +AWAIT client.push.activate() WITH timeout 15s +device_id = client.device().id + +# A realtime client subscribed to the recipient channel receives the push +realtime = Realtime(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: false +)) +rt_channel = realtime.channels.get(recipient_channel) +received = [] +AWAIT rt_channel.subscribe("__ably_push__", (msg) => received.append(msg)) WITH timeout 10s +``` + +### Test Steps +```pseudo +push_payload = { + "notification": { "title": "Integration Test", "body": "Push activation e2e" }, + "data": { "foo": "bar" } +} + +admin = admin_client() +AWAIT admin.push.admin.publish( + recipient: { "deviceId": device_id }, + data: push_payload +) WITH timeout 10s +``` + +### Assertions +```pseudo +poll_until( + condition: FUNCTION() => RETURN received[0] IF received.length >= 1 ELSE null, + interval: 100ms, + timeout: 15s +) + +msg = received[0] +ASSERT msg.name == "__ably_push__" +received_payload = parse_json(msg.data) +ASSERT received_payload["notification"]["title"] == "Integration Test" +ASSERT received_payload["notification"]["body"] == "Push activation e2e" +ASSERT received_payload["data"] == { "foo": "bar" } +``` + +### Cleanup +```pseudo +realtime.close() +AWAIT admin.push.admin.deviceRegistrations.remove(device_id) +``` + +--- + +## RSH3b3c — registration rejected by the server fails activation + +**Test ID**: `rest/integration/RSH3b3c/registration-failure-invalid-platform-0` + +| Spec | Requirement | +|------|-------------| +| RSH3b3c | A failed registration fires `GettingDeviceRegistrationFailed`, failing `activate()` with the server's error | +| RSH3b4a | The activated callback is called with the error and the machine returns to `NotActivated` | + +Tests that a registration the real server rejects — here an invalid `platform` on the platform config — surfaces the server's error through `activate()`, and the machine settles back in `NotActivated`. (Mirrors ably-js `failed_registration`, which observes `code` 40000 / `statusCode` 400 from the sandbox; this spec asserts only a 4xx-range error to avoid over-constraining server behaviour.) + +### Setup +```pseudo +recipient_channel = "push-recipient-RSH3b3c-" + random_id() +storage = seeded_storage(recipient_channel) +client = push_client(storage, platform: "not_a_real_platform") +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.activate() FAILS WITH error WITH timeout 15s +ASSERT error IS NOT null +ASSERT error.statusCode >= 400 AND error.statusCode < 500 + +# The machine settles back in NotActivated +poll_until_success( + condition: () => storage.dump()["ably.push.activationState"] == "NotActivated", + interval: 100ms, + timeout: 10s +) + +ASSERT client.device().deviceIdentityToken == null +``` + +--- + +## RSH2f — updateToken's fire-and-forget sync is accepted by the real server + +**Test ID**: `rest/integration/RSH2f/update-token-synced-0` + +| Spec | Requirement | +|------|-------------| +| RSH2f | `Push#updateToken(token)` delivers new push transport details to the library | +| RSH3d3b | The resulting registration sync (HTTP PATCH carrying the new `push.recipient`) is accepted by the server | + +Tests that on an activated device, `updateToken` resolves and the fire-and-forget PATCH sync lands server-side: the admin API eventually shows the new `fcm` recipient. Note `RSH2f` is part of the pending token-variants spec extension (drafted in `specifications/features.md`, not yet merged upstream). + +**Isolation notes:** +- This test intentionally **replaces** the device's `ablyChannel` recipient with an `fcm` one (the sandbox accepts fabricated FCM `registrationToken`s at both registration and update time, empirically confirmed 2026-08-07), after which the device can no longer receive `ablyChannel` deliveries. It therefore uses its own storage and device (every test here does), and if a derived suite ever shares a device between tests, this test must run last. +- Subject to the known server issue above until [ably/realtime#8591](https://github.com/ably/realtime/pull/8591) is deployed. + +### Setup +```pseudo +recipient_channel = "push-recipient-RSH2f-" + random_id() +storage = seeded_storage(recipient_channel) +client = push_client(storage) + +AWAIT client.push.activate() WITH timeout 15s +device_id = client.device().id +new_token = "fake-fcm-token-" + random_id() +``` + +### Test Steps +```pseudo +AWAIT client.push.updateToken(PushDeviceToken( + transportType: "fcm", + token: new_token +)) WITH timeout 15s +``` + +### Assertions +```pseudo +# The sync is fire-and-forget: poll the admin API until the PATCH lands +admin = admin_client() +registration = poll_until( + condition: FUNCTION() => + reg = AWAIT admin.push.admin.deviceRegistrations.get(device_id) + RETURN reg IF reg.push.recipient["transportType"] == "fcm" ELSE null, + interval: 500ms, + timeout: 20s +) + +ASSERT registration.id == device_id +ASSERT registration.push.recipient["transportType"] == "fcm" +ASSERT registration.push.recipient["registrationToken"] == new_token +``` + +### Cleanup +```pseudo +AWAIT admin.push.admin.deviceRegistrations.remove(device_id) +``` diff --git a/uts/rest/unit/helpers/mock_push_platform.md b/uts/rest/unit/helpers/mock_push_platform.md new file mode 100644 index 000000000..b957257ea --- /dev/null +++ b/uts/rest/unit/helpers/mock_push_platform.md @@ -0,0 +1,276 @@ +# Mock Push Platform Infrastructure + +This document specifies the mock push platform infrastructure for push activation unit tests, and — because no prior portable definition exists — the portable **push platform primitives interface** that the mocks stand in for. All unit tests for the Activation State Machine (`RSH3`), `LocalDevice` (`RSH8`), and platform push operations (`RSH2`) should reference this document. + +## Purpose + +Push activation requires two platform capabilities that the core SDK cannot provide itself: + +1. **Persistent key/value storage** — the Activation State Machine and `LocalDevice` outlive the client instance and the process (`RSH3`), so their state must be persisted (`RSH8a`, `RSH8b`, `RSH8c`) and recovered on restart (`RSH3h`). +2. **Push transport token acquisition** — obtaining the platform's registration/device token (e.g. an FCM registration token or APNs device token) that becomes the `push.recipient` of the registered `DeviceDetails`. + +The mock infrastructure enables unit testing of the activation flow without a real platform: + +1. **In-memory storage** — inspect exactly what was persisted, seed pre-existing state to simulate an app restart, and inject storage failures +2. **Stubbed token acquisition** — return a configured token, or fail, without any platform push service + +## Portable Platform Primitives Interface + +This interface describes what a push platform must *provide* to the SDK; it deliberately does not prescribe how the SDK obtains it (see Installation Mechanism below). The shape matches ably-js's `ably/react-native-push` plugin, where the application supplies it; in other SDKs the same primitives may be sourced internally by the SDK (e.g. ably-java's `ActivationContext` reads Android `SharedPreferences`). + +```pseudo +# Persistent string key/value storage supplied by the application or platform +# adapter. All methods are asynchronous (every real backend — AsyncStorage, +# SharedPreferences, Keychain, a file — is asynchronous or should be treated +# as such). An SDK may additionally support a synchronous storage variant as +# a platform-specific extension; that is outside the scope of these specs. +interface PushKeyValueStorage: + getItem(key: String): Future + setItem(key: String, value: String): Future + removeItem(key: String): Future + +# A push transport token, tagged with its transport so the SDK can build the +# correct push recipient (PDT1-PDT4). +class PushDeviceToken: + transportType: String # "fcm" | "apns" | "web" (PDT2) + token: String # (PDT3) + apnsTokenType: String? # apns only: token slot per PCP3a — "default" (the + # default when absent) | "location" | "pushToStart"; + # slot names are extensible (PDT4) + +# Token acquisition. Called by the SDK whenever it needs the current push +# transport token (RSH3a2d). Requesting user notification permission +# beforehand is the application's responsibility, not the SDK's: platform +# tokens are generally obtainable without notification permission, which +# only gates displaying notifications. +requestToken: () => Future +``` + +The token-to-recipient mapping is: + +| `transportType` | Recipient | +|---|---| +| `fcm` | `{ "transportType": "fcm", "registrationToken": }` | +| `apns` (default slot) | `{ "transportType": "apns", "deviceToken": }` | +| `apns` (variant slot, per `PCP3a`) | `{ "transportType": "apns", ..., "apnsDeviceTokens": { : , ... } }` — the slot map accumulates alongside any existing `deviceToken`; registering a variant must not discard other registered variants (`RSH8l2`) | +| `web` | web push recipients are constructed by the SDK's own web-platform flow (service worker + VAPID subscription), which is browser-specific and out of scope for these portable specs | + +**Token rotation and variant registration** are delivered by the application calling `push.updateToken(token: PushDeviceToken)` (`RSH2f`; see `push_update_token.md`); the primitives interface deliberately has no callback/stream for rotation. Note the token-variant spec points (`RSH2f`, `RSH8l`, `PCP3a`, `PDT1`–`PDT4`) are part of the pending token-variants spec extension; they are drafted in `specifications/features.md` but not yet merged upstream. + +### Push platform configuration + +A client is configured with a push platform as a single object carrying the primitives plus the device attributes needed for registration: + +```pseudo +PushPlatformConfig: + platform: String # "android" | "ios" | "browser" (PCD6) + formFactor: String # "phone" | "tablet" | "desktop" | ... (PCD4) + storage: PushKeyValueStorage + requestToken: () => Future +``` + +### Installation Mechanism + +As with the other mock infrastructures (`mock_http.md`), the mechanism by which the SDK receives its push platform is implementation-specific and not part of the portable interface. The feature spec does not require the platform primitives to be application-supplied; how they reach the SDK is an SDK design decision. Possible approaches include: + +- Plugin or client-options configuration (ably-js: `ClientOptions.plugins.Push`, created via `ReactNativePush.create({storage, requestToken})`) +- An activation context bound to the application environment, with storage sourced internally (ably-java: `ActivationContext` over the Android `Context`, storage from `SharedPreferences` — the test subclasses the context) +- Dependency injection or test doubles + +In pseudocode, tests install the mock platform with a harness construct, mirroring `install_mock(mock_http)`: + +```pseudo +install_push_platform(mock_push_platform) +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +`install_push_platform` must be called before the client first touches any push functionality (device load or the first activation event). Only one push platform is installed at a time: a fresh `install_push_platform` replaces the previous one, so a test that constructs several clients over different storages must not interleave push operations on clients built under different installs. `install_mock(mock_http)` is still required alongside it for the HTTP mock. + +### Persisted state: standard keys + +The feature spec deliberately does not prescribe storage keys (`RSH3` says only that state "must be persisted"). For portability of these test specs, the following keys — already used by ably-js — are standardised. New SDK implementations should adopt them; an SDK with pre-existing different keys must record a deviation and adapt the derived tests' storage assertions. + +| Key | Value representation | Written when | +|---|---|---| +| `ably.push.deviceId` | plain string | `RSH8b` (id/secret generation) | +| `ably.push.deviceSecret` | plain string | `RSH8b` | +| `ably.push.deviceIdentityToken` | JSON-encoded string | `RSH8c` (after successful registration) | +| `ably.push.pushRecipient` | JSON-encoded object | when push device details are obtained or updated | +| `ably.push.activationState` | plain string (state name) | on each transition to a persistent state | + +Two further conventions tests may rely on: + +- Deactivation (`RSH3g2a` "clears all local `DeviceDetails`") removes `ably.push.deviceIdentityToken` and `ably.push.pushRecipient` from storage — not merely from the in-memory device — so a later load cannot resurrect them. +- Only a subset of states needs to be persisted. Transient states (those with an in-flight request or an unresolved platform interaction) may be persisted as the stable state they will be recovered into. Tests therefore assert persisted state **only after an operation has settled**, and assert in-memory state via observable behaviour, never by reading `ably.push.activationState` mid-operation. + +## Mock Interface + +```pseudo +interface MockPushStorage: # implements PushKeyValueStorage + # Optional observation/interception handler, mirroring mock_http's + # onRequest: called synchronously before each operation is applied. + # If the handler RAISEs, the operation fails (the returned future + # rejects with the raised error) and the contents are not modified. + MockPushStorage(onOperation?: (op: StorageOperation) => void) + + getItem(key: String): Future + setItem(key: String, value: String): Future + removeItem(key: String): Future + + # Test-only synchronous inspection: the current contents as a plain map + dump(): Map + + # Test-only seeding: pre-populate contents before the client is created, + # to simulate state persisted by a previous app run + seed(entries: Map) + + # Blanket fault injection: when true, the corresponding operations fail + # (the returned future rejects with an error). The onOperation handler + # runs first; these flags apply to operations the handler did not fail. + fail_writes: Boolean # affects setItem / removeItem + fail_reads: Boolean # affects getItem + +# The record passed to onOperation +StorageOperation: + type: String # "getItem" | "setItem" | "removeItem" + key: String + value: String? # setItem only +``` + +As with the HTTP mock, tests that need the operation history capture it into a **local** array via the handler — a `mock_storage.captured_operations` property is deliberately not provided (Common Mistakes #2/#3 in `writing-test-specs.md`): + +```pseudo +captured_operations = [] +mock_storage = MockPushStorage(onOperation: (op) => captured_operations.append(op)) +``` + +Use `dump()` to assert **end state** (what is persisted once an operation settles) and a captured-operations array to assert **sequence and timing** — e.g. that a key was removed rather than never written, that nothing was written while a request was held, or the relative order of a persist and an HTTP request. Per-key fault injection uses a raising handler; the `fail_writes`/`fail_reads` flags remain for the blanket case: + +```pseudo +# Fail only the identity-token persist +mock_storage = MockPushStorage(onOperation: (op) => { + IF op.type == "setItem" AND op.key == "ably.push.deviceIdentityToken": + RAISE Error("storage unavailable") +}) +``` + +The token provider needs no mock class — tests supply a closure as `requestToken` and count or gate calls with local variables: + +```pseudo +mock_push_platform = MockPushPlatform( + platform: "android", + formFactor: "phone", + storage: mock_storage, + requestToken: () => PushDeviceToken(transportType: "fcm", token: "fcm-token-1") +) +``` + +## Example: Successful Activation + +Activation drives both the push platform mock and the HTTP mock (see `uts/rest/unit/helpers/mock_http.md`): the token comes from `requestToken`, the registration is a `POST /push/deviceRegistrations`, and the resulting identity token is persisted. + +```pseudo +captured_requests = [] +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.method == "POST" AND req.url.path == "/push/deviceRegistrations": + body = parse_json(req.body) + req.respond_with(201, merge(body, {"deviceIdentityToken": {"token": "ident-token-1"}})) + ELSE: + req.respond_with(500, {"error": {"message": "unexpected request"}}) + } +) +install_mock(mock_http) + +mock_storage = MockPushStorage() +mock_push_platform = MockPushPlatform( + platform: "android", + formFactor: "phone", + storage: mock_storage, + requestToken: () => PushDeviceToken(transportType: "fcm", token: "fcm-token-1") +) + +install_push_platform(mock_push_platform) +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +AWAIT client.push.activate() + +ASSERT captured_requests.length == 1 +persisted = mock_storage.dump() +ASSERT persisted["ably.push.deviceId"] IS NOT null +ASSERT parse_json(persisted["ably.push.pushRecipient"]) == { + "transportType": "fcm", + "registrationToken": "fcm-token-1" +} +``` + +## Example: Seeded Storage (App Restart) + +A fresh client over seeded storage simulates a new process recovering persisted state (`RSH3h`, `RSH8a`). The idiomatic way to seed realistic values is to run a first client to completion, then construct a second client over the same storage: + +```pseudo +# First app run: activate and let state persist +install_push_platform(mock_push_platform) +client1 = Rest(options: ClientOptions(key: ...)) +AWAIT client1.push.activate() + +# Second app run: same platform (same storage), fresh client +client2 = Rest(options: ClientOptions(key: ...)) +AWAIT client2.push.activate() + +# The machine recovered into WaitingForNewPushDeviceDetails, so the second +# activate() syncs or resolves without re-registering (RSH3a2a / RSH3d1) +``` + +Hand-crafted seeding is also possible for corruption tests (`RSH8a1`): + +```pseudo +mock_storage.seed({ + "ably.push.deviceId": "device-1", + # deviceSecret missing — device load must fail and discard everything + "ably.push.activationState": "WaitingForNewPushDeviceDetails" +}) +``` + +## Example: Token Acquisition Failure + +```pseudo +mock_push_platform = MockPushPlatform( + platform: "android", + formFactor: "phone", + storage: mock_storage, + requestToken: () => RAISE Error("permission denied") +) +install_push_platform(mock_push_platform) + +client = Rest(options: ClientOptions(key: ...)) + +AWAIT client.push.activate() FAILS WITH error # RSH8h -> RSH3b4 +ASSERT error.message CONTAINS "permission denied" +``` + +## Test Isolation + +Each test should create a fresh `MockPushStorage` and install a fresh mock platform, mirroring the HTTP mock's install/uninstall lifecycle: + +```pseudo +BEFORE EACH TEST: + mock_http = MockHttpClient(...) + install_mock(mock_http) + mock_storage = MockPushStorage() + install_push_platform(MockPushPlatform(..., storage: mock_storage, ...)) + +AFTER EACH TEST: + uninstall_mock() + uninstall_push_platform() +``` + +(In practice the spec files install the platform via their `push_client(...)` shared helper rather than in `BEFORE EACH TEST`, because many tests configure a per-test `requestToken`.) + +## Notes for Spec Authors + +- **When `id`/`deviceSecret` are generated is not portable.** Per the note on `RSH8k2`, ably-cocoa and ably-js generate them eagerly when the `LocalDevice` is first loaded, while `RSH3a2b` (followed by ably-java) generates them lazily on `CalledActivate`. Tests must not assert that storage is empty of `ably.push.deviceId`/`ably.push.deviceSecret` before activation, and must not assert exactly when they appear — only that they exist (and are stable) at points the spec requires them to exist. +- **Fire-and-forget persistence.** State-machine transitions may persist asynchronously after the triggering operation resolves. Tests should allow pending writes to settle (e.g. `poll_until(() => mock_storage.dump()["ably.push.activationState"] == "...")`) rather than asserting storage contents immediately. +- **The machine must not process events before initialisation completes** (`RSH3h`). With asynchronous storage this means `activate()`/`deactivate()` internally await hydration; tests do not need to (and must not) call any explicit initialisation API. diff --git a/uts/rest/unit/push/local_device.md b/uts/rest/unit/push/local_device.md new file mode 100644 index 000000000..254adc46e --- /dev/null +++ b/uts/rest/unit/push/local_device.md @@ -0,0 +1,343 @@ +# LocalDevice Tests + +Spec points: `RSH8`, `RSH8a`, `RSH8d`, `RSH8e`, `RSH8f`, `RSH8k`, `RSH8k1`, `RSH8k2` + +## Test Type +Unit test with mocked HTTP client and mocked push platform + +## Mock HTTP Infrastructure + +See `uts/rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastructure specification. + +## Mock Push Platform Infrastructure + +See `uts/rest/unit/helpers/mock_push_platform.md` for the portable push platform primitives (`PushKeyValueStorage`, `requestToken`, `PushPlatformConfig`), the standard `ably.push.*` storage keys, and the `MockPushStorage` mock. + +## Notes + +`RSH8` defines the `device` method on `RestClient`/`RealtimeClient`. SDKs whose push storage is asynchronous may expose this as an asynchronous accessor instead (ably-js's React Native plugin deprecates the synchronous `device()` in favour of `await getDevice()`). The pseudocode uses `AWAIT client.device()`; derived tests map this to whichever accessor the SDK exposes, and SDKs with synchronous storage simply drop the `AWAIT`. + +These tests are **black-box**: they never construct state machine events or inspect machine state directly. State is observed through behaviour (which requests are made, which operations resolve or fail), through the returned `LocalDevice`, and, after operations settle, through the persisted `ably.push.*` keys. + +Tests must not assert *when* `id`/`deviceSecret` are generated (see "Notes for Spec Authors" in `mock_push_platform.md`): some SDKs generate them eagerly on device load, others lazily per `RSH3a2b`. In particular, before any activation a test may only assert that `deviceIdentityToken` is null — not that `id`/`deviceSecret` are or aren't set. + +The storage key under which the `LocalDevice` `clientId` is persisted is not standardised (the standard keys table in `mock_push_platform.md` deliberately has no entry for it). Tests therefore observe `RSH8d` persistence behaviourally — a fresh client over the same storage sees the `clientId` — rather than by inspecting a specific key. + +Device authentication assertions follow `RSH6a` (`X-Ably-DeviceToken` header). An SDK using a different device-auth mechanism (e.g. an `Authorization` bearer header carrying the `deviceIdentityToken`) must record a deviation and adapt the assertion. + +**Caveat for the RSH8d/RSH8e tests:** they depend on the client *becoming identified* after construction, per `RSA7b2`/`RSA7b3` (a token with a `clientId` is obtained by a previously unidentified client). SDKs that have not implemented this late-identification plumbing — the hook from auth into the `LocalDevice` and the Activation State Machine — must record a deviation and may skip the derived test. + +## Shared Test Setup + +All tests use the following helpers, plus the `BEFORE EACH TEST` / `AFTER EACH TEST` isolation from `mock_push_platform.md`. + +```pseudo +# HTTP mock routing registration endpoints; captures every request. +# Individual tests override specific routes via the `overrides` handler, +# which is consulted first and may hold requests without responding. +FUNCTION mock_registration_server(overrides?: (req) => Boolean): + captured_requests = [] + mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF overrides != null AND overrides(req): + RETURN # the override handled (or held) the request + IF req.method == "POST" AND req.url.path == "/push/deviceRegistrations": + body = parse_json(req.body) + req.respond_with(201, merge(body, {"deviceIdentityToken": {"token": "ident-token-1"}})) + ELSE IF req.method == "PUT" AND req.url.path STARTS WITH "/push/deviceRegistrations/": + req.respond_with(200, parse_json(req.body)) + ELSE IF req.method == "PATCH" AND req.url.path STARTS WITH "/push/deviceRegistrations/": + req.respond_with(200, parse_json(req.body)) + ELSE IF req.method == "DELETE" AND req.url.path == "/push/deviceRegistrations": + req.respond_with(204, "") + ELSE: + req.respond_with(500, {"error": {"message": "unexpected request", "code": 50000}}) + } + ) + install_mock(mock_http) + RETURN captured_requests + +FUNCTION build_push_platform(storage, token?: PushDeviceToken, requestToken?: Function): + RETURN MockPushPlatform( + platform: "android", + formFactor: "phone", + storage: storage, + requestToken: requestToken ?? (() => token ?? PushDeviceToken(transportType: "fcm", token: "fcm-token-1")) + ) + +FUNCTION push_client(storage, clientId?: String, token?, requestToken?): + install_push_platform(build_push_platform(storage, token, requestToken)) + RETURN Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + clientId: clientId + )) + +# Runs a full activation so that `storage` holds a registered device +# (deviceId, deviceSecret, deviceIdentityToken, pushRecipient) and the +# persisted activation state is WaitingForNewPushDeviceDetails. +FUNCTION activate_into(storage, clientId?: String): + client = push_client(storage, clientId) + AWAIT client.push.activate() + poll_until_success(() => storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") + RETURN client + +# A client using token auth via authCallback, so that its identity can change +# after construction (RSA7b2/RSA7b3): the first token is anonymous, every later +# token is identified as "alice". No HTTP is involved — the callback returns +# TokenDetails directly (RSA8d). +FUNCTION late_identified_client(storage): + auth_calls = 0 + install_push_platform(build_push_platform(storage)) + RETURN Rest(options: ClientOptions( + authCallback: (tokenParams) => { + auth_calls += 1 + IF auth_calls == 1: + RETURN TokenDetails(token: "anon-token-1") + RETURN TokenDetails(token: "alice-token-1", clientId: "alice") + } + )) +``` + +--- + +## RSH8, RSH8k1, RSH8k2 — the device accessor returns the activated LocalDevice + +**Test ID**: `rest/unit/RSH8/device-returns-local-device-0` + +| Spec | Requirement | +|------|-------------| +| RSH8 | The `device` method on the `RestClient` or `RealtimeClient` interfaces returns an instance of `LocalDevice` that represents the current state of the device in respect of it being a target for push notifications | +| RSH8k | `LocalDevice` has the attributes `deviceIdentityToken` and `deviceSecret` (with `id`, `platform`, `formFactor` and `push.recipient` inherited from `DeviceDetails`) | +| RSH8k1 | `deviceIdentityToken` string? — populated as described in RSH8c (set after successful registration) | +| RSH8k2 | `deviceSecret` string — populated as described in RSH8b | + +Tests that after a full activation the device accessor reflects the registered device: identifiers, identity token, platform attributes and push recipient. + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +client = AWAIT activate_into(mock_storage) +``` + +### Test Steps +```pseudo +device = AWAIT client.device() +``` + +### Assertions +```pseudo +persisted = mock_storage.dump() +ASSERT device.id == persisted["ably.push.deviceId"] +ASSERT device.deviceSecret IS NOT null # RSH8k2 +ASSERT device.deviceIdentityToken == "ident-token-1" # RSH8k1 +ASSERT device.platform == "android" +ASSERT device.formFactor == "phone" +ASSERT device.push.recipient == { + "transportType": "fcm", + "registrationToken": "fcm-token-1" +} +``` + +--- + +## RSH8a — LocalDevice is populated from persisted state without any request + +**Test ID**: `rest/unit/RSH8a/device-populated-from-persisted-state-0` + +**Spec requirement:** RSH8a — The `LocalDevice` is initialised when first required, either as a result of a call to `RestClient#device` or `RealtimeClient#device`, or as a result of the Activation State Machine being initialised. The `id`, `clientId`, `deviceSecret` and `deviceIdentityToken` attributes are populated, together with any `recipient`-related attributes, to the extent that they exist, from the persisted state. + +Tests that a fresh client over storage holding a registered device returns the same `LocalDevice` attributes, and that the device accessor itself is a pure load from persisted state — it makes no HTTP request. + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +AWAIT activate_into(mock_storage) # client1: register and persist +persisted = mock_storage.dump() +``` + +### Test Steps +```pseudo +# A fresh client over the same storage simulates an app restart +client2 = push_client(mock_storage) +requests_before = captured_requests.length +device = AWAIT client2.device() +``` + +### Assertions +```pseudo +ASSERT device.id == persisted["ably.push.deviceId"] +ASSERT device.deviceSecret == persisted["ably.push.deviceSecret"] +ASSERT device.deviceIdentityToken == "ident-token-1" +ASSERT device.push.recipient == { + "transportType": "fcm", + "registrationToken": "fcm-token-1" +} + +# The device accessor made no HTTP request of its own +ASSERT captured_requests.length == requests_before +``` + +--- + +## RSH8k1 — deviceIdentityToken is null before registration + +**Test ID**: `rest/unit/RSH8k1/device-identity-token-null-before-registration-0` + +**Spec requirement:** RSH8k1 — `deviceIdentityToken` string? — populated as described in RSH8c, i.e. only following successful registration. Before any activation it is unset. + +Tests that the device accessor on a never-activated client returns a `LocalDevice` with no `deviceIdentityToken`. The test deliberately asserts nothing about `id`/`deviceSecret`: whether they are already set at this point diverges between eager and lazy generation (see "Notes for Spec Authors" in `mock_push_platform.md`). + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +client = push_client(mock_storage) +``` + +### Test Steps +```pseudo +device = AWAIT client.device() +``` + +### Assertions +```pseudo +ASSERT device.deviceIdentityToken == null +# Deliberately no assertion on device.id / device.deviceSecret — see Notes +``` + +--- + +## RSH8f — clientId from the registration response is set on the LocalDevice + +**Test ID**: `rest/unit/RSH8f/clientid-from-registration-response-0` + +**Spec requirement:** RSH8f — If the `LocalDevice` is created by an unidentified client (see RSA7) and therefore has no `clientId` set, but on receipt of a registration response (see RSH3c2) the registered device has a non-empty `clientId`, then the `LocalDevice` `clientId` is set with that `clientId`. + +Tests that when the server's registration response carries a `clientId` (e.g. one implied by the authenticating token), the unidentified client's `LocalDevice` adopts it. + +### Setup +```pseudo +captured_requests = mock_registration_server(overrides: (req) => { + IF req.method == "POST" AND req.url.path == "/push/deviceRegistrations": + body = parse_json(req.body) + req.respond_with(201, merge(body, { + "deviceIdentityToken": {"token": "ident-token-1"}, + "clientId": "client-from-server" + })) + RETURN true + RETURN false +}) +mock_storage = MockPushStorage() +client = push_client(mock_storage) # no clientId — unidentified (RSA7) +``` + +### Test Steps +```pseudo +AWAIT client.push.activate() +device = AWAIT client.device() +``` + +### Assertions +```pseudo +ASSERT device.clientId == "client-from-server" +``` + +--- + +## RSH8d — a clientId acquired after registration is set and persisted + +**Test ID**: `rest/unit/RSH8d/late-clientid-persisted-0` + +**Spec requirement:** RSH8d — If the `LocalDevice` is created by an unidentified client (see RSA7) and therefore has no `clientId` set, but the client subsequently becomes identified (as a result of RSA7b2 or RSA7b3), then the `LocalDevice` `clientId` is set and persisted. + +An unidentified token-auth client activates; it then becomes identified by obtaining a token whose `clientId` is `"alice"` via `Auth#authorize`. The `LocalDevice` `clientId` must be set, and persisted — observed via a fresh client over the same storage (see Notes: the storage key for `clientId` is not standardised, so persistence is asserted behaviourally). See the Notes caveat: SDKs without late-identification plumbing must record a deviation and may skip the derived test. + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +client = late_identified_client(mock_storage) +``` + +### Test Steps +```pseudo +AWAIT client.push.activate() +device = AWAIT client.device() +ASSERT device.deviceIdentityToken == "ident-token-1" +ASSERT device.clientId == null # registered while unidentified + +# The client becomes identified: the second token carries clientId "alice" (RSA7b2) +tokenDetails = AWAIT client.auth.authorize() +ASSERT tokenDetails.clientId == "alice" +``` + +### Assertions +```pseudo +# RSH8d — the LocalDevice clientId is set... +poll_until_success(() => (AWAIT client.device()).clientId == "alice") + +# ...and persisted: a fresh client over the same storage sees it (polled, +# because persistence may settle asynchronously after the clientId is set) +device2 = poll_until_success(() => { + d = AWAIT push_client(mock_storage).device() + IF d.clientId == "alice": + RETURN d + RETURN null +}) +ASSERT device2.id == device.id +ASSERT device2.clientId == "alice" +``` + +--- + +## RSH8e — a late clientId on a registered device triggers a registration sync + +**Test ID**: `rest/unit/RSH8e/late-clientid-triggers-sync-0` + +| Spec | Requirement | +|------|-------------| +| RSH8e | If the `LocalDevice` `clientId` becomes set as a result of RSH8d, and the `LocalDevice` is already registered (ie the `deviceIdentityToken` is set), and the ActivationStateMachine is in any state other than `NotActivated`, then a `GotPushDeviceDetails` event is sent to the state machine once the effects of RSH8d are visible, ie. once `LocalDevice` `clientId` is set | +| RSH3d3b | (In `WaitingForNewPushDeviceDetails`, on `GotPushDeviceDetails`) make an asynchronous PATCH HTTP request to `/push/deviceRegistrations/:deviceId` using the local `DeviceDetails`'s push details as body. This operation requires push device authentication | + +Continuation of the RSH8d scenario: after activation the machine is in `WaitingForNewPushDeviceDetails` (not `NotActivated`) and the device is registered, so the `GotPushDeviceDetails` event fired when the `clientId` becomes set is observable as a registration sync — a PATCH to the device-specific path with push device authentication. See the Notes caveat: SDKs without late-identification plumbing must record a deviation and may skip the derived test. + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +client = late_identified_client(mock_storage) +``` + +### Test Steps +```pseudo +AWAIT client.push.activate() # registered; machine in WaitingForNewPushDeviceDetails +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") +device_id = mock_storage.dump()["ably.push.deviceId"] + +AWAIT client.auth.authorize() # client becomes identified as "alice" (RSA7b2), RSH8d sets clientId + +# RSH8e — GotPushDeviceDetails is sent once the clientId is set, observable +# as the RSH3d3b registration sync +patch = poll_until_success(() => { + patches = captured_requests WHERE method == "PATCH" + IF patches.length > 0: + RETURN patches[0] + RETURN null +}) +``` + +### Assertions +```pseudo +ASSERT patch.url.path == "/push/deviceRegistrations/" + encode_uri_component(device_id) + +# RSH3d3b + RSH6a — push device authentication +ASSERT patch.headers["X-Ably-DeviceToken"] == "ident-token-1" + +# The sync completes and the machine settles back into WaitingForNewPushDeviceDetails +# (RSH3d3d -> WaitingForRegistrationSync, then RegistrationSynced -> RSH3e2a) +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") +``` diff --git a/uts/rest/unit/push/push_activation_event_queue.md b/uts/rest/unit/push/push_activation_event_queue.md new file mode 100644 index 000000000..1727a13dc --- /dev/null +++ b/uts/rest/unit/push/push_activation_event_queue.md @@ -0,0 +1,263 @@ +# Push Activation Event Queue Tests + +Spec points: `RSH3a2a3`, `RSH3a2a4`, `RSH3a2b`, `RSH3a2d`, `RSH3a2e`, `RSH3a3a`, `RSH3b2a`, `RSH3b2b`, `RSH3b3b`, `RSH3c2b`, `RSH3d1a`, `RSH3e1`, `RSH3e2a`, `RSH3e2b`, `RSH3g2a`, `RSH3g2b`, `RSH3g2c`, `RSH4`, `RSH5` + +## Test Type +Unit test with mocked HTTP client and mocked push platform + +## Mock HTTP Infrastructure + +See `uts/rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastructure specification. + +## Mock Push Platform Infrastructure + +See `uts/rest/unit/helpers/mock_push_platform.md` for the portable push platform primitives (`PushKeyValueStorage`, `requestToken`, `PushPlatformConfig`), the standard `ably.push.*` storage keys, and the `MockPushStorage` mock. + +## Notes + +These tests exercise the Activation State Machine's pending-event queue and its event-handling discipline: + +> `(RSH4)` When an event is fired and a transition from the current state is not defined for such event, the event is put into a queue. Then, whenever a transition happens, an event is dequeued from the queue. If a transition from the new current state is defined for the dequeued event, such transition happens. If not, the event is put back in its place in the queue. E. g. we're `WaitingForDeregistration`, and an event `CalledActivate` happens. This event will be put in the queue, since there's no transition defined for it. Then, an event `Deregistered` arrives, causing a transition to `NotActivated`. Now we peek the next item on the queue: `CalledActivate`. Because `NotActivated` transitions on `CalledActivate`, the event is consumed and the machine transitions. + +> `(RSH5)` Event handling is atomic and sequential: while an event is being handled, the next one should be handled only after the current one has caused a state transition or has been put into the pending events queue. + +Also relevant is `RSH3e1`'s carve-out: in `WaitingForRegistrationSync`, `CalledActivate` has a defined transition **unless** the machine is in that state as a result of a `CalledActivate` event — in which case the event queues per `RSH4`. + +These tests are **black-box**, following `push_activation_state_machine.md`: they never construct events or inspect machine state (or the queue) directly. Events are produced by driving the public API (`push.activate()`, `push.deactivate()` — `RSH2a`/`RSH2b`), by the mocked `requestToken`, and by responding to the mocked HTTP requests the machine issues. Queuing is observed through behaviour: while an event is queued, no request attributable to it is made; once the unblocking transition happens, its consumption becomes visible as requests and operation resolutions. To pin the machine in an intermediate state, tests hold a `PendingRequest` (capture it in the `onRequest` handler without responding) or a pending `requestToken` (a `Deferred`, as defined in `push_activation_state_machine.md`), and release it later. + +**Known ably-js deviation** (record in derived tests): ably-js does not implement the `RSH3a2a3` registration sync — a `CalledActivate` on a device that already has a `deviceIdentityToken` re-queues the event into `WaitingForNewPushDeviceDetails` and resolves `activate()` immediately, with no validation request. Consequently the `WaitingForRegistrationSync`-entered-via-`CalledActivate` scenario of `second-activate-queued-during-activate-sync-1` is not reachable in ably-js as specced; a derived ably-js test must record a deviation. + +## Shared Test Setup + +All tests use the following helpers from `push_activation_state_machine.md`, plus the `BEFORE EACH TEST` / `AFTER EACH TEST` isolation from `mock_push_platform.md`. + +```pseudo +# HTTP mock routing registration endpoints; captures every request. +# Individual tests override specific routes via the `overrides` handler, +# which is consulted first and may hold requests without responding. +FUNCTION mock_registration_server(overrides?: (req) => Boolean): + captured_requests = [] + mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF overrides != null AND overrides(req): + RETURN # the override handled (or held) the request + IF req.method == "POST" AND req.url.path == "/push/deviceRegistrations": + body = parse_json(req.body) + req.respond_with(201, merge(body, {"deviceIdentityToken": {"token": "ident-token-1"}})) + ELSE IF req.method == "PUT" AND req.url.path STARTS WITH "/push/deviceRegistrations/": + req.respond_with(200, parse_json(req.body)) + ELSE IF req.method == "PATCH" AND req.url.path STARTS WITH "/push/deviceRegistrations/": + req.respond_with(200, parse_json(req.body)) + ELSE IF req.method == "DELETE" AND req.url.path == "/push/deviceRegistrations": + req.respond_with(204, "") + ELSE: + req.respond_with(500, {"error": {"message": "unexpected request", "code": 50000}}) + } + ) + install_mock(mock_http) + RETURN captured_requests + +FUNCTION build_push_platform(storage, token?: PushDeviceToken, requestToken?: Function): + RETURN MockPushPlatform( + platform: "android", + formFactor: "phone", + storage: storage, + requestToken: requestToken ?? (() => token ?? PushDeviceToken(transportType: "fcm", token: "fcm-token-1")) + ) + +FUNCTION push_client(storage, clientId?: String, token?, requestToken?): + install_push_platform(build_push_platform(storage, token, requestToken)) + RETURN Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + clientId: clientId + )) + +# Runs a full activation so that `storage` holds a registered device +# (deviceId, deviceSecret, deviceIdentityToken, pushRecipient) and the +# persisted activation state is WaitingForNewPushDeviceDetails. +FUNCTION activate_into(storage, clientId?: String): + client = push_client(storage, clientId) + AWAIT client.push.activate() + poll_until_success(() => storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") + RETURN client +``` + +--- + +## RSH4 — activate queued during deregistration is consumed after it and re-registers a new device + +**Test ID**: `rest/unit/RSH4/activate-queued-during-deregistration-0` + +| Spec | Requirement | +|------|-------------| +| RSH4 | `WaitingForDeregistration` defines no transition for `CalledActivate`, so the event queues; on the `Deregistered` → `NotActivated` transition it is dequeued and consumed (the spec's own worked example) | +| RSH3g2a | On `Deregistered`, clears all local `DeviceDetails` | +| RSH3g2b | Makes `Push#deactivate` return with no error | +| RSH3g2c | Transitions to `NotActivated` | +| RSH3a2b | The cleared device has no `id`/`deviceSecret`, so new ones are generated | +| RSH3a2d | The cleared device has no push details, so they are requested from the platform again | +| RSH3b3b | The consumed `CalledActivate` drives a full re-registration POST | +| RSH3c2b | `Push#activate` resolves when the re-registration completes | + +This is the worked example from RSH4's own text, driven black-box: with the deregistration DELETE held open, an `activate()` produces a `CalledActivate` with no defined transition in `WaitingForDeregistration` — observable as no new request while the DELETE is held. Releasing the DELETE lands the machine in `NotActivated`, where the queued event is consumed and the full registration flow re-runs against a **new** device identity (RSH3g2a cleared the old one). + +### Setup +```pseudo +held_delete = null +captured_requests = mock_registration_server(overrides: (req) => { + IF req.method == "DELETE" AND held_delete == null: + held_delete = req # hold the deregistration open + RETURN true + RETURN false +}) +mock_storage = MockPushStorage() + +token_requests = 0 +client = push_client(mock_storage, requestToken: () => { + token_requests += 1 + RETURN PushDeviceToken(transportType: "fcm", token: "fcm-token-1") +}) +AWAIT client.push.activate() +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") +``` + +### Test Steps +```pseudo +deactivation = client.push.deactivate() +poll_until(() => held_delete != null) + +# No transition defined for CalledActivate in WaitingForDeregistration: it queues (RSH4) +activation = client.push.activate() +process_pending_events() +ASSERT captured_requests.length == 2 # activation POST + held DELETE; nothing new while queued + +held_delete.respond_with(204, "") + +AWAIT deactivation # RSH3g2b +AWAIT activation # RSH3c2b — resolves after the dequeued event's full re-registration +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") +``` + +### Assertions +```pseudo +# RSH3a2d — the platform token was requested again for the re-registration +ASSERT token_requests == 2 + +# RSH3b3b — a second registration POST ran after the deregistration +post_requests = captured_requests WHERE method == "POST" +ASSERT post_requests.length == 2 + +# RSH3g2a + RSH3a2b — the old device was cleared, so a NEW deviceId was registered +first_id = parse_json(post_requests[0].body)["id"] +second_id = parse_json(post_requests[1].body)["id"] +ASSERT second_id != first_id +ASSERT mock_storage.dump()["ably.push.deviceId"] == second_id +``` + +--- + +## RSH5 — back-to-back activate then deactivate are handled strictly in order + +**Test ID**: `rest/unit/RSH5/back-to-back-activate-deactivate-ordered-0` + +| Spec | Requirement | +|------|-------------| +| RSH5 | Event handling is atomic and sequential: the next event is handled only after the current one has caused a transition or been queued | +| RSH3a2e | `CalledActivate` (handled first) transitions to `WaitingForPushDeviceDetails` | +| RSH3b2a | `CalledDeactivate` (handled second, in `WaitingForPushDeviceDetails`) makes `Push#deactivate` return with no error | +| RSH3b2b | Transitions to `NotActivated` | +| RSH3a3a | The late-arriving `GotPushDeviceDetails` is consumed in `NotActivated` with a self-transition | + +With `requestToken` pending on a `Deferred`, `activate()` and `deactivate()` are called back-to-back without awaiting. Per RSH5 the events are handled strictly in call order: `CalledActivate` → `WaitingForPushDeviceDetails` (token acquisition initiated, RSH3a2d), then `CalledDeactivate` → RSH3b2 → `NotActivated`. Completing the deferred token afterwards delivers `GotPushDeviceDetails` into `NotActivated`, where RSH3a3a consumes it — so **no registration POST is ever made**. (Had the ordering not been respected — `CalledDeactivate` handled first in `NotActivated` per RSH3a1d — the activation would then have proceeded to register, which the zero-requests assertion rules out.) + +The spec defines no resolution for the in-flight `activate()` in this scenario (RSH3b2 resolves only `deactivate`), so the test does not await `activation`. + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() + +token_deferred = Deferred() +client = push_client(mock_storage, requestToken: () => token_deferred.future) +``` + +### Test Steps +```pseudo +activation = client.push.activate() # RSH3a2e — handled first +deactivation = client.push.deactivate() # RSH5 — handled only after CalledActivate has transitioned + +AWAIT deactivation # RSH3b2a — resolves with no error +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "NotActivated") + +# The token arrives late: RSH3a3a — consumed in NotActivated, no registration +token_deferred.complete(PushDeviceToken(transportType: "fcm", token: "fcm-token-1")) +``` + +### Assertions +```pseudo +process_pending_events() +ASSERT captured_requests.length == 0 +ASSERT mock_storage.dump()["ably.push.activationState"] == "NotActivated" +``` + +--- + +## RSH3e1, RSH4 — a second activate during an activate-triggered sync queues until the sync settles + +**Test ID**: `rest/unit/RSH4/second-activate-queued-during-activate-sync-1` + +| Spec | Requirement | +|------|-------------| +| RSH3a2a3 | `CalledActivate` on a registered device performs the RSH3d3b sync: an HTTP PATCH to `/push/deviceRegistrations/:deviceId` | +| RSH3a2a4 | Transitions to `WaitingForRegistrationSync` | +| RSH3e1 | In `WaitingForRegistrationSync`, `CalledActivate` has a defined transition **unless** the state was entered as a result of a `CalledActivate` event — here it was, so the transition is not defined | +| RSH4 | The undefined event queues; it is dequeued after the next transition | +| RSH3e2b | On `RegistrationSynced` (entered via `CalledActivate`), makes the first `Push#activate` return with no error | +| RSH3e2a | Transitions to `WaitingForNewPushDeviceDetails` | +| RSH3d1a | The dequeued `CalledActivate`, consumed in `WaitingForNewPushDeviceDetails`, makes the second `Push#activate` return with no error | + +Contrast with `activate-during-token-sync-7` in `push_update_token.md`, where the machine entered `WaitingForRegistrationSync` via `GotPushDeviceDetails` and RSH3e1a resolved the `activate()` immediately: here the state was entered via `CalledActivate`, so the second `CalledActivate` must queue (RSH4) — observable as its resolution being deferred until the held PATCH is released, with no additional request. See Notes for the ably-js deviation. + +### Setup +```pseudo +held_patch = null +captured_requests = mock_registration_server(overrides: (req) => { + IF req.method == "PATCH" AND held_patch == null: + held_patch = req # hold the validation sync open + RETURN true + RETURN false +}) +mock_storage = MockPushStorage() +AWAIT activate_into(mock_storage) +device_id = mock_storage.dump()["ably.push.deviceId"] +``` + +### Test Steps +```pseudo +# A fresh client over registered storage: activate syncs the registration via PATCH (RSH3a2a3) +client = push_client(mock_storage) +first = client.push.activate() +poll_until(() => held_patch != null) # machine in WaitingForRegistrationSync via CalledActivate (RSH3a2a4) + +# RSH3e1 carve-out applies: no transition defined, so the event queues (RSH4) +second = client.push.activate() +process_pending_events() +ASSERT captured_requests.length == 2 # seeding POST + held PATCH; no additional request + +held_patch.respond_with(200, parse_json(held_patch.body)) + +AWAIT first # RSH3e2b +AWAIT second # RSH3d1a — the dequeued CalledActivate resolves it from WaitingForNewPushDeviceDetails +``` + +### Assertions +```pseudo +# Exactly one sync PATCH: the queued CalledActivate was consumed by RSH3d1a, not by a second validation +patch_requests = captured_requests WHERE method == "PATCH" +ASSERT patch_requests.length == 1 +ASSERT patch_requests[0].url.path == "/push/deviceRegistrations/" + encode_uri_component(device_id) + +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") +``` diff --git a/uts/rest/unit/push/push_activation_persistence.md b/uts/rest/unit/push/push_activation_persistence.md new file mode 100644 index 000000000..2c9f64d4b --- /dev/null +++ b/uts/rest/unit/push/push_activation_persistence.md @@ -0,0 +1,336 @@ +# Push Activation Persistence Tests + +Spec points: `RSH3h`, `RSH3a2c`, `RSH8a`, `RSH8a1`, `RSH8b`, `RSH8c` + +## Test Type +Unit test with mocked HTTP client and mocked push platform + +## Mock HTTP Infrastructure + +See `uts/rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastructure specification. + +## Mock Push Platform Infrastructure + +See `uts/rest/unit/helpers/mock_push_platform.md` for the portable push platform primitives (`PushKeyValueStorage`, `requestToken`, `PushPlatformConfig`), the standard `ably.push.*` storage keys, and the `MockPushStorage` mock. + +## Notes + +These tests cover the persistence seam of push activation: what the SDK loads from storage on a fresh start (`RSH3h`, `RSH8a`, `RSH3a2c`), how it recovers from corrupt or partial persisted state (`RSH8a1`), how it behaves when persistence itself fails (`RSH8b`), and when the registration outcome is persisted (`RSH8c`). + +These tests are **black-box**: they never construct state machine events or inspect machine state directly. Events are produced by driving the public API (`push.activate()`, `push.deactivate()`), by the mocked `requestToken`, and by responding to the mocked HTTP requests the machine issues. State is observed through behaviour (which requests are made, which operations resolve or fail) and, after operations settle, through the persisted `ably.push.activationState`. + +Seeded storage simulates state persisted by a previous app run. Seeded values follow the persisted value representations in `mock_push_platform.md`: `deviceIdentityToken` is a JSON-encoded string, `pushRecipient` a JSON-encoded object, everything else a plain string. + +To pin the machine in an intermediate state, tests hold a `PendingRequest` (capture it in the `onRequest` handler without responding) and release it later. + +Tests must not assert *when* `id`/`deviceSecret` are generated (see "Notes for Spec Authors" in `mock_push_platform.md`): the `RSH8a1` test observes regeneration only through the registration request body, which is valid under both eager and lazy generation. + +## Shared Test Setup + +All tests use the following helpers, plus the `BEFORE EACH TEST` / `AFTER EACH TEST` isolation from `mock_push_platform.md`. + +```pseudo +# HTTP mock routing registration endpoints; captures every request. +# Individual tests override specific routes via the `overrides` handler, +# which is consulted first and may hold requests without responding. +FUNCTION mock_registration_server(overrides?: (req) => Boolean): + captured_requests = [] + mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF overrides != null AND overrides(req): + RETURN # the override handled (or held) the request + IF req.method == "POST" AND req.url.path == "/push/deviceRegistrations": + body = parse_json(req.body) + req.respond_with(201, merge(body, {"deviceIdentityToken": {"token": "ident-token-1"}})) + ELSE IF req.method == "PUT" AND req.url.path STARTS WITH "/push/deviceRegistrations/": + req.respond_with(200, parse_json(req.body)) + ELSE IF req.method == "PATCH" AND req.url.path STARTS WITH "/push/deviceRegistrations/": + req.respond_with(200, parse_json(req.body)) + ELSE IF req.method == "DELETE" AND req.url.path == "/push/deviceRegistrations": + req.respond_with(204, "") + ELSE: + req.respond_with(500, {"error": {"message": "unexpected request", "code": 50000}}) + } + ) + install_mock(mock_http) + RETURN captured_requests + +FUNCTION build_push_platform(storage, token?: PushDeviceToken, requestToken?: Function): + RETURN MockPushPlatform( + platform: "android", + formFactor: "phone", + storage: storage, + requestToken: requestToken ?? (() => token ?? PushDeviceToken(transportType: "fcm", token: "fcm-token-1")) + ) + +FUNCTION push_client(storage, clientId?: String, token?, requestToken?): + install_push_platform(build_push_platform(storage, token, requestToken)) + RETURN Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + clientId: clientId + )) +``` + +--- + +## RSH8a1, RSH3h — corrupt persisted device state discards all persisted state + +**Test ID**: `rest/unit/RSH8a1/corrupt-device-state-discarded-0` + +| Spec | Requirement | +|------|-------------| +| RSH8a1 | If loading the `LocalDevice` `id` or `deviceSecret` attributes fails, then: (1) all persisted `LocalDevice` attributes must be discarded; (2) all persisted Activation State Machine data must be discarded | +| RSH3h | (Combined with the above) this ensures that the state machine starts in `NotActivated` | + +Storage is seeded with a `deviceId` but **no** `deviceSecret` — an incomplete pair, so the device load fails — plus a stale identity token and a machine state (`WaitingForNewPushDeviceDetails`) that, if honoured, would drive the `RSH3a2a` registration sync. Everything must instead be discarded: activation runs the full first-time registration (platform token request, then a POST — not a sync PATCH) with a freshly generated `id`, and the stale identity token is replaced by the newly registered one. + +### Setup +```pseudo +token_requests = 0 +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +mock_storage.seed({ + "ably.push.deviceId": "seeded-device-1", + # deviceSecret missing — the id/secret pair is incomplete, so the device load must fail + "ably.push.deviceIdentityToken": "\"stale-token\"", + "ably.push.activationState": "WaitingForNewPushDeviceDetails" +}) +client = push_client(mock_storage, requestToken: () => { + token_requests += 1 + RETURN PushDeviceToken(transportType: "fcm", token: "fcm-token-1") +}) +``` + +### Test Steps +```pseudo +AWAIT client.push.activate() +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") +``` + +### Assertions +```pseudo +# Full first-time registration — not the registration sync the stale state would imply +ASSERT token_requests == 1 +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT request.method == "POST" +ASSERT request.url.path == "/push/deviceRegistrations" + +# RSH8a1 (1) — the seeded device identity was discarded; a fresh id was generated +body = parse_json(request.body) +ASSERT body["id"] != "seeded-device-1" + +# The stale identity token was discarded and replaced by the registration result +persisted = mock_storage.dump() +ASSERT persisted["ably.push.deviceId"] != "seeded-device-1" +ASSERT parse_json(persisted["ably.push.deviceIdentityToken"]) == "ident-token-1" +``` + +--- + +## RSH8a1, RSH3h — corrupt persisted machine state recovers without crashing + +**Test ID**: `rest/unit/RSH8a1/corrupt-machine-state-recovers-1` + +| Spec | Requirement | +|------|-------------| +| RSH3h | (2) the in-memory state machine is then constructed from the persisted Activation State Machine data, or starts in `NotActivated` if no such data is persisted | +| RSH8a1 | (Mirror scenario) here the device load *succeeds* — only the machine data is unusable, so the `LocalDevice` attributes must survive | + +Storage is seeded with a complete, valid registered device but an unrecognisable machine state name. The machine cannot be constructed from this data; per `RSH3h` it must treat it as absent and fall back to `NotActivated` rather than crash. From `NotActivated`, `activate()` on a device that has a `deviceIdentityToken` behaves per `RSH3a2a`: a PATCH sync of the existing registration, resolving successfully. + +**Note:** if an SDK instead discards *everything* on unparseable machine state (so `activate()` performs a full POST registration rather than the registration sync), record a deviation — the essential assertion is that activation completes without crashing. + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +mock_storage.seed({ + "ably.push.deviceId": "seeded-device-1", + "ably.push.deviceSecret": "seeded-secret", + "ably.push.deviceIdentityToken": "\"seeded-ident-token\"", + "ably.push.pushRecipient": "{\"transportType\":\"fcm\",\"registrationToken\":\"seeded-token-1\"}", + "ably.push.activationState": "BogusStateName" +}) +client = push_client(mock_storage) +``` + +### Test Steps and Assertions +```pseudo +# Must not crash: the machine falls back to NotActivated (RSH3h), where the +# registered device (it has a deviceIdentityToken) is validated per RSH3a2a +AWAIT client.push.activate() + +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT request.method == "PATCH" +ASSERT request.url.path == "/push/deviceRegistrations/" + encode_uri_component("seeded-device-1") +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") +``` + +--- + +## RSH3a2c, RSH8a — persisted push details skip the platform token request + +**Test ID**: `rest/unit/RSH3a2c/existing-push-details-skip-token-request-0` + +| Spec | Requirement | +|------|-------------| +| RSH3a2c | If the local device has the necessary push details (registration token, etc.), sends a `GotPushDeviceDetails` event | +| RSH8a | The `LocalDevice` attributes are populated, together with any `recipient`-related attributes, to the extent that they exist, from the persisted state | + +Storage is seeded with a valid device pair and a persisted push recipient, but no identity token (not yet registered, so `RSH3a2a` does not apply) and machine state `NotActivated`. On `activate()` the device already has the necessary push details, so `GotPushDeviceDetails` is sent **without consulting the platform**: `requestToken` must not be called, and the registration POST carries the persisted recipient. (Verified against ably-js: `NotActivated` checks the device's `push.recipient` before consulting the platform's token acquisition, so ably-js conforms.) + +### Setup +```pseudo +token_requests = 0 +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +mock_storage.seed({ + "ably.push.deviceId": "seeded-device-1", + "ably.push.deviceSecret": "seeded-secret", + # no deviceIdentityToken — the device is not yet registered + "ably.push.pushRecipient": "{\"transportType\":\"fcm\",\"registrationToken\":\"persisted-token-1\"}", + "ably.push.activationState": "NotActivated" +}) +client = push_client(mock_storage, requestToken: () => { + token_requests += 1 + RETURN PushDeviceToken(transportType: "fcm", token: "unexpected-token") +}) +``` + +### Test Steps +```pseudo +AWAIT client.push.activate() +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") +``` + +### Assertions +```pseudo +# RSH3a2c — the platform was not consulted +ASSERT token_requests == 0 + +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT request.method == "POST" +ASSERT request.url.path == "/push/deviceRegistrations" + +body = parse_json(request.body) +# RSH3a2b — id and deviceSecret already exist, so they are not regenerated +ASSERT body["id"] == "seeded-device-1" +# RSH8a — the recipient came from persisted state +ASSERT body["push"]["recipient"] == { + "transportType": "fcm", + "registrationToken": "persisted-token-1" +} +``` + +--- + +## RSH8b — a persistence failure fails activate; activation recovers once it clears + +**Test ID**: `rest/unit/RSH8b/persist-failure-fails-activate-then-recovers-0` + +**Spec requirement:** RSH8b — The `LocalDevice` `id` and `deviceSecret` attributes are generated, **and persisted** as part of the `LocalDevice` state. Persistence is integral to the generation step: if the generated identifiers cannot be persisted, activation must fail — before any network request — and a later `activate()` on the same client must be able to succeed once storage works again (the failed device load must not be cached). + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +mock_storage.fail_writes = true +client = push_client(mock_storage) +``` + +### Test Steps and Assertions +```pseudo +# The generated identifiers cannot be persisted: activation fails, no HTTP request +AWAIT client.push.activate() FAILS WITH error +ASSERT captured_requests.length == 0 + +# Once storage works again, the SAME client can activate: the failed device +# load must not be cached +mock_storage.fail_writes = false +AWAIT client.push.activate() + +ASSERT captured_requests.length == 1 +ASSERT captured_requests[0].method == "POST" +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") +``` + +--- + +## RSH8c — deviceIdentityToken is persisted only after successful registration + +**Test ID**: `rest/unit/RSH8c/identity-token-persisted-only-after-registration-0` + +**Spec requirement:** RSH8c — Following successful registration of a `LocalDevice`, following the procedure in RSH3c2a, the now known `deviceIdentityToken` is set and persisted. It therefore must not appear in storage while the registration request is still in flight. + +The registration POST is held open (captured without responding). While held, storage must contain no `ably.push.deviceIdentityToken`; after the response is released and the writes settle, the returned token is persisted. + +### Setup +```pseudo +held_post = null +captured_requests = mock_registration_server(overrides: (req) => { + IF req.method == "POST" AND req.url.path == "/push/deviceRegistrations" AND held_post == null: + held_post = req # hold the registration open + RETURN true + RETURN false +}) +mock_storage = MockPushStorage() +client = push_client(mock_storage) +``` + +### Test Steps +```pseudo +activation = client.push.activate() +poll_until_success(() => held_post != null) + +# Registration in flight: the identity token must not be persisted yet +ASSERT "ably.push.deviceIdentityToken" NOT IN mock_storage.dump() + +held_post.respond_with(201, merge(parse_json(held_post.body), {"deviceIdentityToken": {"token": "ident-token-1"}})) +AWAIT activation +``` + +### Assertions +```pseudo +# After activate resolves and the fire-and-forget writes settle +poll_until_success(() => mock_storage.dump()["ably.push.deviceIdentityToken"] != null) +ASSERT parse_json(mock_storage.dump()["ably.push.deviceIdentityToken"]) == "ident-token-1" +``` + +--- + +## RSH3h — with no persisted state the machine starts in NotActivated + +**Test ID**: `rest/unit/RSH3h/no-persisted-state-starts-not-activated-0` + +| Spec | Requirement | +|------|-------------| +| RSH3h | (2) the in-memory state machine is then constructed from the persisted Activation State Machine data, or starts in `NotActivated` if no such data is persisted | +| RSH3a1d | (In `NotActivated`, on `CalledDeactivate`, no `deviceIdentityToken`) does the same as RSH3g2: `deactivate()` resolves with no error | + +Anchors `RSH3h` clause (2) over completely empty storage: the initial state is `NotActivated`, so `deactivate()` resolves immediately with no requests, and `activate()` then runs the full first-time registration flow. + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +client = push_client(mock_storage) +``` + +### Test Steps and Assertions +```pseudo +# NotActivated is the initial state: deactivate resolves immediately (RSH3a1d) +AWAIT client.push.deactivate() +ASSERT captured_requests.length == 0 + +# and activate runs the full first-time registration flow from NotActivated +AWAIT client.push.activate() +ASSERT captured_requests.length == 1 +ASSERT captured_requests[0].method == "POST" +ASSERT captured_requests[0].url.path == "/push/deviceRegistrations" +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") +``` diff --git a/uts/rest/unit/push/push_activation_state_machine.md b/uts/rest/unit/push/push_activation_state_machine.md new file mode 100644 index 000000000..97ed7c3ef --- /dev/null +++ b/uts/rest/unit/push/push_activation_state_machine.md @@ -0,0 +1,965 @@ +# Push Activation State Machine Tests + +Spec points: `RSH2a`, `RSH2b`, `RSH3a1c`, `RSH3a1d`, `RSH3a2a`, `RSH3a2a1`, `RSH3a2a2`, `RSH3a2a3`, `RSH3a2a4`, `RSH3a2b`, `RSH3a2c`, `RSH3a2d`, `RSH3a2e`, `RSH3a3a`, `RSH3b1a`, `RSH3b2a`, `RSH3b2b`, `RSH3b3a`, `RSH3b3b`, `RSH3b3c`, `RSH3b3d`, `RSH3b4a`, `RSH3b4b`, `RSH3c1a`, `RSH3c2a`, `RSH3c2b`, `RSH3c2c`, `RSH3c3a`, `RSH3c3b`, `RSH3d1a`, `RSH3d1b`, `RSH3d2a`, `RSH3d2b`, `RSH3d2c`, `RSH3d2c1`, `RSH3d2d`, `RSH3e1a`, `RSH3e1b`, `RSH3e2a`, `RSH3e2b`, `RSH3e3b`, `RSH3e3c`, `RSH3f1a`, `RSH3f2a`, `RSH3g1a`, `RSH3g2a`, `RSH3g2b`, `RSH3g2c`, `RSH3g3a`, `RSH3g3b`, `RSH6a`, `RSH8h` + +## Test Type +Unit test with mocked HTTP client and mocked push platform + +## Mock HTTP Infrastructure + +See `uts/rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastructure specification. + +## Mock Push Platform Infrastructure + +See `uts/rest/unit/helpers/mock_push_platform.md` for the portable push platform primitives (`PushKeyValueStorage`, `requestToken`, `PushPlatformConfig`), the standard `ably.push.*` storage keys, and the `MockPushStorage` mock. + +## Notes + +The Activation State Machine (`RSH3`) has seven states: + +`NotActivated` (initial), `WaitingForPushDeviceDetails`, `WaitingForDeviceRegistration`, `WaitingForNewPushDeviceDetails`, `WaitingForRegistrationSync`, `AfterRegistrationSyncFailed`, `WaitingForDeregistration` + +and ten events: + +`CalledActivate`, `CalledDeactivate`, `GotPushDeviceDetails`, `GettingPushDeviceDetailsFailed`, `GotDeviceRegistration`, `GettingDeviceRegistrationFailed`, `RegistrationSynced`, `SyncRegistrationFailed`, `Deregistered`, `DeregistrationFailed`. + +These tests are **black-box**: they never construct events or inspect machine state directly. Events are produced by driving the public API (`push.activate()`, `push.deactivate()` — `RSH2a`/`RSH2b`), by the mocked `requestToken` (producing `GotPushDeviceDetails` / `GettingPushDeviceDetailsFailed` per `RSH8h`), and by responding to the mocked HTTP requests the machine issues. State is observed through behaviour (which requests are made, which operations resolve or fail) and, after operations settle, through the persisted `ably.push.activationState`. + +To pin the machine in an intermediate state, tests hold a `PendingRequest` (capture it in the `onRequest` handler without responding) or a pending `requestToken` (a `Deferred`), and release it later. A `Deferred` is a harness construct: a future the test completes manually with `deferred.complete(value)` or `deferred.fail(error)`; `deferred.future` is the awaitable side. + +Device authentication assertions follow `RSH6a` (`X-Ably-DeviceToken` header). An SDK using a different device-auth mechanism (e.g. an `Authorization` bearer header carrying the `deviceIdentityToken`) must record a deviation and adapt the assertion. + +Tests must not assert *when* `id`/`deviceSecret` are generated (see "Notes for Spec Authors" in `mock_push_platform.md`): some SDKs generate them eagerly on device load, others lazily per `RSH3a2b`. + +## Shared Test Setup + +All tests use the following helpers, plus the `BEFORE EACH TEST` / `AFTER EACH TEST` isolation from `mock_push_platform.md`. + +```pseudo +# HTTP mock routing registration endpoints; captures every request. +# Individual tests override specific routes via the `overrides` handler, +# which is consulted first and may hold requests without responding. +FUNCTION mock_registration_server(overrides?: (req) => Boolean): + captured_requests = [] + mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF overrides != null AND overrides(req): + RETURN # the override handled (or held) the request + IF req.method == "POST" AND req.url.path == "/push/deviceRegistrations": + body = parse_json(req.body) + req.respond_with(201, merge(body, {"deviceIdentityToken": {"token": "ident-token-1"}})) + ELSE IF req.method == "PUT" AND req.url.path STARTS WITH "/push/deviceRegistrations/": + req.respond_with(200, parse_json(req.body)) + ELSE IF req.method == "PATCH" AND req.url.path STARTS WITH "/push/deviceRegistrations/": + req.respond_with(200, parse_json(req.body)) + ELSE IF req.method == "DELETE" AND req.url.path == "/push/deviceRegistrations": + req.respond_with(204, "") + ELSE: + req.respond_with(500, {"error": {"message": "unexpected request", "code": 50000}}) + } + ) + install_mock(mock_http) + RETURN captured_requests + +FUNCTION build_push_platform(storage, token?: PushDeviceToken, requestToken?: Function): + RETURN MockPushPlatform( + platform: "android", + formFactor: "phone", + storage: storage, + requestToken: requestToken ?? (() => token ?? PushDeviceToken(transportType: "fcm", token: "fcm-token-1")) + ) + +FUNCTION push_client(storage, clientId?: String, token?, requestToken?): + install_push_platform(build_push_platform(storage, token, requestToken)) + RETURN Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + clientId: clientId + )) + +# Runs a full activation so that `storage` holds a registered device +# (deviceId, deviceSecret, deviceIdentityToken, pushRecipient) and the +# persisted activation state is WaitingForNewPushDeviceDetails. +FUNCTION activate_into(storage, clientId?: String): + client = push_client(storage, clientId) + AWAIT client.push.activate() + poll_until_success(() => storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") + RETURN client +``` + +--- + +## RSH2a, RSH3a2, RSH3b3, RSH3c2 — activate performs the full registration flow + +**Test ID**: `rest/unit/RSH2a/activate-full-flow-0` + +| Spec | Requirement | +|------|-------------| +| RSH2a | `Push#activate` sends a `CalledActivate` event to the state machine | +| RSH3a2b | `id` and `deviceSecret` are generated locally | +| RSH3a2d | The device requests push details from the underlying platform | +| RSH3a2e | Transitions to `WaitingForPushDeviceDetails` | +| RSH3b3b | On `GotPushDeviceDetails`, POSTs the `LocalDevice` with push details and `deviceSecret` to `/push/deviceRegistrations` | +| RSH3b3d | Transitions to `WaitingForDeviceRegistration` | +| RSH3c2a | On `GotDeviceRegistration`, updates the local `DeviceDetails` | +| RSH3c2b | Makes `Push#activate` return with no error | +| RSH3c2c | Transitions to `WaitingForNewPushDeviceDetails` | + +Tests the complete happy-path activation of a previously unactivated device: token acquisition, direct-HTTP registration, and the resulting persisted state. + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +client = push_client(mock_storage) +``` + +### Test Steps +```pseudo +AWAIT client.push.activate() +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "POST" +ASSERT request.url.path == "/push/deviceRegistrations" + +body = parse_json(request.body) +# RSH3b3b — the LocalDevice with push details and deviceSecret +ASSERT body["id"] IS NOT null +ASSERT body["deviceSecret"] IS NOT null +ASSERT body["platform"] == "android" +ASSERT body["formFactor"] == "phone" +ASSERT body["push"]["recipient"] == { + "transportType": "fcm", + "registrationToken": "fcm-token-1" +} + +# RSH3c2a + RSH8c — the registration response was applied and persisted +persisted = mock_storage.dump() +ASSERT persisted["ably.push.deviceId"] == body["id"] +ASSERT persisted["ably.push.deviceSecret"] IS NOT null +ASSERT parse_json(persisted["ably.push.deviceIdentityToken"]) == "ident-token-1" +ASSERT parse_json(persisted["ably.push.pushRecipient"]) == { + "transportType": "fcm", + "registrationToken": "fcm-token-1" +} +``` + +--- + +## RSH3a2b — generated device identifiers are unique and the secret has sufficient entropy + +**Test ID**: `rest/unit/RSH3a2b/device-id-secret-generation-0` + +**Spec requirement:** RSH3a2b — `id` must be a unique identifier; `deviceSecret` must be a base64-encoded digest of at least 32 bytes derived from secure random data. + +Tests the properties of the generated identifiers, without asserting *when* they are generated (see Notes). + +### Setup +```pseudo +captured_requests = mock_registration_server() +storage_a = MockPushStorage() +storage_b = MockPushStorage() +``` + +### Test Steps +```pseudo +AWAIT push_client(storage_a).push.activate() +AWAIT push_client(storage_b).push.activate() +poll_until_success(() => storage_a.dump()["ably.push.deviceId"] != null) +poll_until_success(() => storage_b.dump()["ably.push.deviceId"] != null) +``` + +### Assertions +```pseudo +a = storage_a.dump() +b = storage_b.dump() + +# Unique per device +ASSERT a["ably.push.deviceId"] != b["ably.push.deviceId"] +ASSERT a["ably.push.deviceSecret"] != b["ably.push.deviceSecret"] + +# The secret is base64 and decodes to at least 32 bytes +decoded = base64_decode(a["ably.push.deviceSecret"]) +ASSERT decoded.length >= 32 +``` + +--- + +## RSH3b3a, RSH8c — activate with a custom registerCallback routes registration through the callback + +**Test ID**: `rest/unit/RSH3b3a/activate-register-callback-0` + +| Spec | Requirement | +|------|-------------| +| RSH3b3a | If a custom `registerCallback` was provided to `Push#activate`, pass it the local `DeviceDetails` updated with the push details | +| RSH3b3c | When the registration is done, a `GotDeviceRegistration` or `GettingDeviceRegistrationFailed` event should be fired | +| RSH8c | Following successful registration, the now known `deviceIdentityToken` is set and persisted | + +Tests that a custom registrar replaces the direct HTTP registration entirely, and that the identity token it returns is persisted. + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +client = push_client(mock_storage) + +registered_devices = [] +FUNCTION register_callback(device): + registered_devices.append(device) + RETURN {"deviceIdentityToken": {"token": "custom-ident-1"}} +``` + +### Test Steps +```pseudo +AWAIT client.push.activate(registerCallback: register_callback) +poll_until_success(() => mock_storage.dump()["ably.push.deviceIdentityToken"] != null) +``` + +### Assertions +```pseudo +# Registration went through the callback, not HTTP +ASSERT captured_requests.length == 0 +ASSERT registered_devices.length == 1 +ASSERT registered_devices[0].push.recipient == { + "transportType": "fcm", + "registrationToken": "fcm-token-1" +} + +# RSH8c — the callback's identity token was persisted +ASSERT parse_json(mock_storage.dump()["ably.push.deviceIdentityToken"]) == "custom-ident-1" +``` + +--- + +## RSH3c3 — failed registration fails activate and returns to NotActivated + +**Test ID**: `rest/unit/RSH3c3a/registration-failure-0` + +| Spec | Requirement | +|------|-------------| +| RSH3c3a | On `GettingDeviceRegistrationFailed`, makes `Push#activate` return with the error | +| RSH3c3b | Transitions to `NotActivated` | + +Tests that a server rejection of the registration POST propagates to `activate()`, that the machine returns to `NotActivated`, and that a subsequent `activate()` retries the registration. + +### Setup +```pseudo +fail_registration = true +captured_requests = mock_registration_server(overrides: (req) => { + IF req.method == "POST" AND req.url.path == "/push/deviceRegistrations" AND fail_registration: + req.respond_with(400, {"error": {"message": "registration rejected", "code": 40198, "statusCode": 400}}) + RETURN true + RETURN false +}) +mock_storage = MockPushStorage() +client = push_client(mock_storage) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.activate() FAILS WITH error +ASSERT error.code == 40198 +ASSERT captured_requests.length == 1 +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "NotActivated") + +# RSH3c3b — from NotActivated, activation can be retried successfully +fail_registration = false +AWAIT client.push.activate() +ASSERT captured_requests.length == 2 +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") +``` + +--- + +## RSH3b4, RSH8h — failed token acquisition fails activate and returns to NotActivated + +**Test ID**: `rest/unit/RSH3b4a/token-failure-0` + +| Spec | Requirement | +|------|-------------| +| RSH8h | If an attempt to obtain the push transport details fails, a `GettingPushDeviceDetailsFailed` event containing the error is sent to the state machine | +| RSH3b4a | Makes `Push#activate` return with the error | +| RSH3b4b | Transitions to `NotActivated` | + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +client = push_client(mock_storage, requestToken: () => RAISE Error("permission denied")) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.activate() FAILS WITH error +ASSERT error.message CONTAINS "permission denied" + +# No registration was attempted +ASSERT captured_requests.length == 0 +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "NotActivated") +``` + +--- + +## RSH3a2a, RSH3a2a3, RSH3e2 — activate on an already-registered device syncs the registration via PATCH + +**Test ID**: `rest/unit/RSH3a2a3/activate-existing-registration-sync-0` + +| Spec | Requirement | +|------|-------------| +| RSH3a2a | If the local device has `deviceIdentityToken`, performs a validation of the local `DeviceDetails` | +| RSH3a2a3 | Performs the RSH3d3b registration sync: an HTTP PATCH to `/push/deviceRegistrations/:deviceId` carrying the complete `push.recipient` | +| RSH3a2a4 | Transitions to `WaitingForRegistrationSync` | +| RSH3e2b | On `RegistrationSynced` (entered via `CalledActivate`), makes `Push#activate` return with no error | +| RSH3e2a | Transitions to `WaitingForNewPushDeviceDetails` | + +Tests that a fresh client over storage holding a registered device does not re-register (no POST); it validates the existing registration by syncing it to the device-specific path. (Per RSH3a2a3, an SDK may equivalently perform the sync as a legacy PUT with the full local `DeviceDetails` as body — such SDKs adapt the method/body assertions accordingly, without recording a deviation.) + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +AWAIT activate_into(mock_storage) +device_id = mock_storage.dump()["ably.push.deviceId"] +``` + +### Test Steps +```pseudo +# A fresh client over the same storage simulates an app restart +client = push_client(mock_storage) +AWAIT client.push.activate() +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") +``` + +### Assertions +```pseudo +# One POST from the seeding activation, then exactly one sync PATCH — no second POST +ASSERT captured_requests.length == 2 +request = captured_requests[1] +ASSERT request.method == "PATCH" +ASSERT request.url.path == "/push/deviceRegistrations/" + encode_uri_component(device_id) + +# RSH3d3b — changed fields only, with the complete recipient +body = parse_json(request.body) +ASSERT body["push"]["recipient"] == { + "transportType": "fcm", + "registrationToken": "fcm-token-1" +} +``` + +--- + +## RSH3a2a2 — activate on an already-registered device with a custom registerCallback + +**Test ID**: `rest/unit/RSH3a2a2/activate-existing-registration-register-callback-0` + +**Spec requirement:** RSH3a2a2 — If a custom `registerCallback` was provided to `Push#activate`, pass it the local `DeviceDetails` (instead of the RSH3a2a3 PATCH sync). + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +AWAIT activate_into(mock_storage) +device_id = mock_storage.dump()["ably.push.deviceId"] + +registered_devices = [] +FUNCTION register_callback(device): + registered_devices.append(device) + RETURN {"deviceIdentityToken": {"token": "ident-token-1"}} +``` + +### Test Steps and Assertions +```pseudo +client = push_client(mock_storage) +AWAIT client.push.activate(registerCallback: register_callback) + +# The validation went through the callback: no requests beyond the seeding POST +ASSERT captured_requests.length == 1 +ASSERT registered_devices.length == 1 +ASSERT registered_devices[0].id == device_id +``` + +--- + +## RSH3a2a1 — activate fails with 61002 when the client identity conflicts with the registered device + +**Test ID**: `rest/unit/RSH3a2a1/activate-clientid-mismatch-0` + +**Spec requirement:** RSH3a2a1 — If the `LocalDevice` has a non-empty `clientId`, and the present identified client has a different (non-null) `clientId`, a `SyncRegistrationFailed` event should be fired containing an error with code 61002. + +Tests that a device registered by an identified client cannot be re-activated by a client identified differently: `activate()` fails with 61002, no validation request is made, and the machine ends in `AfterRegistrationSyncFailed` (`RSH3e3b`). + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +AWAIT activate_into(mock_storage, clientId: "alice") +``` + +### Test Steps and Assertions +```pseudo +client = push_client(mock_storage, clientId: "bob") +AWAIT client.push.activate() FAILS WITH error +ASSERT error.code == 61002 + +# No validation request was made — only the seeding POST +ASSERT captured_requests.length == 1 +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "AfterRegistrationSyncFailed") +``` + +--- + +## RSH3d1 — activate when already registered in the same session resolves without any request + +**Test ID**: `rest/unit/RSH3d1a/activate-when-registered-resolves-0` + +| Spec | Requirement | +|------|-------------| +| RSH3d1a | In `WaitingForNewPushDeviceDetails`, on `CalledActivate`, makes `Push#activate` return with no error | +| RSH3d1b | Transitions to `WaitingForNewPushDeviceDetails` (self) | + +Contrast with `RSH3a2a3`: the *same* client instance whose machine is already in `WaitingForNewPushDeviceDetails` resolves a second `activate()` immediately; only a *fresh* machine starting from persisted state re-syncs via PATCH. + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +client = AWAIT activate_into(mock_storage) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.activate() + +# No additional request beyond the original registration POST +ASSERT captured_requests.length == 1 +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") +``` + +--- + +## RSH3b1a — repeated activate while waiting for push device details is idempotent + +**Test ID**: `rest/unit/RSH3b1a/activate-while-waiting-push-details-0` + +**Spec requirement:** RSH3b1a — In `WaitingForPushDeviceDetails`, on `CalledActivate`, transitions to `WaitingForPushDeviceDetails` (self). + +Tests that calling `activate()` again while token acquisition is in flight does not request a second token or issue a second registration; both calls resolve when registration completes (`RSH3c2b`). + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() + +token_deferred = Deferred() +token_requests = 0 +client = push_client(mock_storage, requestToken: () => { + token_requests += 1 + RETURN token_deferred.future +}) +``` + +### Test Steps +```pseudo +first = client.push.activate() # pends on requestToken +second = client.push.activate() # RSH3b1a — self-transition + +token_deferred.complete(PushDeviceToken(transportType: "fcm", token: "fcm-token-1")) + +AWAIT first +AWAIT second +``` + +### Assertions +```pseudo +ASSERT token_requests == 1 +ASSERT captured_requests.length == 1 +ASSERT captured_requests[0].method == "POST" +``` + +--- + +## RSH3b2 — deactivate while waiting for push device details returns to NotActivated + +**Test ID**: `rest/unit/RSH3b2a/deactivate-while-waiting-push-details-0` + +| Spec | Requirement | +|------|-------------| +| RSH3b2a | Makes `Push#deactivate` return with no error | +| RSH3b2b | Transitions to `NotActivated` | +| RSH3a3a | In `NotActivated`, `GotPushDeviceDetails` is consumed with a self-transition | + +Tests that deactivating while token acquisition is in flight succeeds immediately without any HTTP request, and that the token arriving afterwards (`GotPushDeviceDetails` in `NotActivated`) does **not** trigger a registration. + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() + +token_deferred = Deferred() +client = push_client(mock_storage, requestToken: () => token_deferred.future) +``` + +### Test Steps +```pseudo +activation = client.push.activate() # pends on requestToken + +AWAIT client.push.deactivate() # RSH3b2a — resolves with no error +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "NotActivated") + +# The token arrives late: RSH3a3a — consumed in NotActivated, no registration +token_deferred.complete(PushDeviceToken(transportType: "fcm", token: "fcm-token-1")) +``` + +### Assertions +```pseudo +# No deregistration DELETE (nothing was registered), and no late POST +poll_until(() => false, interval: 50ms, timeout: 500ms) # allow any erroneous request to surface +ASSERT captured_requests.length == 0 +ASSERT mock_storage.dump()["ably.push.activationState"] == "NotActivated" +``` + +--- + +## RSH3c1a — repeated activate while device registration is in flight is idempotent + +**Test ID**: `rest/unit/RSH3c1a/activate-while-registering-0` + +**Spec requirement:** RSH3c1a — In `WaitingForDeviceRegistration`, on `CalledActivate`, transitions to `WaitingForDeviceRegistration` (self). + +### Setup +```pseudo +held_post = null +captured_requests = mock_registration_server(overrides: (req) => { + IF req.method == "POST" AND req.url.path == "/push/deviceRegistrations" AND held_post == null: + held_post = req # hold the registration open + RETURN true + RETURN false +}) +mock_storage = MockPushStorage() +client = push_client(mock_storage) +``` + +### Test Steps +```pseudo +first = client.push.activate() +poll_until_success(() => held_post != null) + +second = client.push.activate() # RSH3c1a — self-transition, no second POST + +held_post.respond_with(201, merge(parse_json(held_post.body), {"deviceIdentityToken": {"token": "ident-token-1"}})) + +AWAIT first +AWAIT second +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") +``` + +--- + +## RSH3d2, RSH3g2 — deactivate deregisters the device and clears local state + +**Test ID**: `rest/unit/RSH2b/deactivate-full-flow-0` + +| Spec | Requirement | +|------|-------------| +| RSH2b | `Push#deactivate` sends a `CalledDeactivate` event to the state machine | +| RSH3d2b | Makes an asynchronous DELETE to `/push/deviceRegistrations` using the device's ID, with push device authentication without other token or key authentication | +| RSH3d2d | Transitions to `WaitingForDeregistration` | +| RSH3g2a | On `Deregistered`, clears all local `DeviceDetails` | +| RSH3g2b | Makes `Push#deactivate` return with no error | +| RSH3g2c | Transitions to `NotActivated` | + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +client = AWAIT activate_into(mock_storage) +device_id = mock_storage.dump()["ably.push.deviceId"] +``` + +### Test Steps +```pseudo +AWAIT client.push.deactivate() +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "NotActivated") +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 2 +request = captured_requests[1] +ASSERT request.method == "DELETE" +ASSERT request.url.path == "/push/deviceRegistrations" +ASSERT request.url.query["deviceId"] == device_id + +# RSH3d2b + RSH6a — push device authentication +ASSERT request.headers["X-Ably-DeviceToken"] == "ident-token-1" + +# RSH3g2a — the registered identity is cleared from storage, not just memory +persisted = mock_storage.dump() +ASSERT "ably.push.deviceIdentityToken" NOT IN persisted +ASSERT "ably.push.pushRecipient" NOT IN persisted +``` + +--- + +## RSH3d2a — deactivate with a custom deregisterCallback routes deregistration through the callback + +**Test ID**: `rest/unit/RSH3d2a/deactivate-deregister-callback-0` + +**Spec requirement:** RSH3d2a — If a custom `deregisterCallback` was provided to `Push#deactivate`, pass it the local `DeviceDetails`'s id. + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +client = AWAIT activate_into(mock_storage) +device_id = mock_storage.dump()["ably.push.deviceId"] + +deregistered_ids = [] +FUNCTION deregister_callback(deviceId): + deregistered_ids.append(deviceId) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.deactivate(deregisterCallback: deregister_callback) + +# Deregistration went through the callback: no DELETE +ASSERT captured_requests.length == 1 # just the seeding POST +ASSERT deregistered_ids == [device_id] +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "NotActivated") +``` + +--- + +## RSH3a1c — deactivate from NotActivated with a registered device still deregisters + +**Test ID**: `rest/unit/RSH3a1c/deactivate-not-activated-with-token-0` + +**Spec requirement:** RSH3a1c — In `NotActivated`, on `CalledDeactivate`, if the local device has `deviceIdentityToken`, does the same as RSH3d2. + +Tests via hand-seeded storage: a device that is registered (has an identity token) but whose persisted machine state is `NotActivated` — e.g. state left behind by an earlier partial failure. + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +mock_storage.seed({ + "ably.push.deviceId": "seeded-device-1", + "ably.push.deviceSecret": "seeded-secret", + "ably.push.deviceIdentityToken": "\"seeded-ident-token\"", + "ably.push.activationState": "NotActivated" +}) +client = push_client(mock_storage) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.deactivate() + +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT request.method == "DELETE" +ASSERT request.url.query["deviceId"] == "seeded-device-1" +ASSERT request.headers["X-Ably-DeviceToken"] == "seeded-ident-token" +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "NotActivated") +``` + +--- + +## RSH3a1d — deactivate from NotActivated with no registration resolves without any request + +**Test ID**: `rest/unit/RSH3a1d/deactivate-not-activated-0` + +**Spec requirement:** RSH3a1d — Otherwise (no `deviceIdentityToken`), does the same as RSH3g2 (resolve deactivate, remain `NotActivated`). + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +client = push_client(mock_storage) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.deactivate() + +ASSERT captured_requests.length == 0 +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "NotActivated") +``` + +--- + +## RSH3d2c1 — deregistration treats 401 as success + +**Test ID**: `rest/unit/RSH3d2c1/deregister-401-succeeds-0` + +**Spec requirement:** RSH3d2c1 — `Deregistered` should be fired if the DELETE returns a 2xx status, 401 (unauthorized), or error code 40005 (invalid credentials). Otherwise `DeregistrationFailed`. + +A 401 means the server no longer recognises the device's credentials — the registration is already gone, so deactivation has succeeded. + +### Setup +```pseudo +captured_requests = mock_registration_server(overrides: (req) => { + IF req.method == "DELETE": + req.respond_with(401, {"error": {"message": "unauthorized", "code": 40100, "statusCode": 401}}) + RETURN true + RETURN false +}) +mock_storage = MockPushStorage() +client = AWAIT activate_into(mock_storage) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.deactivate() # resolves despite the 401 + +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "NotActivated") +persisted = mock_storage.dump() +ASSERT "ably.push.deviceIdentityToken" NOT IN persisted +``` + +--- + +## RSH3d2c1 — deregistration treats error code 40005 as success + +**Test ID**: `rest/unit/RSH3d2c1/deregister-40005-succeeds-1` + +**Spec requirement:** RSH3d2c1 — as above; 40005 (invalid credentials) is classified as `Deregistered`. + +### Setup +```pseudo +captured_requests = mock_registration_server(overrides: (req) => { + IF req.method == "DELETE": + req.respond_with(400, {"error": {"message": "invalid credentials", "code": 40005, "statusCode": 400}}) + RETURN true + RETURN false +}) +mock_storage = MockPushStorage() +client = AWAIT activate_into(mock_storage) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.deactivate() # resolves despite the 40005 + +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "NotActivated") +``` + +--- + +## RSH3d2c1, RSH3g3 — deregistration failure fails deactivate and rolls back to the previous state + +**Test ID**: `rest/unit/RSH3g3b/deregister-failure-rollback-0` + +| Spec | Requirement | +|------|-------------| +| RSH3d2c1 | Status codes other than 2xx/401/40005 fire `DeregistrationFailed` (a non-retriable 4xx is injected so that SDKs implementing RSC15 fallback-host retries issue exactly one DELETE attempt) | +| RSH3g3a | Makes `Push#deactivate` return with the error | +| RSH3g3b | Transitions to the previous state (`WaitingForNewPushDeviceDetails` here) | + +Tests the rollback by observing that after the failure the device is still registered: a retry of `deactivate()` issues the DELETE again, and succeeds. + +### Setup +```pseudo +fail_delete = true +captured_requests = mock_registration_server(overrides: (req) => { + IF req.method == "DELETE" AND fail_delete: + req.respond_with(400, {"error": {"message": "deregistration rejected", "code": 40198, "statusCode": 400}}) + RETURN true + RETURN false +}) +mock_storage = MockPushStorage() +client = AWAIT activate_into(mock_storage) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.deactivate() FAILS WITH error +ASSERT error.code == 40198 + +# RSH3g3b — still registered: the identity token survives the failed deregistration +ASSERT mock_storage.dump()["ably.push.deviceIdentityToken"] IS NOT null + +# Retry succeeds from the rolled-back state +fail_delete = false +AWAIT client.push.deactivate() +ASSERT captured_requests.length == 3 # POST + failed DELETE + successful DELETE +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "NotActivated") +``` + +--- + +## RSH3g1a — repeated deactivate while deregistration is in flight is idempotent + +**Test ID**: `rest/unit/RSH3g1a/deactivate-while-deregistering-0` + +**Spec requirement:** RSH3g1a — In `WaitingForDeregistration`, on `CalledDeactivate`, transitions to `WaitingForDeregistration` (self). + +### Setup +```pseudo +held_delete = null +captured_requests = mock_registration_server(overrides: (req) => { + IF req.method == "DELETE" AND held_delete == null: + held_delete = req # hold the deregistration open + RETURN true + RETURN false +}) +mock_storage = MockPushStorage() +client = AWAIT activate_into(mock_storage) +``` + +### Test Steps +```pseudo +first = client.push.deactivate() +poll_until_success(() => held_delete != null) + +second = client.push.deactivate() # RSH3g1a — self-transition, no second DELETE + +held_delete.respond_with(204, "") + +AWAIT first +AWAIT second +``` + +### Assertions +```pseudo +# Exactly one DELETE was issued +delete_requests = captured_requests WHERE method == "DELETE" +ASSERT delete_requests.length == 1 +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "NotActivated") +``` + +--- + +## RSH3e3, RSH3f1 — a failed registration sync fails activate; re-activating retries the sync + +**Test ID**: `rest/unit/RSH3e3c/sync-failure-then-reactivate-0` + +| Spec | Requirement | +|------|-------------| +| RSH3e3c | On `SyncRegistrationFailed` (entered via `CalledActivate`), makes `Push#activate` return with the error | +| RSH3e3b | Transitions to `AfterRegistrationSyncFailed` | +| RSH3f1a | In `AfterRegistrationSyncFailed`, `CalledActivate` does the same as RSH3a2a (the RSH3a2a3 PATCH sync) | + +### Setup +```pseudo +fail_patch = true +captured_requests = mock_registration_server(overrides: (req) => { + IF req.method == "PATCH" AND fail_patch: + req.respond_with(400, {"error": {"message": "sync rejected", "code": 40199, "statusCode": 400}}) + RETURN true + RETURN false +}) +mock_storage = MockPushStorage() +AWAIT activate_into(mock_storage) +device_id = mock_storage.dump()["ably.push.deviceId"] +``` + +### Test Steps and Assertions +```pseudo +# Fresh client over registered storage: activate syncs via PATCH, which fails +client = push_client(mock_storage) +AWAIT client.push.activate() FAILS WITH error +ASSERT error.code == 40199 +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "AfterRegistrationSyncFailed") + +# RSH3f1a — activate again; the machine re-runs the RSH3a2a validation +fail_patch = false +AWAIT client.push.activate() + +patch_requests = captured_requests WHERE method == "PATCH" +ASSERT patch_requests.length == 2 +ASSERT patch_requests[1].url.path == "/push/deviceRegistrations/" + encode_uri_component(device_id) +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") +``` + +--- + +## RSH3f2a — deactivate from AfterRegistrationSyncFailed deregisters normally + +**Test ID**: `rest/unit/RSH3f2a/deactivate-after-sync-failure-0` + +**Spec requirement:** RSH3f2a — In `AfterRegistrationSyncFailed`, on `CalledDeactivate`, does the same as RSH3d2. + +### Setup +```pseudo +captured_requests = mock_registration_server(overrides: (req) => { + IF req.method == "PATCH": + req.respond_with(400, {"error": {"message": "sync rejected", "code": 40199, "statusCode": 400}}) + RETURN true + RETURN false +}) +mock_storage = MockPushStorage() +AWAIT activate_into(mock_storage) +device_id = mock_storage.dump()["ably.push.deviceId"] + +client = push_client(mock_storage) +AWAIT client.push.activate() FAILS WITH error # drive into AfterRegistrationSyncFailed +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "AfterRegistrationSyncFailed") +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.deactivate() + +delete_requests = captured_requests WHERE method == "DELETE" +ASSERT delete_requests.length == 1 +ASSERT delete_requests[0].url.query["deviceId"] == device_id +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "NotActivated") +``` + +--- + +## RSH3g3b — deregistration failure from AfterRegistrationSyncFailed rolls back to AfterRegistrationSyncFailed + +**Test ID**: `rest/unit/RSH3g3b/deregister-failure-rollback-after-sync-failed-1` + +**Spec requirement:** RSH3g3b — Transitions to the previous state, which is either `WaitingForNewPushDeviceDetails` or `AfterRegistrationSyncFailed`. + +Tests the second rollback target: after a failed deactivation from `AfterRegistrationSyncFailed`, the machine is back in `AfterRegistrationSyncFailed` — observable because a subsequent `activate()` re-runs the RSH3a2a validation (per `RSH3f1a`) rather than resolving immediately. + +### Setup +```pseudo +fail_patch = true +fail_delete = true +captured_requests = mock_registration_server(overrides: (req) => { + IF req.method == "PATCH" AND fail_patch: + req.respond_with(400, {"error": {"message": "sync rejected", "code": 40199, "statusCode": 400}}) + RETURN true + IF req.method == "DELETE" AND fail_delete: + req.respond_with(400, {"error": {"message": "deregistration rejected", "code": 40198, "statusCode": 400}}) + RETURN true + RETURN false +}) +mock_storage = MockPushStorage() +AWAIT activate_into(mock_storage) + +client = push_client(mock_storage) +AWAIT client.push.activate() FAILS WITH error # -> AfterRegistrationSyncFailed +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "AfterRegistrationSyncFailed") +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.deactivate() FAILS WITH error +ASSERT error.code == 40198 + +# Back in AfterRegistrationSyncFailed: activate re-syncs via PATCH (RSH3f1a) +fail_patch = false +AWAIT client.push.activate() +patch_requests = captured_requests WHERE method == "PATCH" +ASSERT patch_requests.length == 2 +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") +``` diff --git a/uts/rest/unit/push/push_device_auth.md b/uts/rest/unit/push/push_device_auth.md new file mode 100644 index 000000000..646fdde1d --- /dev/null +++ b/uts/rest/unit/push/push_device_auth.md @@ -0,0 +1,329 @@ +# Push Device Authentication Tests + +Spec points: `RSH6`, `RSH6a`, `RSH6b`, `RSH1b3`, `RSH1b5`, `RSH1c3`, `RSH1c4`, `RSH3d2b` + +## Test Type +Unit test with mocked HTTP client and mocked push platform + +## Mock HTTP Infrastructure + +See `uts/rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastructure specification. + +## Mock Push Platform Infrastructure + +See `uts/rest/unit/helpers/mock_push_platform.md` for the portable push platform primitives (`PushKeyValueStorage`, `requestToken`, `PushPlatformConfig`), the standard `ably.push.*` storage keys, and the `MockPushStorage` mock. + +## Notes + +These tests cover **push device authentication** (`RSH6`) — how an activated (or partially activated) push target device authenticates itself for requests that operate on its own registration — and the clauses of the push admin API (`RSH1b`/`RSH1c`) that require it: + +- `RSH6a` — *"If a device has completed activation and has a `deviceIdentityToken` then push device authentication is performed for a request by adding an `X-Ably-DeviceToken` request header whose value is the `deviceIdentityToken`."* The spec adds: this header *"has always been `X-Ably-DeviceToken`, but has previously been mistakenly documented as `X-Ably-DeviceIdentityToken` … It was never renamed."* +- `RSH6b` — *"If a device has not completed [activation] but has a `deviceSecret` then push device authentication is performed for a request by adding an `X-Ably-DeviceSecret` request header whose value is the `deviceSecret`."* + +The `RSH1b3`/`RSH1b5`/`RSH1c3`/`RSH1c4` admin operations must include push device authentication **only** when the `deviceId` they reference is that of the present, activated client. For any other `deviceId` (or a `clientId`-based subscription) the request carries no device-auth header — admin operations are otherwise authorised solely by the client's normal token/key auth. + +**Deviation caveats:** + +- As in `push_activation_state_machine.md`: an SDK using a different device-auth mechanism (e.g. an `Authorization` bearer header carrying the base64-encoded `deviceIdentityToken`, as ably-js's activation plugin does) must record a deviation and adapt the header assertions. +- ably-js's common push admin implementation (`src/common/lib/client/push.ts`) currently does **not** implement the own-device auth clauses of `RSH1b3`/`RSH1b5`/`RSH1c3`/`RSH1c4` at all — its admin requests never carry device auth. These tests are written to the feature spec; ably-js derived tests may record a deviation for the `RSH6a` admin tests below. +- ably-js does not implement `RSH6b` (`X-Ably-DeviceSecret`) — its device-auth path requires a `deviceIdentityToken` and fails without one. Derived tests for the `RSH6b` test below may record a deviation. +- The `RSH6b` test drives activation through a custom `registerCallback` whose result confers no `deviceIdentityToken`. Some SDKs may not accept a registration result without an identity token; if the scenario is unreachable that way, the derived test may instead construct it by seeding storage with `ably.push.deviceId`, `ably.push.deviceSecret`, and `ably.push.activationState` = `"WaitingForNewPushDeviceDetails"` — and **no** `ably.push.deviceIdentityToken` — then deactivating. +- `PushChannelSubscription.forDevice`/`forClientId` are the IDL's factories (`PCS5`); an SDK without them may construct the subscription by an equivalent means. + +## Shared Test Setup + +All tests use the following helpers (adapted from `push_activation_state_machine.md`, with routes added for the admin `channelSubscriptions` endpoints), plus the `BEFORE EACH TEST` / `AFTER EACH TEST` isolation from `mock_push_platform.md`. + +```pseudo +# HTTP mock routing registration and admin endpoints; captures every request. +# Individual tests override specific routes via the `overrides` handler, +# which is consulted first and may hold requests without responding. +FUNCTION mock_registration_server(overrides?: (req) => Boolean): + captured_requests = [] + mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF overrides != null AND overrides(req): + RETURN # the override handled (or held) the request + IF req.method == "POST" AND req.url.path == "/push/deviceRegistrations": + body = parse_json(req.body) + req.respond_with(201, merge(body, {"deviceIdentityToken": {"token": "ident-token-1"}})) + ELSE IF req.method == "PUT" AND req.url.path STARTS WITH "/push/deviceRegistrations/": + req.respond_with(200, parse_json(req.body)) + ELSE IF req.method == "PATCH" AND req.url.path STARTS WITH "/push/deviceRegistrations/": + req.respond_with(200, parse_json(req.body)) + ELSE IF req.method == "DELETE" AND req.url.path == "/push/deviceRegistrations": + req.respond_with(204, "") + ELSE IF req.method == "POST" AND req.url.path == "/push/channelSubscriptions": + req.respond_with(200, parse_json(req.body)) + ELSE IF req.method == "DELETE" AND req.url.path == "/push/channelSubscriptions": + req.respond_with(204, "") + ELSE: + req.respond_with(500, {"error": {"message": "unexpected request", "code": 50000}}) + } + ) + install_mock(mock_http) + RETURN captured_requests + +FUNCTION build_push_platform(storage, token?: PushDeviceToken, requestToken?: Function): + RETURN MockPushPlatform( + platform: "android", + formFactor: "phone", + storage: storage, + requestToken: requestToken ?? (() => token ?? PushDeviceToken(transportType: "fcm", token: "fcm-token-1")) + ) + +FUNCTION push_client(storage, clientId?: String, token?, requestToken?): + install_push_platform(build_push_platform(storage, token, requestToken)) + RETURN Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + clientId: clientId + )) + +# Runs a full activation so that `storage` holds a registered device +# (deviceId, deviceSecret, deviceIdentityToken, pushRecipient) and the +# persisted activation state is WaitingForNewPushDeviceDetails. +FUNCTION activate_into(storage, clientId?: String): + client = push_client(storage, clientId) + AWAIT client.push.activate() + poll_until_success(() => storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") + RETURN client +``` + +--- + +## RSH6a, RSH1b3 — deviceRegistrations.save for the present activated device includes device auth + +**Test ID**: `rest/unit/RSH6a/admin-device-registrations-save-own-device-0` + +| Spec | Requirement | +|------|-------------| +| RSH6a | Device auth is performed *"by adding an `X-Ably-DeviceToken` request header whose value is the `deviceIdentityToken`"* | +| RSH1b3 | `#save(device)` issues a `PUT` request to `/push/deviceRegistrations/:deviceId` using the `DeviceDetails` object argument. *"If the client has been activated as a push target device, and the specified `deviceId` is that of the present client, then this request must include push device authentication"* | + +Tests that after full activation, an admin `save()` whose `DeviceDetails.id` is the local device's id carries the `X-Ably-DeviceToken` header with the registered `deviceIdentityToken`. + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +client = AWAIT activate_into(mock_storage) +device_id = mock_storage.dump()["ably.push.deviceId"] +``` + +### Test Steps +```pseudo +device = DeviceDetails( + id: device_id, + platform: "android", + formFactor: "phone", + push: DevicePushDetails( + recipient: {"transportType": "fcm", "registrationToken": "fcm-token-1"} + ) +) + +AWAIT client.push.admin.deviceRegistrations.save(device) +``` + +### Assertions +```pseudo +# The seeding activation POST, then the admin save PUT +ASSERT captured_requests.length == 2 + +request = captured_requests[1] +ASSERT request.method == "PUT" +ASSERT request.url.path == "/push/deviceRegistrations/" + encode_uri_component(device_id) + +# RSH1b3 + RSH6a — the deviceId is that of the present activated client, +# so the request must include push device authentication +ASSERT request.headers["X-Ably-DeviceToken"] == "ident-token-1" +``` + +--- + +## RSH6a, RSH1b3 — deviceRegistrations.save for a different device carries no device auth + +**Test ID**: `rest/unit/RSH6a/admin-save-other-device-no-device-auth-1` + +**Spec requirement:** RSH1b3 — device auth is required only *"if the client has been activated as a push target device, and the specified `deviceId` is that of the present client"*. For any other `deviceId`, the request must **not** include push device authentication. + +Tests that the same activated client saving a `DeviceDetails` for a *different* `deviceId` sends no device-auth header. + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +client = AWAIT activate_into(mock_storage) +``` + +### Test Steps +```pseudo +device = DeviceDetails( + id: "other-device-1", + platform: "ios", + formFactor: "tablet", + push: DevicePushDetails( + recipient: {"transportType": "apns", "deviceToken": "apns-token-1"} + ) +) + +AWAIT client.push.admin.deviceRegistrations.save(device) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 2 + +request = captured_requests[1] +ASSERT request.method == "PUT" +ASSERT request.url.path == "/push/deviceRegistrations/" + encode_uri_component("other-device-1") + +# The deviceId is not that of the present client — no device auth +ASSERT "X-Ably-DeviceToken" NOT IN request.headers +ASSERT "X-Ably-DeviceSecret" NOT IN request.headers +``` + +--- + +## RSH6a, RSH1c3, RSH1c4 — channelSubscriptions save/remove for the present device include device auth + +**Test ID**: `rest/unit/RSH6a/admin-channel-subscriptions-save-own-device-2` + +| Spec | Requirement | +|------|-------------| +| RSH1c3 | `#save(pushChannelSubscription)` issues a `POST` request to `/push/channelSubscriptions`. *"If the client has been activated as a push target device, and the specified `PushChannelSubscription` contains a `deviceId` matching that of the present client, then this request must include push device authentication"* | +| RSH1c4 | `#remove(push_channel_subscription)` issues a `DELETE` request to `/push/channelSubscriptions` using the attributes as params. *"If the client has been activated as a push target device, and the specified `PushChannelSubscription` contains a `deviceId` matching that of the present client, then this request must include push device authentication"* | +| RSH6a | Device auth is the `X-Ably-DeviceToken` header carrying the `deviceIdentityToken` | + +Tests that an activated client saving, and then removing, a channel subscription for its own `deviceId` includes the `X-Ably-DeviceToken` header on both requests. + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +client = AWAIT activate_into(mock_storage) +device_id = mock_storage.dump()["ably.push.deviceId"] +``` + +### Test Steps +```pseudo +subscription = PushChannelSubscription.forDevice("push-test-channel", device_id) + +AWAIT client.push.admin.channelSubscriptions.save(subscription) # RSH1c3 +AWAIT client.push.admin.channelSubscriptions.remove(subscription) # RSH1c4 +``` + +### Assertions +```pseudo +# Seeding POST, then the subscription POST, then the subscription DELETE +ASSERT captured_requests.length == 3 + +# RSH1c3 — the save POST includes device auth +save_request = captured_requests[1] +ASSERT save_request.method == "POST" +ASSERT save_request.url.path == "/push/channelSubscriptions" +body = parse_json(save_request.body) +ASSERT body["channel"] == "push-test-channel" +ASSERT body["deviceId"] == device_id +ASSERT save_request.headers["X-Ably-DeviceToken"] == "ident-token-1" + +# RSH1c4 — the remove DELETE includes device auth +remove_request = captured_requests[2] +ASSERT remove_request.method == "DELETE" +ASSERT remove_request.url.path == "/push/channelSubscriptions" +ASSERT remove_request.url.queryParams["channel"] == "push-test-channel" +ASSERT remove_request.url.queryParams["deviceId"] == device_id +ASSERT remove_request.headers["X-Ably-DeviceToken"] == "ident-token-1" +``` + +--- + +## RSH6a, RSH1b5 — deviceRegistrations.removeWhere for the present device includes device auth + +**Test ID**: `rest/unit/RSH6a/admin-remove-where-own-device-3` + +| Spec | Requirement | +|------|-------------| +| RSH1b5 | `#removeWhere(params)` issues a `DELETE` request to `/push/deviceRegistrations` and deletes the registered devices matching the provided `params`. *"If the client has been activated as a push target device, and the specified `deviceId` is that of the present client, then this request must include push device authentication"* | +| RSH6a | Device auth is the `X-Ably-DeviceToken` header carrying the `deviceIdentityToken` | + +Tests that an activated client issuing `removeWhere(deviceId: )` includes the `X-Ably-DeviceToken` header on the DELETE. + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +client = AWAIT activate_into(mock_storage) +device_id = mock_storage.dump()["ably.push.deviceId"] +``` + +### Test Steps +```pseudo +AWAIT client.push.admin.deviceRegistrations.removeWhere({"deviceId": device_id}) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 2 + +request = captured_requests[1] +ASSERT request.method == "DELETE" +ASSERT request.url.path == "/push/deviceRegistrations" +ASSERT request.url.queryParams["deviceId"] == device_id + +# RSH1b5 + RSH6a — the deviceId param is that of the present activated client +ASSERT request.headers["X-Ably-DeviceToken"] == "ident-token-1" +``` + +--- + +## RSH6b — a device with a deviceSecret but no deviceIdentityToken authenticates with X-Ably-DeviceSecret + +**Test ID**: `rest/unit/RSH6b/device-secret-auth-before-identity-token-0` + +| Spec | Requirement | +|------|-------------| +| RSH6b | *"If a device has not completed [activation] but has a `deviceSecret` then push device authentication is performed for a request by adding an `X-Ably-DeviceSecret` request header whose value is the `deviceSecret`"* | +| RSH3d2b | The deregistration DELETE is made *"using the device's ID, with push device authentication"* | + +Tests the `deviceSecret` form of device auth: the device is activated through a custom `registerCallback` whose result confers **no** `deviceIdentityToken` (registration "succeeds" but the device never completes the identity-token half of activation). The subsequent `deactivate()` DELETE must then carry `X-Ably-DeviceSecret` — matching the persisted `ably.push.deviceSecret` — and **not** `X-Ably-DeviceToken`. + +See the Notes for the storage-seeding fallback if a derived SDK cannot reach this state via `registerCallback`, and the ably-js deviation caveat (ably-js does not implement `RSH6b`). + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +client = push_client(mock_storage) + +FUNCTION register_callback(device): + RETURN {} # registration succeeds but confers no deviceIdentityToken +``` + +### Test Steps +```pseudo +AWAIT client.push.activate(registerCallback: register_callback) +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") + +device_id = mock_storage.dump()["ably.push.deviceId"] +device_secret = mock_storage.dump()["ably.push.deviceSecret"] + +AWAIT client.push.deactivate() +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "NotActivated") +``` + +### Assertions +```pseudo +# Registration went through the callback, so the only HTTP request is the DELETE +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "DELETE" +ASSERT request.url.path == "/push/deviceRegistrations" +ASSERT request.url.queryParams["deviceId"] == device_id + +# RSH6b — deviceSecret auth, since the device has no deviceIdentityToken +ASSERT request.headers["X-Ably-DeviceSecret"] == device_secret +ASSERT "X-Ably-DeviceToken" NOT IN request.headers +``` diff --git a/uts/rest/unit/push/push_update_token.md b/uts/rest/unit/push/push_update_token.md new file mode 100644 index 000000000..392bddcb0 --- /dev/null +++ b/uts/rest/unit/push/push_update_token.md @@ -0,0 +1,647 @@ +# Push updateToken Tests + +Spec points: `RSH2f`, `RSH2f1`, `RSH2f2`, `RSH2f3`, `RSH3a2a3`, `RSH3a3a`, `RSH3d3`, `RSH3d3a`, `RSH3d3b`, `RSH3d3c`, `RSH3d3d`, `RSH3e1a`, `RSH3e1b`, `RSH3e2a`, `RSH3e2c`, `RSH3e3b`, `RSH3e3d`, `RSH3f1a`, `RSH3g2a`, `RSH3h`, `RSH4`, `RSH6a`, `RSH8g`, `RSH8l2`, `PCP3a`, `PDT4` + +## Test Type +Unit test with mocked HTTP client and mocked push platform + +## Mock HTTP Infrastructure + +See `uts/rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastructure specification. + +## Mock Push Platform Infrastructure + +See `uts/rest/unit/helpers/mock_push_platform.md` for the portable push platform primitives (`PushKeyValueStorage`, `requestToken`, `PushPlatformConfig`, `PushDeviceToken`), the standard `ably.push.*` storage keys, and the `MockPushStorage` mock. + +## Notes + +`push.updateToken(token: PushDeviceToken)` is specified by `RSH2f` (with `PushDeviceToken` per `PDT1`–`PDT4`): the portable API (established by ably-js PR #2267) through which the application delivers a rotated or additional platform token, thereby producing the `RSH8g` `GotPushDeviceDetails` event. Note `RSH2f`/`RSH8l`/`PCP3a`/`PDT*` are part of the pending token-variants spec extension (drafted in `specifications/features.md`, not yet merged upstream); the remaining anchors are long-established points. + +`updateToken` resolves once the new recipient has been persisted (to `ably.push.pushRecipient`) and the `GotPushDeviceDetails` event has been handed to the state machine. The registration sync the event triggers (`RSH3d3b`/`RSH3d3c`) is **fire-and-forget**: its outcome is reported through the `updatedCallback` provided to `Push#activate` (`RSH3e2c`/`RSH3e3d`), never through the `updateToken` return value. Tests therefore poll for the sync's observable effects (the HTTP request, the persisted state) rather than awaiting a promise. + +The client-side guards are specified by `RSH2f1` (validation) and `RSH2f2` (activation required): `updateToken` fails with `code` 40000 — before any event reaches the machine and without any HTTP request — when the device is not activated (no `deviceIdentityToken`), when the token is null or malformed, when the token string is empty, or when `transportType` is `"web"` (web recipients are constructed by the SDK's own web-platform flow and are not app-suppliable — see the token-to-recipient table in `mock_push_platform.md`). + +These tests are **black-box**, following `push_activation_state_machine.md`: they never construct events or inspect machine state directly. Events are produced by driving the public API (`push.activate()`, `push.deactivate()`, `push.updateToken()`) and by responding to the mocked HTTP requests the machine issues. State is observed through behaviour (which requests are made, which operations resolve or fail) and, after operations settle, through the persisted `ably.push.activationState`. To pin the machine in an intermediate state, tests hold a `PendingRequest` (capture it in the `onRequest` handler without responding) and release it later; `Deferred` is as defined in `push_activation_state_machine.md`. + +Device authentication assertions follow `RSH6a` (`X-Ably-DeviceToken` header). An SDK using a different device-auth mechanism (e.g. an `Authorization` bearer header carrying the `deviceIdentityToken` — as ably-js does) must record a deviation and adapt the assertion. + +**Known ably-js deviations** (record in derived tests): + +- `RSH3e3d` says a sync failure not attributable to a `CalledActivate` calls the `updatedCallback` provided to `Push#activate` with the error; `RSH3e2c` says a successful sync calls it with no error. ably-js currently routes failures to the deprecated `updateFailedCallback` (`RSH3e3a`) and has no success-notification path at all, so derived ably-js tests must adapt the callback assertions in `update-token-sync-failure-callback-4` (deliver the error via `updateFailedCallback`; omit the no-error `RSH3e2c` assertion). +- (Resolved — no longer a deviation:) `RSH3a2a3` now defines the re-activation/retry sync as the same `RSH3d3b` PATCH carrying the complete recipient (with a full-body PUT permitted as a legacy equivalent, as ably-java and ably-cocoa do). ably-js's PATCH-based `updateRegistration()` in `AfterRegistrationSyncFailed` is therefore conformant, and the retry-request assertions in `update-token-sync-failure-callback-4` assert the PATCH directly. + +## Shared Test Setup + +All tests use the following helpers, plus the `BEFORE EACH TEST` / `AFTER EACH TEST` isolation from `mock_push_platform.md`. They are the helpers of `push_activation_state_machine.md`, with `build_push_platform`/`push_client` extended to take an optional `platform`. + +```pseudo +# HTTP mock routing registration endpoints; captures every request. +# Individual tests override specific routes via the `overrides` handler, +# which is consulted first and may hold requests without responding. +FUNCTION mock_registration_server(overrides?: (req) => Boolean): + captured_requests = [] + mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF overrides != null AND overrides(req): + RETURN # the override handled (or held) the request + IF req.method == "POST" AND req.url.path == "/push/deviceRegistrations": + body = parse_json(req.body) + req.respond_with(201, merge(body, {"deviceIdentityToken": {"token": "ident-token-1"}})) + ELSE IF req.method == "PUT" AND req.url.path STARTS WITH "/push/deviceRegistrations/": + req.respond_with(200, parse_json(req.body)) + ELSE IF req.method == "PATCH" AND req.url.path STARTS WITH "/push/deviceRegistrations/": + req.respond_with(200, parse_json(req.body)) + ELSE IF req.method == "DELETE" AND req.url.path == "/push/deviceRegistrations": + req.respond_with(204, "") + ELSE: + req.respond_with(500, {"error": {"message": "unexpected request", "code": 50000}}) + } + ) + install_mock(mock_http) + RETURN captured_requests + +FUNCTION build_push_platform(storage, token?: PushDeviceToken, requestToken?: Function, platform?: String): + RETURN MockPushPlatform( + platform: platform ?? "android", + formFactor: "phone", + storage: storage, + requestToken: requestToken ?? (() => token ?? PushDeviceToken(transportType: "fcm", token: "fcm-token-1")) + ) + +FUNCTION push_client(storage, clientId?: String, token?, requestToken?, platform?): + install_push_platform(build_push_platform(storage, token, requestToken, platform)) + RETURN Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + clientId: clientId + )) + +# Runs a full activation so that `storage` holds a registered device +# (deviceId, deviceSecret, deviceIdentityToken, pushRecipient) and the +# persisted activation state is WaitingForNewPushDeviceDetails. +FUNCTION activate_into(storage, clientId?: String): + client = push_client(storage, clientId) + AWAIT client.push.activate() + poll_until_success(() => storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") + RETURN client +``` + +--- + +## RSH8g, RSH3d3 — a rotated fcm token is synced via PATCH with changed fields only + +**Test ID**: `rest/unit/RSH3d3b/update-token-patch-0` + +| Spec | Requirement | +|------|-------------| +| RSH8g | A change of the push transport details sends a `GotPushDeviceDetails` event to the state machine | +| RSH3d3 | In `WaitingForNewPushDeviceDetails`, `GotPushDeviceDetails` occurs when the push details change after first being set (e.g. FCM registration token refresh) | +| RSH3d3b | Makes an asynchronous PATCH to `/push/deviceRegistrations/:deviceId` with only the changed fields as body; requires push device authentication | +| RSH3d3c | When the sync is done, a `RegistrationSynced` or `SyncRegistrationFailed` event is fired | +| RSH3e2a | On `RegistrationSynced`, transitions to `WaitingForNewPushDeviceDetails` | +| RSH6a | Push device authentication adds an `X-Ably-DeviceToken` header whose value is the `deviceIdentityToken` | + +Tests that delivering a rotated FCM token to an activated device produces the RSH3d3b sync: a PATCH addressed to the device, carrying only the new recipient, authenticated as the device, and that the new recipient is persisted and the machine settles back in `WaitingForNewPushDeviceDetails`. + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +client = AWAIT activate_into(mock_storage) +device_id = mock_storage.dump()["ably.push.deviceId"] +``` + +### Test Steps +```pseudo +AWAIT client.push.updateToken(PushDeviceToken(transportType: "fcm", token: "fcm-token-2")) + +# The sync is fire-and-forget: poll for the PATCH it issues +poll_until(() => captured_requests.length == 2) +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") +``` + +### Assertions +```pseudo +request = captured_requests[1] +ASSERT request.method == "PATCH" +ASSERT request.url.path == "/push/deviceRegistrations/" + encode_uri_component(device_id) + +# RSH3d3b — only the changed fields travel in the body; the device id is in the URL +ASSERT parse_json(request.body) == { + "push": {"recipient": {"transportType": "fcm", "registrationToken": "fcm-token-2"}} +} + +# RSH3d3b + RSH6a — push device authentication +ASSERT request.headers["X-Ably-DeviceToken"] == "ident-token-1" + +# The rotated recipient was persisted +ASSERT parse_json(mock_storage.dump()["ably.push.pushRecipient"]) == { + "transportType": "fcm", + "registrationToken": "fcm-token-2" +} +``` + +--- + +## RSH8g — an apns token maps to an apns recipient + +**Test ID**: `rest/unit/RSH8g/update-token-apns-recipient-1` + +| Spec | Requirement | +|------|-------------| +| RSH8g | A change of the push transport details sends a `GotPushDeviceDetails` event to the state machine | +| RSH3d3b | The PATCH body carries the changed push details — here an `apns` recipient, whose token field is `deviceToken` (see the token-to-recipient table in `mock_push_platform.md`) | + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +client = push_client(mock_storage, token: PushDeviceToken(transportType: "apns", token: "apns-token-1"), platform: "ios") +AWAIT client.push.activate() +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") +``` + +### Test Steps +```pseudo +AWAIT client.push.updateToken(PushDeviceToken(transportType: "apns", token: "apns-token-2")) +poll_until(() => captured_requests.length == 2) +``` + +### Assertions +```pseudo +request = captured_requests[1] +ASSERT request.method == "PATCH" +ASSERT parse_json(request.body) == { + "push": {"recipient": {"transportType": "apns", "deviceToken": "apns-token-2"}} +} +``` + +--- + +## RSH2f2 — updateToken requires an activated device, and does not disturb a later activation + +**Test ID**: `rest/unit/RSH2f2/update-token-requires-activation-2` + +**Spec requirement:** `RSH2f2` — `updateToken` requires that the device has completed activation (has a `deviceIdentityToken`); otherwise it is rejected with an error with `code` 40000, without any effect. The guard fires before any event reaches the state machine, so a subsequent `activate()` proceeds entirely normally. + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +client = push_client(mock_storage) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.updateToken(PushDeviceToken(transportType: "fcm", token: "fcm-token-2")) FAILS WITH error +ASSERT error.code == 40000 + +# Nothing reached the machine or the network +process_pending_events() +ASSERT captured_requests.length == 0 + +# A subsequent activation is unaffected by the rejected update +AWAIT client.push.activate() +ASSERT captured_requests.length == 1 +ASSERT captured_requests[0].method == "POST" +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") +``` + +--- + +## RSH8g, RSH3h — updateToken works from persisted state on a cold start, without activate() this session + +**Test ID**: `rest/unit/RSH8g/update-token-cold-start-3` + +| Spec | Requirement | +|------|-------------| +| RSH8g | A change of the push transport details sends a `GotPushDeviceDetails` event to the state machine | +| RSH3h | The state machine is initialised from the persisted Activation State Machine data when an event first needs to be delivered to it | +| RSH3d3b | The sync PATCH is addressed to the persisted device's id | + +Tests that a fresh client over storage holding a registered device (persisted state `WaitingForNewPushDeviceDetails`) can deliver a rotated token without `activate()` having been called this session: the machine hydrates from storage and runs the RSH3d3 sync against the persisted registration. + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +AWAIT activate_into(mock_storage) +device_id = mock_storage.dump()["ably.push.deviceId"] +``` + +### Test Steps +```pseudo +# A fresh client over the same storage simulates an app restart +restarted = push_client(mock_storage) +AWAIT restarted.push.updateToken(PushDeviceToken(transportType: "fcm", token: "fcm-token-2")) +poll_until(() => captured_requests.length == 2) +``` + +### Assertions +```pseudo +request = captured_requests[1] +ASSERT request.method == "PATCH" +# The restarted client loaded the persisted device id and addressed the same registration +ASSERT request.url.path == "/push/deviceRegistrations/" + encode_uri_component(device_id) +ASSERT parse_json(request.body)["push"]["recipient"]["registrationToken"] == "fcm-token-2" +``` + +--- + +## RSH3e3d, RSH3f1a — a failed sync is reported via updatedCallback; a retry re-validates per RSH3a2a + +**Test ID**: `rest/unit/RSH3e3d/update-token-sync-failure-callback-4` + +| Spec | Requirement | +|------|-------------| +| RSH3d3c | When the sync is done, a `RegistrationSynced` or `SyncRegistrationFailed` event is fired | +| RSH3e3d | On `SyncRegistrationFailed` (not entered via `CalledActivate`), calls the `updatedCallback` provided to `Push#activate` with the error | +| RSH3e3b | Transitions to `AfterRegistrationSyncFailed` | +| RSH3f1a | In `AfterRegistrationSyncFailed`, `GotPushDeviceDetails` does the same as RSH3a2a | +| RSH3a2a3 | Performs the RSH3d3b sync: an HTTP PATCH to `/push/deviceRegistrations/:deviceId` carrying the complete `push.recipient` | +| RSH3e2c | On `RegistrationSynced` (not entered via `CalledActivate`), calls the `updatedCallback` with no error | +| RSH3e2a | Transitions to `WaitingForNewPushDeviceDetails` | + +Tests that a server rejection of the sync surfaces through the `updatedCallback` (not through `updateToken`, which has already resolved — the sync is fire-and-forget), that the rotated recipient is nonetheless persisted, and that a retry from `AfterRegistrationSyncFailed` re-runs the RSH3a2a validation — which per RSH3a2a3 is the same RSH3d3b PATCH sync. See Notes for the ably-js deviation this test records (`updateFailedCallback` instead of `updatedCallback`). + +### Setup +```pseudo +fail_patch = true +captured_requests = mock_registration_server(overrides: (req) => { + IF req.method == "PATCH" AND fail_patch: + req.respond_with(400, {"error": {"message": "sync rejected", "code": 40199, "statusCode": 400}}) + RETURN true + RETURN false +}) +mock_storage = MockPushStorage() +client = push_client(mock_storage) + +sync_results = [] +AWAIT client.push.activate(updatedCallback: (error) => sync_results.append(error)) +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") +device_id = mock_storage.dump()["ably.push.deviceId"] +``` + +### Test Steps and Assertions +```pseudo +# The sync is fire-and-forget: updateToken resolves despite the PATCH failing +AWAIT client.push.updateToken(PushDeviceToken(transportType: "fcm", token: "fcm-token-2")) + +# RSH3e3d — the failure reaches the updatedCallback +poll_until(() => sync_results.length == 1) +ASSERT sync_results[0].code == 40199 + +# The rotated recipient was persisted even though the sync failed +ASSERT parse_json(mock_storage.dump()["ably.push.pushRecipient"]) == { + "transportType": "fcm", + "registrationToken": "fcm-token-2" +} + +# RSH3e3b — the machine is in AfterRegistrationSyncFailed +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "AfterRegistrationSyncFailed") + +# RSH3f1a — a retry with the server healthy re-runs the RSH3a2a validation, which +# per RSH3a2a3 is the same RSH3d3b sync: a second PATCH with the complete recipient +fail_patch = false +AWAIT client.push.updateToken(PushDeviceToken(transportType: "fcm", token: "fcm-token-2")) + +poll_until(() => (captured_requests WHERE method == "PATCH").length == 2) +retry = (captured_requests WHERE method == "PATCH")[1] +ASSERT retry.url.path == "/push/deviceRegistrations/" + encode_uri_component(device_id) +retry_body = parse_json(retry.body) +ASSERT retry_body["push"]["recipient"]["registrationToken"] == "fcm-token-2" + +# RSH3e2c — the successful sync reaches the updatedCallback with no error +poll_until(() => sync_results.length == 2) +ASSERT sync_results[1] == null + +# RSH3e2a — settled back in WaitingForNewPushDeviceDetails +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") +``` + +--- + +## RSH2f1 — malformed tokens are rejected without touching the machine, the network, or storage + +**Test ID**: `rest/unit/RSH2f1/update-token-validation-5` + +**Spec requirement:** `RSH2f1` — the provided token must carry a supported `transportType` and a non-empty `token`; an invalid token is rejected with an error with `code` 40000, without any effect on the `LocalDevice` or the state machine. A null token, a `"web"` transport (web recipients are not app-suppliable — see `mock_push_platform.md`), and an empty token string each fail, producing no `GotPushDeviceDetails` event, no HTTP request, and no storage change. + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +client = AWAIT activate_into(mock_storage) +persisted_before = mock_storage.dump() +``` + +### Test Steps and Assertions +```pseudo +FOR bad IN [null, + PushDeviceToken(transportType: "web", token: "web-token-1"), + PushDeviceToken(transportType: "fcm", token: "")]: + AWAIT client.push.updateToken(bad) FAILS WITH error + ASSERT error.code == 40000 + +process_pending_events() +ASSERT captured_requests.length == 1 # just the activation POST +ASSERT mock_storage.dump() == persisted_before # storage untouched, recipient included +``` + +--- + +## RSH3d3a — a device activated via a custom registerCallback syncs through the same callback + +**Test ID**: `rest/unit/RSH3d3a/update-token-register-callback-6` + +| Spec | Requirement | +|------|-------------| +| RSH3d3a | If a custom `registerCallback` was provided to `Push#activate`, pass it the local `DeviceDetails` updated with the push details (instead of the PATCH of RSH3d3b) | +| RSH3d3c | When the sync is done, a `RegistrationSynced` or `SyncRegistrationFailed` event is fired | + +Tests that the token-rotation sync is routed through the customer's registrar with the new recipient, and no HTTP request is made at any point. + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +client = push_client(mock_storage) + +registered_devices = [] +FUNCTION register_callback(device): + registered_devices.append(device) + RETURN {"deviceIdentityToken": {"token": "custom-ident-1"}} + +AWAIT client.push.activate(registerCallback: register_callback) +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") +``` + +### Test Steps +```pseudo +AWAIT client.push.updateToken(PushDeviceToken(transportType: "fcm", token: "fcm-token-2")) +poll_until(() => registered_devices.length == 2) +``` + +### Assertions +```pseudo +# RSH3d3a — the sync went through the same registerCallback, with the new recipient +ASSERT registered_devices[1].push.recipient == { + "transportType": "fcm", + "registrationToken": "fcm-token-2" +} + +# No HTTP at all: neither the registration nor the sync touched the network +ASSERT captured_requests.length == 0 + +# The rotated recipient was persisted +poll_until_success(() => parse_json(mock_storage.dump()["ably.push.pushRecipient"])["registrationToken"] == "fcm-token-2") +``` + +--- + +## RSH3e1a — activate during an in-flight token sync resolves immediately without a request + +**Test ID**: `rest/unit/RSH3e1a/activate-during-token-sync-7` + +| Spec | Requirement | +|------|-------------| +| RSH3d3d | `GotPushDeviceDetails` transitions to `WaitingForRegistrationSync` | +| RSH3e1a | In `WaitingForRegistrationSync` not entered via `CalledActivate`, on `CalledActivate`, makes `Push#activate` return with no error | +| RSH3e1b | Transitions to `WaitingForRegistrationSync` (self) | +| RSH3e2a | On `RegistrationSynced`, transitions to `WaitingForNewPushDeviceDetails` | + +Tests that with the RSH3d3b PATCH held open — pinning the machine in `WaitingForRegistrationSync` entered via `GotPushDeviceDetails` — an `activate()` resolves with no error without waiting for the sync and without issuing any request (the `RSH3e1` "unless ... as a result of a `CalledActivate` event" carve-out does not apply here). + +### Setup +```pseudo +held_patch = null +captured_requests = mock_registration_server(overrides: (req) => { + IF req.method == "PATCH" AND held_patch == null: + held_patch = req # hold the sync open + RETURN true + RETURN false +}) +mock_storage = MockPushStorage() +client = AWAIT activate_into(mock_storage) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.updateToken(PushDeviceToken(transportType: "fcm", token: "fcm-token-2")) +poll_until(() => held_patch != null) # machine now in WaitingForRegistrationSync (RSH3d3d) + +# RSH3e1a — resolves while the PATCH is still held, so it did not wait for the sync +AWAIT client.push.activate() + +# RSH3e1b — self-transition: no request was issued for the activate +process_pending_events() +ASSERT captured_requests.length == 2 # activation POST + held PATCH only + +held_patch.respond_with(200, parse_json(held_patch.body)) + +# RSH3e2a — the released sync settles the machine in WaitingForNewPushDeviceDetails +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") +``` + +--- + +## RSH4 — an update issued during an in-flight sync is queued and applied after it settles + +**Test ID**: `rest/unit/RSH4/update-token-queued-behind-inflight-sync-8` + +| Spec | Requirement | +|------|-------------| +| RSH4 | An event with no transition defined in the current state is queued, and dequeued after the next transition | +| RSH3d3b | The dequeued `GotPushDeviceDetails`, consumed in `WaitingForNewPushDeviceDetails`, issues its own changed-fields PATCH | + +`WaitingForRegistrationSync` defines no transition for `GotPushDeviceDetails`, so the second update's event queues (RSH4) behind the held sync; when the first sync settles (`RegistrationSynced` → `WaitingForNewPushDeviceDetails`, `RSH3e2a`), the queued event is dequeued and consumed per RSH3d3. + +### Setup +```pseudo +held_patch = null +captured_requests = mock_registration_server(overrides: (req) => { + IF req.method == "PATCH" AND held_patch == null: + held_patch = req # hold the first sync open; later PATCHes use the default route + RETURN true + RETURN false +}) +mock_storage = MockPushStorage() +client = AWAIT activate_into(mock_storage) +``` + +### Test Steps +```pseudo +AWAIT client.push.updateToken(PushDeviceToken(transportType: "fcm", token: "fcm-token-2")) +poll_until(() => held_patch != null) + +# Resolves (recipient persisted, event handed over), but its sync is queued per RSH4 +AWAIT client.push.updateToken(PushDeviceToken(transportType: "fcm", token: "fcm-token-3")) + +process_pending_events() +ASSERT (captured_requests WHERE method == "PATCH").length == 1 # only the held one + +held_patch.respond_with(200, parse_json(held_patch.body)) +poll_until(() => (captured_requests WHERE method == "PATCH").length == 2) +``` + +### Assertions +```pseudo +patches = captured_requests WHERE method == "PATCH" +ASSERT parse_json(patches[1].body) == { + "push": {"recipient": {"transportType": "fcm", "registrationToken": "fcm-token-3"}} +} + +poll_until_success(() => parse_json(mock_storage.dump()["ably.push.pushRecipient"])["registrationToken"] == "fcm-token-3") +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") +``` + +--- + +## RSH4 — an update racing a deactivation is discarded once the device is deregistered + +**Test ID**: `rest/unit/RSH4/update-token-discarded-after-deregistration-9` + +| Spec | Requirement | +|------|-------------| +| RSH4 | An event with no transition defined in the current state is queued, and dequeued after the next transition | +| RSH3a3a | In `NotActivated`, `GotPushDeviceDetails` is consumed with a self-transition | +| RSH3g2a | On `Deregistered`, clears all local `DeviceDetails` | + +`WaitingForDeregistration` defines no transition for `GotPushDeviceDetails`, so the update's event queues (RSH4). Deregistration then lands the machine in `NotActivated`, where the dequeued event is consumed per RSH3a3a — so no sync PATCH is ever issued, and the recipient the update persisted has been cleared by RSH3g2a. + +### Setup +```pseudo +held_delete = null +captured_requests = mock_registration_server(overrides: (req) => { + IF req.method == "DELETE" AND held_delete == null: + held_delete = req # hold the deregistration open + RETURN true + RETURN false +}) +mock_storage = MockPushStorage() +client = AWAIT activate_into(mock_storage) +``` + +### Test Steps +```pseudo +deactivation = client.push.deactivate() +poll_until(() => held_delete != null) + +# The device still has its deviceIdentityToken, so the guard passes; the event queues +AWAIT client.push.updateToken(PushDeviceToken(transportType: "fcm", token: "fcm-token-2")) + +held_delete.respond_with(204, "") +AWAIT deactivation +poll_until_success(() => mock_storage.dump()["ably.push.activationState"] == "NotActivated") +``` + +### Assertions +```pseudo +# RSH4 + RSH3a3a — the queued event was consumed in NotActivated: no sync ever ran +process_pending_events() +ASSERT (captured_requests WHERE method == "PATCH").length == 0 + +# RSH3g2a — deregistration removed the recipient the update had persisted +ASSERT "ably.push.pushRecipient" NOT IN mock_storage.dump() +``` + +--- + +## RSH8l2, PCP3a — registering a push-to-start token adds a variant slot without disturbing the default token + +**Test ID**: `rest/unit/RSH8l2/update-token-push-to-start-10` + +| Spec | Requirement | +|------|-------------| +| RSH2f3 | The new details are applied to the recipient, for an `apns` token to the slot indicated by its `apnsTokenType` | +| PCP3a | Variant tokens are carried in the recipient's `apnsDeviceTokens` map, keyed by slot name | +| RSH8l2 | Registering a variant is a change of push transport details (`RSH8g`); the sync carries the complete updated recipient, including the unchanged variants | + +Tests that delivering a Live Activity push-to-start token via `updateToken` on a device activated with a default APNs token adds the `pushToStart` slot to the recipient — and keeps the default token registered. + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +client = AWAIT activate_into_apns(mock_storage) # defined below +device_id = mock_storage.dump()["ably.push.deviceId"] + +# Activation as in activate_into, but as an ios/apns device +FUNCTION activate_into_apns(storage): + client = push_client(storage, token: PushDeviceToken(transportType: "apns", token: "apns-token-1"), platform: "ios") + AWAIT client.push.activate() + poll_until_success(() => storage.dump()["ably.push.activationState"] == "WaitingForNewPushDeviceDetails") + RETURN client +``` + +### Test Steps +```pseudo +AWAIT client.push.updateToken(PushDeviceToken( + transportType: "apns", + token: "pts-token-1", + apnsTokenType: "pushToStart" +)) + +poll_until(() => (captured_requests WHERE method == "PATCH").length == 1, interval: 50ms, timeout: 5 seconds) +``` + +### Assertions +```pseudo +patch = (captured_requests WHERE method == "PATCH")[0] +ASSERT patch.url.path == "/push/deviceRegistrations/" + encode_uri_component(device_id) + +recipient = parse_json(patch.body)["push"]["recipient"] +ASSERT recipient["transportType"] == "apns" + +# PCP3a — the variant landed in its slot +ASSERT recipient["apnsDeviceTokens"]["pushToStart"] == "pts-token-1" + +# RSH8l2 — the default token was preserved (either representation per PCP3a) +ASSERT recipient["deviceToken"] == "apns-token-1" OR recipient["apnsDeviceTokens"]["default"] == "apns-token-1" + +# The full recipient, variants included, is persisted +persisted_recipient = parse_json(mock_storage.dump()["ably.push.pushRecipient"]) +ASSERT persisted_recipient["apnsDeviceTokens"]["pushToStart"] == "pts-token-1" +``` + +--- + +## RSH8l2 — rotating the default token preserves registered variant slots + +**Test ID**: `rest/unit/RSH8l2/update-token-variant-preserves-others-11` + +**Spec requirement:** `RSH8l2` — the registration, update or removal of any single variant is a change of the push transport details, and the ensuing sync carries the complete updated recipient *including the unchanged variants*. Rotating the default token after a `pushToStart` token has been registered must not drop the `pushToStart` slot. + +### Setup +```pseudo +captured_requests = mock_registration_server() +mock_storage = MockPushStorage() +client = AWAIT activate_into_apns(mock_storage) # as in update-token-push-to-start-10 + +AWAIT client.push.updateToken(PushDeviceToken(transportType: "apns", token: "pts-token-1", apnsTokenType: "pushToStart")) +poll_until(() => (captured_requests WHERE method == "PATCH").length == 1, interval: 50ms, timeout: 5 seconds) +``` + +### Test Steps +```pseudo +# Rotate the default token (apnsTokenType absent — defaults to "default" per PDT4) +AWAIT client.push.updateToken(PushDeviceToken(transportType: "apns", token: "apns-token-2")) + +poll_until(() => (captured_requests WHERE method == "PATCH").length == 2, interval: 50ms, timeout: 5 seconds) +``` + +### Assertions +```pseudo +patch = (captured_requests WHERE method == "PATCH")[1] +recipient = parse_json(patch.body)["push"]["recipient"] + +# The rotated default token +ASSERT recipient["deviceToken"] == "apns-token-2" OR recipient["apnsDeviceTokens"]["default"] == "apns-token-2" + +# RSH8l2 — the pushToStart variant survived the default-token rotation +ASSERT recipient["apnsDeviceTokens"]["pushToStart"] == "pts-token-1" + +persisted_recipient = parse_json(mock_storage.dump()["ably.push.pushRecipient"]) +ASSERT persisted_recipient["apnsDeviceTokens"]["pushToStart"] == "pts-token-1" +``` diff --git a/uts/rest/unit/types/push_types.md b/uts/rest/unit/types/push_types.md new file mode 100644 index 000000000..da2a509b2 --- /dev/null +++ b/uts/rest/unit/types/push_types.md @@ -0,0 +1,269 @@ +# Push Type Tests + +Spec points: `PCD1`, `PCD2`, `PCD3`, `PCD4`, `PCD5`, `PCD6`, `PCD7`, `PCP1`, `PCP2`, `PCP3`, `PCP4`, `PCS1`, `PCS2`, `PCS3`, `PCS4`, `PCS5` + +## Test Type +Unit test — pure type construction and serialization, no HTTP mock needed. + +## Notes + +- **Push state wire casing (PCP4):** the feature spec names the `DevicePushDetails.state` values `Active`, `Failing`, `Failed`; ably-js (`src/common/lib/types/devicedetails.ts`) types the wire value as uppercase — `'ACTIVE' | 'FAILING' | 'FAILED'` — which these tests use as the wire form. Assertions on the parsed attribute use the spec's enum members (`DevicePushState.Active` etc.); an SDK that exposes the raw wire string, or that observes a different server casing, must adapt those assertions and record a deviation. +- **`errorReason` wire field (PCP2):** the spec names the attribute `errorReason`. ably-js's wire mapping writes the push error as `error` under `push` (see `toJSON()` in `devicedetails.ts`); ably-js derived tests may record a deviation on the wire field name. +- **`metadata` (PCD5):** the spec defines it as *"a map of string key/value pairs"*; ably-js currently types `metadata` as a plain string. These tests follow the spec (a map); ably-js derived tests may record a deviation. +- **PCS5 enforcement is language-specific:** *"precisely one of `deviceId` or `clientId` must be non-null -- this should be enforced by a mechanism appropriate to the language, for example one constructor that takes a device ID and one that takes a client ID."* The IDL prescribes the `+forDevice(channel, deviceId)` and `+forClientId(channel, clientId)` factories. ably-js currently exposes neither factory nor any exactly-one enforcement (`fromValues` copies fields as-received); ably-js derived tests may record deviations for the factory tests, constructing subscriptions by an equivalent means. + +--- + +## PCD1–PCD7 — DeviceDetails round-trips all attributes through wire JSON + +**Test ID**: `rest/unit/PCD1/device-details-round-trip-0` + +| Spec | Requirement | +|------|-------------| +| PCD1 | `DeviceDetails` — details of a registered device, consisting of the following attributes | +| PCD2 | `id` string — the id of the device registration | +| PCD3 | `clientId` string — (optional, populated for device registrations associated with a `clientId`) | +| PCD4 | `formFactor` — the device formfactor | +| PCD5 | `metadata` — a map of string key/value pairs containing any other registered metadata | +| PCD6 | `platform` — the device platform | +| PCD7 | `push` `DevicePushDetails` — details of the push registration for this device | +| PCP2 | `errorReason` `ErrorInfo` — (optional) any error information associated with the registration | +| PCP3 | `recipient` — a map of string key/value pairs containing details of the push transport and address | +| PCP4 | `state` — the state of the push registration | + +Tests that a full `DeviceDetails` parses every attribute from wire JSON, and that serializing it back reproduces the same wire fields. + +### Test Steps +```pseudo +wire = { + "id": "device-001", + "clientId": "client-abc", + "platform": "android", + "formFactor": "phone", + "metadata": {"environment": "test"}, + "push": { + "recipient": {"transportType": "fcm", "registrationToken": "reg-token-1"}, + "state": "ACTIVE", + "errorReason": {"code": 40000, "statusCode": 400, "message": "example error"} + } +} + +device = DeviceDetails.fromJson(wire) + +ASSERT device.id == "device-001" # PCD2 +ASSERT device.clientId == "client-abc" # PCD3 +ASSERT device.formFactor == DeviceFormFactor.phone # PCD4 +ASSERT device.metadata == {"environment": "test"} # PCD5 +ASSERT device.platform == DevicePlatform.android # PCD6 + +# PCD7 — push is a DevicePushDetails (PCP1) +ASSERT device.push IS DevicePushDetails +ASSERT device.push.recipient == { # PCP3 + "transportType": "fcm", + "registrationToken": "reg-token-1" +} +ASSERT device.push.state == DevicePushState.Active # PCP4 +ASSERT device.push.errorReason IS ErrorInfo # PCP2 +ASSERT device.push.errorReason.code == 40000 +ASSERT device.push.errorReason.statusCode == 400 +ASSERT device.push.errorReason.message == "example error" + +# Round trip — serialization reproduces the wire fields +json_data = device.toJson() +ASSERT json_data["id"] == "device-001" +ASSERT json_data["clientId"] == "client-abc" +ASSERT json_data["platform"] == "android" +ASSERT json_data["formFactor"] == "phone" +ASSERT json_data["metadata"] == {"environment": "test"} +ASSERT json_data["push"]["recipient"] == { + "transportType": "fcm", + "registrationToken": "reg-token-1" +} +ASSERT json_data["push"]["state"] == "ACTIVE" +ASSERT json_data["push"]["errorReason"]["code"] == 40000 +``` + +--- + +## PCD4 — all DeviceFormFactor values are accepted + +**Test ID**: `rest/unit/PCD4/form-factor-values-0` + +**Spec requirement:** PCD4 — `formFactor` is *"the device formfactor, one of `phone`, `tablet`, `desktop`, `tv`, `watch`, `car`, `embedded`, `other`"*. + +### Test Steps +```pseudo +form_factors = ["phone", "tablet", "desktop", "tv", "watch", "car", "embedded", "other"] + +FOR EACH form_factor IN form_factors: + device = DeviceDetails.fromJson({ + "id": "device-001", + "platform": "android", + "formFactor": form_factor + }) + + # DeviceFormFactor(x) denotes the enum member whose wire value is x + ASSERT device.formFactor == DeviceFormFactor(form_factor) + ASSERT device.toJson()["formFactor"] == form_factor +``` + +--- + +## PCD6 — all DevicePlatform values are accepted + +**Test ID**: `rest/unit/PCD6/platform-values-0` + +**Spec requirement:** PCD6 — `platform` is *"the device platform, one of `android`, `ios`, `browser`"*. + +### Test Steps +```pseudo +platforms = ["android", "ios", "browser"] + +FOR EACH platform IN platforms: + device = DeviceDetails.fromJson({ + "id": "device-001", + "platform": platform, + "formFactor": "phone" + }) + + # DevicePlatform(x) denotes the enum member whose wire value is x + ASSERT device.platform == DevicePlatform(platform) + ASSERT device.toJson()["platform"] == platform +``` + +--- + +## PCP2, PCP3, PCP4 — DevicePushDetails state values, errorReason, and recipient parse from wire JSON + +**Test ID**: `rest/unit/PCP4/device-push-state-values-0` + +| Spec | Requirement | +|------|-------------| +| PCP4 | `state` — the state of the push registration, one of `Active`, `Failing`, `Failed` | +| PCP2 | `errorReason` `ErrorInfo` — (optional) any error information associated with the registration | +| PCP3 | `recipient` — a map of string key/value pairs containing details of the push transport and address | + +Tests each `DevicePushDetails.state` value parsed from its wire form (uppercase, per ably-js's type declarations — see Notes), that `errorReason` parses as an `ErrorInfo`, and that `recipient` is preserved as an opaque string map. + +### Test Cases + +| Wire state | Enum member | +|------------|-------------| +| `"ACTIVE"` | `DevicePushState.Active` | +| `"FAILING"` | `DevicePushState.Failing` | +| `"FAILED"` | `DevicePushState.Failed` | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + device = DeviceDetails.fromJson({ + "id": "device-001", + "platform": "ios", + "formFactor": "phone", + "push": { + "recipient": {"transportType": "apns", "deviceToken": "apns-token-1"}, + "state": test_case.wire_state, + "errorReason": {"code": 71103, "statusCode": 500, "message": "upstream failure"} + } + }) + + # PCP4 — state parses to the corresponding enum member + ASSERT device.push.state == test_case.enum_member + + # PCP2 — errorReason parses as ErrorInfo + ASSERT device.push.errorReason IS ErrorInfo + ASSERT device.push.errorReason.code == 71103 + + # PCP3 — recipient is an opaque string map, preserved as-received + ASSERT device.push.recipient == { + "transportType": "apns", + "deviceToken": "apns-token-1" + } +``` + +--- + +## PCS5 — forDevice sets channel and deviceId, leaving clientId null + +**Test ID**: `rest/unit/PCS5/push-channel-subscription-for-device-0` + +| Spec | Requirement | +|------|-------------| +| PCS2 | `deviceId` string — (optional, populated for subscriptions made for a specific device registration) | +| PCS4 | `channel` string — the channel name associated with this subscription | +| PCS5 | Precisely one of `deviceId` or `clientId` must be non-null — e.g. *"one constructor that takes a device ID"* (the IDL's `+forDevice(channel, deviceId)`) | + +Tests that the device-targeted factory populates exactly `channel` and `deviceId`, and that serialization contains exactly those fields. + +### Test Steps +```pseudo +subscription = PushChannelSubscription.forDevice("push-test-channel", "device-001") + +ASSERT subscription.channel == "push-test-channel" # PCS4 +ASSERT subscription.deviceId == "device-001" # PCS2 +ASSERT subscription.clientId IS null # PCS5 — the other identifier stays null + +json_data = subscription.toJson() +ASSERT json_data["channel"] == "push-test-channel" +ASSERT json_data["deviceId"] == "device-001" +ASSERT "clientId" NOT IN json_data OR json_data["clientId"] IS null +``` + +--- + +## PCS5 — forClientId sets channel and clientId, leaving deviceId null + +**Test ID**: `rest/unit/PCS5/push-channel-subscription-for-client-1` + +| Spec | Requirement | +|------|-------------| +| PCS3 | `clientId` string — (optional, populated for subscriptions made for a specific `clientId`) | +| PCS4 | `channel` string — the channel name associated with this subscription | +| PCS5 | Precisely one of `deviceId` or `clientId` must be non-null — e.g. *"one … that takes a client ID"* (the IDL's `+forClientId(channel, clientId)`) | + +Tests that the client-targeted factory populates exactly `channel` and `clientId`, and that serialization contains exactly those fields. + +### Test Steps +```pseudo +subscription = PushChannelSubscription.forClientId("push-test-channel", "client-abc") + +ASSERT subscription.channel == "push-test-channel" # PCS4 +ASSERT subscription.clientId == "client-abc" # PCS3 +ASSERT subscription.deviceId IS null # PCS5 — the other identifier stays null + +json_data = subscription.toJson() +ASSERT json_data["channel"] == "push-test-channel" +ASSERT json_data["clientId"] == "client-abc" +ASSERT "deviceId" NOT IN json_data OR json_data["deviceId"] IS null +``` + +--- + +## PCS5 — precisely one of deviceId or clientId is non-null + +**Test ID**: `rest/unit/PCS5/exactly-one-of-device-client-2` + +**Spec requirement:** PCS5 — *"precisely one of `deviceId` or `clientId` must be non-null -- this should be enforced by a mechanism appropriate to the language, for example one constructor that takes a device ID and one that takes a client ID."* + +The enforcement mechanism is deliberately language-specific, so this test asserts two portable facets: + +1. **Construction:** the factory-based API offers no way to construct a subscription carrying both identifiers — each factory takes exactly one, and leaves the other null (asserted below; in statically typed languages this is additionally a compile-time property). +2. **Wire parsing:** a (server-invalid) wire object carrying *both* identifiers is handled deterministically — the SDK either rejects it with a parse error, or exposes the object as-received. The derived test asserts whichever branch its SDK takes and records the choice as a deviation note. ably-js takes the as-received branch (`fromValues` copies fields without validation). + +### Test Steps +```pseudo +# 1. Construction — each factory populates exactly one identifier (PCS5) +ASSERT PushChannelSubscription.forDevice("ch", "device-001").clientId IS null +ASSERT PushChannelSubscription.forClientId("ch", "client-abc").deviceId IS null + +# 2. Wire parsing — both identifiers present: reject, or expose as-received +wire = {"channel": "ch", "deviceId": "device-001", "clientId": "client-abc"} + +EITHER: + PushChannelSubscription.fromJson(wire) FAILS WITH error # (a) rejected on parse +OR: + subscription = PushChannelSubscription.fromJson(wire) # (b) exposed as-received + ASSERT subscription.channel == "ch" + ASSERT subscription.deviceId == "device-001" + ASSERT subscription.clientId == "client-abc" +```