diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index 09521f28d..4dbb1daa6 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -245,11 +245,13 @@ class PostHogApi { distinctId: String, deviceToken: String, appId: String, + identityToken: String?, completion: @escaping (PostHogUploadInfo) -> Void ) { sendPushSubscription( httpMethod: "POST", endpointName: "push subscription", - distinctId: distinctId, deviceToken: deviceToken, appId: appId, completion: completion + distinctId: distinctId, deviceToken: deviceToken, appId: appId, + identityToken: identityToken, completion: completion ) } @@ -260,11 +262,13 @@ class PostHogApi { distinctId: String, deviceToken: String, appId: String, + identityToken: String?, completion: @escaping (PostHogUploadInfo) -> Void ) { sendPushSubscription( httpMethod: "DELETE", endpointName: "push unsubscription", - distinctId: distinctId, deviceToken: deviceToken, appId: appId, completion: completion + distinctId: distinctId, deviceToken: deviceToken, appId: appId, + identityToken: identityToken, completion: completion ) } @@ -274,6 +278,7 @@ class PostHogApi { distinctId: String, deviceToken: String, appId: String, + identityToken: String?, completion: @escaping (PostHogUploadInfo) -> Void ) { guard let url = getEndpointURL("/api/push_subscriptions/", relativeTo: config.host) else { @@ -281,13 +286,18 @@ class PostHogApi { return completion(PostHogUploadInfo(statusCode: nil, error: nil)) } - let toSend: [String: Any] = [ + // The token key is omitted entirely when absent — the backend treats a missing key as + // "unsigned"; an explicit null would fail its string check. + var toSend: [String: Any] = [ "api_key": config.projectToken, "distinct_id": distinctId, "device_token": deviceToken, "platform": "ios", "app_id": appId, ] + if let identityToken { + toSend["identity_token"] = identityToken + } guard let data = try? JSONSerialization.data(withJSONObject: toSend) else { hedgeLog("Error parsing the \(endpointName) body") diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index c82c771f0..1e44de4cf 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -185,6 +185,27 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? @objc public var capturePushNotificationOpened: Bool = true #endif + /// Supplies a signed identity token to attach (as `identity_token`) to push subscription + /// register/unregister requests, for projects that enable identity verification on their push + /// integration. Invoked with the `distinct_id` and `app_id` the request carries; call `completion` + /// **exactly once**, from any thread, with a token minted by your backend — or `nil` to send the + /// request without one. The SDK never mints tokens itself: your backend signs an HS256 JWT with + /// the project's **secret** API key, claims `sub` = distinct id, `app_id`, + /// `aud` = "posthog:push_identity", and an `exp`. + /// + /// The token is cached in memory per `(distinctId, appId)` and re-requested on identity change, on + /// the next launch, and once after a 401 rejection. If `completion` isn't called within ~10s the + /// SDK stops waiting and sends the request without an identity token; where your project requires + /// identity verification the backend will likely reject it, so make sure your provider always + /// completes. + /// + /// The hook is invoked on a background queue that serializes push-registration work — never on the + /// calling thread — and `completion` may be called from any thread. Still return promptly: blocking + /// here stalls the SDK's push retry/offline-resume flow (though not your app's main thread). + /// + /// Default: `nil` (requests carry no identity token). + @objc public var pushIdentityProvider: ((_ distinctId: String, _ appId: String, _ completion: @escaping (String?) -> Void) -> Void)? + #if os(iOS) || targetEnvironment(macCatalyst) /// Enables UIKit element interaction autocapture on iOS and Mac Catalyst. /// diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 622f633cb..279fe53c4 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -2398,6 +2398,8 @@ let maxRetryDelay = 30.0 storage?.setBool(forKey: .optOut, contents: true) } + pushSubscriptionHandler?.onOptOut() + setupLock.withLock { uninstallIntegrations() } @@ -2979,9 +2981,10 @@ let maxRetryDelay = 30.0 /// Unregisters this device's push token from PostHog so Workflows stop targeting it — for example /// from your logout flow. /// - /// Sends a best-effort `DELETE /api/push_subscriptions/` for the current distinct id (the backend - /// unsets the subscription property) and forgets the locally stored token. Unlike registration this - /// is not retried. Call it directly if you manage push subscriptions yourself. On `reset()` the SDK + /// Sends a `DELETE /api/push_subscriptions/` for the current distinct id (the backend unsets the + /// subscription property) and forgets the locally stored token. The delete intent is durable: an + /// offline or failed attempt is retried on `flush()`/next launch until it succeeds or hits a + /// terminal 4xx. Call it directly if you manage push subscriptions yourself. On `reset()` the SDK /// already moves any registered token to the new anonymous identity (unregister then re-register), /// independently of `capturePushNotificationSubscriptions` — that flag only gates automatic token /// subscription at startup. diff --git a/PostHog/PostHogStorage.swift b/PostHog/PostHogStorage.swift index 3fa4618be..219c88961 100644 --- a/PostHog/PostHogStorage.swift +++ b/PostHog/PostHogStorage.swift @@ -255,6 +255,7 @@ class PostHogStorage { case capturePerformance = "posthog.capturePerformance" case deviceId = "posthog.deviceId" case pushSubscription = "posthog.pushSubscription" + case pushPendingUnregister = "posthog.pushPendingUnregister" } // The location for storing data that we always want to keep diff --git a/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift b/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift index 00d6b678d..a0ea96026 100644 --- a/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift +++ b/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift @@ -12,6 +12,7 @@ final class PostHogPushSubscriptionHandler { static let deviceToken = "deviceToken" static let appId = "appId" static let deliveredForDistinctId = "deliveredForDistinctId" + static let distinctId = "distinctId" } private struct PendingRecord { @@ -20,9 +21,35 @@ final class PostHogPushSubscriptionHandler { let deliveredForDistinctId: String? } + /// A durable "delete this subscription" intent for the identity it names, persisted so an offline or + /// failed unregister (logout/reset) is retried on `flush()`/next launch instead of silently leaving + /// the device associated with the logged-out user. Separate key from `PendingRecord` so it coexists + /// with a fresh registration the reset path writes under the new anonymous id. + /// + /// Single-slot, last-write-wins: only one identity's unregister can be pending at a time. Two + /// overlapping logouts (rapid double-reset) drop the older intent — acceptable given one device token + /// and how rare that is; a per-identity queue would be the fix if it ever matters. + private struct PendingUnregister: Equatable { + let distinctId: String + let deviceToken: String + let appId: String + } + + private struct CachedIdentityToken { + let token: String + let distinctId: String + let appId: String + } + private static let firstRetryDelay: TimeInterval = 5 private static let maxRetryDelay: TimeInterval = 30 + /// Watchdog window for `pushIdentityProvider`: if the host never calls completion within this, fall + /// back to a token-less send so a misbehaving provider can't wedge sending for the whole process. + /// A slow legitimate mint on a bad network is cut off and retried token-less (the 401 refresh + /// re-mints). Test seam: shrunk in tests so the fallback fires quickly. Keep in parity with Android. + var identityTokenMintTimeout: TimeInterval = 10 + private let api: PostHogApi private let storage: PostHogStorage private let config: PostHogConfig @@ -32,7 +59,8 @@ final class PostHogPushSubscriptionHandler { /// SDK is disabled or opted out. The record is kept so an opt-in can resume later. private let isAllowedProvider: () -> Bool - /// Guards `retryCount`, `pausedUntil`, `isSending`, `pendingResend`, and `halted`. + /// Guards `retryCount`, `pausedUntil`, `isSending`, `pendingResend`, `halted`, + /// `cachedIdentityToken`, and `didAuthRetry`. private let stateLock = NSLock() private var retryCount = 0 private var pausedUntil: Date? @@ -43,12 +71,24 @@ final class PostHogPushSubscriptionHandler { /// Set after a non-retryable failure or once retries are exhausted: no more attempts this session, /// but the record is kept for one retry on the next launch. private var halted = false + /// Last token minted by `config.pushIdentityProvider` for a `(distinctId, appId)` pair, reused by + /// retries and reset() to avoid extra provider roundtrips; cleared on a 401 re-mint and opt-out. In memory + /// only — unlike the persisted record, a short-lived credential must not outlive the process. + private var cachedIdentityToken: CachedIdentityToken? + /// One fresh-token retry per send-cycle after a 401; reset with the retry state. + private var didAuthRetry = false /// Serializes storage-record read-modify-writes (persist, deliver-stamp, unregister-clear, reset /// re-register) so a concurrent `send()`, `reset()`, or send completion can't interleave and lose a /// token. `PostHogStorage` does no locking of its own. private let recordLock = NSLock() + /// Runs `config.pushIdentityProvider` off the caller's thread (which is the host's main thread on + /// the first registration, via `didRegisterForRemoteNotificationsWithDeviceToken:`). A provider + /// that blocks can only stall this serial queue — and the mint watchdog still fires on a separate + /// queue — never the host's UI. Matches Android, where minting runs on the SDK executor. + private let mintQueue = DispatchQueue(label: "com.posthog.push.identity-mint") + private var contextChangedToken: RegistrationToken? init( @@ -115,6 +155,13 @@ final class PostHogPushSubscriptionHandler { /// Retries a persisted, not-yet-delivered subscription if the backoff window has elapsed. /// Called from `PostHogSDK.flush()`. func retryIfNeeded() { + // Drain any pending unregister first (offline logout, transport failure) — independent of the + // send record, which is usually absent after a logout. On this path the in-memory identity-token + // cache is empty, so the retry re-mints a fresh token and sidesteps the expired-token 401. + if let pending = loadPendingUnregister() { + attemptUnregister(pending) + } + guard let record = loadRecord() else { return } let distinctId = distinctIdProvider() @@ -133,9 +180,10 @@ final class PostHogPushSubscriptionHandler { attemptIfAllowed(deviceToken: record.deviceToken, appId: record.appId) } - /// Best-effort unregister: a single `DELETE /api/push_subscriptions` for `distinctId`. Unlike - /// `send`, there is no retry, backoff, or persistence — a failure is logged and dropped (the - /// backend also unsets a dead token on the next send, and the durable path is the re-register). + /// Unregister: `DELETE /api/push_subscriptions` for `distinctId`. The intent is persisted first, so an + /// offline or failed attempt is retried on `flush()`/next launch (see `PendingUnregister`) rather than + /// dropped — otherwise a logout while offline, or with an expired identity token, would leave the + /// device associated with the logged-out user on the backend. Cleared on a 2xx or a terminal 4xx. func unregister(distinctId: String, deviceToken: String, appId: String) { guard isAllowedProvider() else { hedgeLog("Push unregister skipped: SDK is disabled or opted out.") @@ -145,15 +193,59 @@ final class PostHogPushSubscriptionHandler { hedgeLog("Push unregister skipped: missing distinct id, token, or app id.") return } - api.deletePushSubscription(distinctId: distinctId, deviceToken: deviceToken, appId: appId) { info in - if let statusCode = info.statusCode, 200 ... 299 ~= statusCode { - hedgeLog("Push subscription unregistered successfully.") - } else { - hedgeLog("Push unregister failed (status \(info.statusCode.map(String.init) ?? "none")); ignoring (best-effort).") + let pending = PendingUnregister(distinctId: distinctId, deviceToken: deviceToken, appId: appId) + writePendingUnregister(pending) + attemptUnregister(pending) + } + + /// Attempts the persisted DELETE. Offline defers without clearing the intent (retried later). On a 401 + /// with a provider, re-mints a fresh identity token once and retries — the same-process logout path may + /// hold an expired cached token (the provider mints a short TTL). `isRetry` caps that to one re-mint. + private func attemptUnregister(_ pending: PendingUnregister, isRetry: Bool = false) { + guard isAllowedProvider() else { return } + guard isConnectedProvider() else { + hedgeLog("Push unregister deferred: no network connection. Will retry on flush/next launch.") + return + } + // Same one-verification-state as registration: the DELETE resolves a token for the distinct id it + // carries (the old identity on the reset() path). + resolveIdentityToken(distinctId: pending.distinctId, appId: pending.appId) { [weak self] identityToken in + guard let self, isAllowedProvider() else { return } + api.deletePushSubscription( + distinctId: pending.distinctId, deviceToken: pending.deviceToken, appId: pending.appId, + identityToken: identityToken + ) { [weak self] info in + self?.handleUnregisterResult(info, pending: pending, isRetry: isRetry) } } } + private func handleUnregisterResult(_ info: PostHogUploadInfo, pending: PendingUnregister, isRetry: Bool) { + if let statusCode = info.statusCode, 200 ... 299 ~= statusCode { + clearPendingUnregister(matching: pending) + hedgeLog("Push subscription unregistered successfully.") + return + } + + // 401: identity verification failed. Re-mint once and retry, mirroring the send-path 401 refresh. + if info.statusCode == 401, config.pushIdentityProvider != nil, !isRetry { + stateLock.withLock { cachedIdentityToken = nil } + hedgeLog("Push unregister rejected (401). Retrying once with a fresh identity token.") + attemptUnregister(pending, isRetry: true) + return + } + + // Retryable (transport error, 429, 5xx): keep the intent for flush/next launch. + if isRetryable(info) { + hedgeLog("Push unregister failed (status \(statusString(info))); will retry on flush/next launch.") + return + } + + // Terminal (post-retry 401, 404 already gone, other 4xx): drop the intent, best-effort ceiling. + clearPendingUnregister(matching: pending) + hedgeLog("Push unregister failed (status \(statusString(info))); dropping (best-effort).") + } + /// Snapshot of the stored token/appId, read *before* `reset()` clears storage so the old /// identity's subscription can be DELETEd and then re-registered under the new anonymous id. func recordForReset() -> (deviceToken: String, appId: String)? { @@ -196,6 +288,15 @@ final class PostHogPushSubscriptionHandler { unregister(distinctId: distinctIdProvider(), deviceToken: record.deviceToken, appId: record.appId) } + /// Opt-out drops the cached identity credential so a later opt-in re-mints it, and clears the + /// per-cycle 401 retry flag so a consumed retry doesn't stay stuck and suppress the next one. + func onOptOut() { + stateLock.withLock { + cachedIdentityToken = nil + didAuthRetry = false + } + } + // MARK: - Private private func resendIfDistinctIdChanged(currentDistinctId: String) { @@ -216,6 +317,7 @@ final class PostHogPushSubscriptionHandler { retryCount = 0 pausedUntil = nil halted = false + didAuthRetry = false } } @@ -254,8 +356,93 @@ final class PostHogPushSubscriptionHandler { } guard shouldSend else { return } - api.pushSubscription(distinctId: distinctId, deviceToken: deviceToken, appId: appId) { [weak self] info in - self?.handleResult(info, deviceToken: deviceToken, appId: appId, distinctId: distinctId) + // `isSending` stays claimed across the mint, so a registration arriving mid-mint folds into + // `pendingResend` exactly like one arriving mid-request. + resolveIdentityToken(distinctId: distinctId, appId: appId) { [weak self] identityToken in + guard let self else { return } + // Opt-out may land during a slow mint; re-check before sending. + guard isAllowedProvider() else { + // Also drop any registration that folded into `pendingResend` during the mint. We're + // opted out, so it must not fire, and leaving it set would make the next send's + // `handleResult` service a stale resend. A later opt-in re-registers normally. + stateLock.withLock { + self.isSending = false + self.pendingResend = false + } + return + } + api.pushSubscription( + distinctId: distinctId, deviceToken: deviceToken, appId: appId, identityToken: identityToken + ) { [weak self] info in + self?.handleResult(info, deviceToken: deviceToken, appId: appId, distinctId: distinctId) + } + } + } + + /// Resolves the identity token for `distinctId`/`appId`, preferring an exact cache hit. The + /// completion may run synchronously or from whatever thread the provider completes on; extra + /// provider completions are ignored (an uncalled one falls back to a token-less send after the + /// watchdog timeout — see the config doc). + private func resolveIdentityToken(distinctId: String, appId: String, completion: @escaping (String?) -> Void) { + guard let provider = config.pushIdentityProvider else { + hedgeLog("No identity token attached to push request (no pushIdentityProvider).") + return completion(nil) + } + + let cached = stateLock.withLock { () -> String? in + guard let cache = cachedIdentityToken, cache.distinctId == distinctId, cache.appId == appId else { + return nil + } + return cache.token + } + if let cached { + hedgeLog("Attaching cached identity token to push request.") + return completion(cached) + } + + var completed = false + // A provider that never calls its completion would hold isSending for the whole process and + // wedge every later send. Bound the wait: if the mint doesn't land in time, fall back to a + // token-less send. A late real completion is a no-op via `completed`. + let timeout = identityTokenMintTimeout + DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { [weak self] in + guard let self else { return } + let isFirst = self.stateLock.withLock { () -> Bool in + if completed { + return false + } + completed = true + return true + } + guard isFirst else { return } + hedgeLog("pushIdentityProvider did not complete within \(timeout)s; sending without identity token.") + completion(nil) + } + // Hop off the caller's (often main) thread: a blocking provider stalls only `mintQueue`, and + // the watchdog above still fires from `DispatchQueue.global()` to unwedge the send. + mintQueue.async { + provider(distinctId, appId) { [weak self] token in + guard let self else { return } + let isFirst = self.stateLock.withLock { () -> Bool in + if completed { + return false + } + completed = true + // Sample opt-out under the same lock onOptOut() clears the cache with, so an opt-out + // can't land between the check and the write and leave a stale token cached. If opt-out + // wins, isAllowedProvider() is false here and nothing is cached; if we win, onOptOut() + // clears our write right after we release the lock. + if let token, self.isAllowedProvider() { + self.cachedIdentityToken = CachedIdentityToken(token: token, distinctId: distinctId, appId: appId) + } + return true + } + guard isFirst else { return } + hedgeLog(token != nil + ? "Attaching freshly minted identity token to push request." + : "No identity token attached to push request (provider completed nil).") + completion(token) + } } } @@ -272,31 +459,59 @@ final class PostHogPushSubscriptionHandler { resetRetryState() hedgeLog("Push subscription sent successfully.") } else { - handleFailure(info) + handleFailure(info, deviceToken: deviceToken, appId: appId) } // A newer registration or identity change arrived while this send was in flight. Service it with - // fresh retry state so the latest token isn't stranded behind this send's backoff or halt. - if hadPendingResend, let record = loadRecord() { - resetRetryState() - attemptIfAllowed(deviceToken: record.deviceToken, appId: record.appId) + // fresh retry state so the latest token isn't stranded behind this send's backoff or halt — + // unless a retry (e.g. the 401 fresh-token retry `handleFailure` just started) is already in + // flight for this cycle: reset would clear that retry's per-cycle `didAuthRetry` cap out from + // under it, so fold into it instead and let its completion service the latest record here. + if hadPendingResend { + let deferredToInFlight = stateLock.withLock { () -> Bool in + if isSending { + pendingResend = true + return true + } + return false + } + if !deferredToInFlight, let record = loadRecord() { + resetRetryState() + attemptIfAllowed(deviceToken: record.deviceToken, appId: record.appId) + } } } /// Applies backoff/halt state for a non-2xx result. Split from `handleResult` so a coalesced resend /// is serviced afterward regardless of which failure branch this took. - private func handleFailure(_ info: PostHogUploadInfo) { - // Retryable: transport error (no status), 429, or 5xx. Everything else (400/401) is terminal. - let retryable: Bool - if let statusCode = info.statusCode { - retryable = statusCode == 429 || (500 ... 599 ~= statusCode) - } else { - retryable = true + private func handleFailure(_ info: PostHogUploadInfo, deviceToken: String, appId: String) { + // A 401 means identity verification failed. With a provider, allow one fresh-token retry per + // send-cycle; a second 401 falls through to the terminal branch below. + if info.statusCode == 401, config.pushIdentityProvider != nil { + let shouldRetryWithFreshToken = stateLock.withLock { () -> Bool in + if didAuthRetry { + return false + } + didAuthRetry = true + cachedIdentityToken = nil + return true + } + if shouldRetryWithFreshToken { + hedgeLog("Push subscription rejected (401). Retrying once with a fresh identity token.") + attemptIfAllowed(deviceToken: deviceToken, appId: appId) + return + } } - guard retryable else { + guard isRetryable(info) else { stateLock.withLock { halted = true } - hedgeLog("Push subscription rejected (status \(info.statusCode.map(String.init) ?? "none")). Keeping record for next launch.") + if info.statusCode == 401, config.pushIdentityProvider == nil { + hedgeLog( + "Push subscription rejected (401): identity verification may be required — set config.pushIdentityProvider. Keeping record for next launch." + ) + } else { + hedgeLog("Push subscription rejected (status \(statusString(info))). Keeping record for next launch.") + } return } @@ -328,6 +543,13 @@ final class PostHogPushSubscriptionHandler { } writeRecord(deviceToken: deviceToken, appId: appId, deliveredForDistinctId: distinctId) + + // A fresh registration delivered to this identity supersedes any queued logout-DELETE for + // it (log out of A, then back into A): otherwise the next retryIfNeeded() drain would + // unregister the subscription we just re-registered. + clearPendingUnregisterLocked( + matching: PendingUnregister(distinctId: distinctId, deviceToken: deviceToken, appId: appId) + ) } } @@ -362,6 +584,52 @@ final class PostHogPushSubscriptionHandler { return PendingRecord(deviceToken: deviceToken, appId: appId, deliveredForDistinctId: data[Key.deliveredForDistinctId]) } + private func writePendingUnregister(_ pending: PendingUnregister) { + recordLock.withLock { + storage.setDictionary(forKey: .pushPendingUnregister, contents: [ + Key.distinctId: pending.distinctId, + Key.deviceToken: pending.deviceToken, + Key.appId: pending.appId, + ]) + } + } + + private func loadPendingUnregister() -> PendingUnregister? { + recordLock.withLock { loadPendingUnregisterLocked() } + } + + private func loadPendingUnregisterLocked() -> PendingUnregister? { + guard let data = storage.getDictionary(forKey: .pushPendingUnregister) as? [String: String], + let distinctId = data[Key.distinctId], + let deviceToken = data[Key.deviceToken], + let appId = data[Key.appId] + else { + return nil + } + return PendingUnregister(distinctId: distinctId, deviceToken: deviceToken, appId: appId) + } + + /// Clears the intent only if it's still the one that just resolved — a newer unregister (a second + /// logout while this DELETE was in flight) may have overwritten the slot, and its intent must not be + /// dropped by this stale completion. + private func clearPendingUnregister(matching pending: PendingUnregister) { + recordLock.withLock { clearPendingUnregisterLocked(matching: pending) } + } + + private func clearPendingUnregisterLocked(matching pending: PendingUnregister) { + guard loadPendingUnregisterLocked() == pending else { return } + storage.remove(key: .pushPendingUnregister) + } + + /// Transport error (no status), 429, or 5xx is retryable; everything else (4xx) is terminal. + private func isRetryable(_ info: PostHogUploadInfo) -> Bool { + info.statusCode.map { $0 == 429 || (500 ... 599 ~= $0) } ?? true + } + + private func statusString(_ info: PostHogUploadInfo) -> String { + info.statusCode.map(String.init) ?? "none" + } + /// Exponential backoff: `min(5 * 2^(attempt-1), 30)` → 5, 10, 20, 30, 30, … func retryDelay(forAttempt attempt: Int) -> TimeInterval { min(Self.firstRetryDelay * pow(2, Double(attempt - 1)), Self.maxRetryDelay) @@ -382,5 +650,9 @@ final class PostHogPushSubscriptionHandler { func clearBackoffForTesting() { stateLock.withLock { pausedUntil = nil } } + + var hasPendingUnregisterForTesting: Bool { + loadPendingUnregister() != nil + } } #endif diff --git a/PostHogTests/PostHogApiTest.swift b/PostHogTests/PostHogApiTest.swift index 32eb9a3c6..891708b01 100644 --- a/PostHogTests/PostHogApiTest.swift +++ b/PostHogTests/PostHogApiTest.swift @@ -97,6 +97,7 @@ enum PostHogApiTests { distinctId: "test-user", deviceToken: "abc123", appId: "com.example.app", + identityToken: nil, completion: completion ) } @@ -114,6 +115,7 @@ enum PostHogApiTests { distinctId: "user-42", deviceToken: "deadbeef", appId: "com.example.app", + identityToken: nil, completion: completion ) } @@ -129,6 +131,38 @@ enum PostHogApiTests { #expect(body["app_id"] as? String == "com.example.app") } + /// Shared test vector 9 at the API layer: a provided identity token is serialized as + /// `identity_token` alongside the unchanged five fields, on POST and DELETE alike. + @Test("push subscription bodies carry identity_token when provided") + func pushSubscriptionBodyIdentityToken() async throws { + let sut = getSut(host: "http://localhost") + let _: PostHogUploadInfo = await getApiResponse { completion in + sut.pushSubscription( + distinctId: "user-42", + deviceToken: "deadbeef", + appId: "com.example.app", + identityToken: "jwt-abc", + completion: completion + ) + } + let _: PostHogUploadInfo = await getApiResponse { completion in + sut.deletePushSubscription( + distinctId: "user-42", + deviceToken: "deadbeef", + appId: "com.example.app", + identityToken: "jwt-abc", + completion: completion + ) + } + + #expect(server.pushSubscriptionRequests.count == 2) + for request in server.pushSubscriptionRequests { + let body = try #require(server.parseRequest(request)) + #expect(Set(body.keys) == ["api_key", "distinct_id", "device_token", "platform", "app_id", "identity_token"]) + #expect(body["identity_token"] as? String == "jwt-abc") + } + } + func getSut(host: String) -> PostHogApi { PostHogApi(PostHogConfig(projectToken: "test_project_token", host: host)) } @@ -287,7 +321,7 @@ enum PostHogApiTests { func pushSubscriptionDeclaresGzip() async throws { let sut = getSut(host: "http://localhost") let _: PostHogUploadInfo = await getApiResponse { completion in - sut.pushSubscription(distinctId: "x", deviceToken: "tok", appId: "app", completion: completion) + sut.pushSubscription(distinctId: "x", deviceToken: "tok", appId: "app", identityToken: nil, completion: completion) } let request = try #require(server.pushSubscriptionRequests.first) #expect(request.value(forHTTPHeaderField: "Content-Encoding") == "gzip") diff --git a/PostHogTests/PostHogConfigTest.swift b/PostHogTests/PostHogConfigTest.swift index 5d7b176e3..62727d56a 100644 --- a/PostHogTests/PostHogConfigTest.swift +++ b/PostHogTests/PostHogConfigTest.swift @@ -32,6 +32,7 @@ class PostHogConfigTest: QuickSpec { expect(config.capturePushNotificationSubscriptions) == true expect(config.capturePushNotificationOpened) == true #endif + expect(config.pushIdentityProvider).to(beNil()) } it("init takes project token") { diff --git a/PostHogTests/PostHogPushNotificationTest.swift b/PostHogTests/PostHogPushNotificationTest.swift index e468d8194..e28398bff 100644 --- a/PostHogTests/PostHogPushNotificationTest.swift +++ b/PostHogTests/PostHogPushNotificationTest.swift @@ -32,6 +32,23 @@ // MARK: - Helpers + /// Thread-safe recorder for `pushIdentityProvider` closures, which may run on any thread. + private final class MintRecorder { + private let lock = NSLock() + private var invocations = [(distinctId: String, appId: String)]() + + /// Records one invocation and returns its 1-based ordinal (usable as a unique token suffix). + func record(_ distinctId: String, _ appId: String) -> Int { + lock.withLock { + invocations.append((distinctId, appId)) + return invocations.count + } + } + + var count: Int { lock.withLock { invocations.count } } + var distinctIds: [String] { lock.withLock { invocations.map(\.distinctId) } } + } + private func waitFor(_ condition: @escaping () -> Bool, timeout: TimeInterval = 5) async -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { @@ -67,6 +84,9 @@ let storage = PostHogStorage(config) if resetStorage { storage.reset() + // reset() deliberately keeps .pushPendingUnregister (a logout DELETE must outlive reset); + // clear it here so a durable intent from a prior test can't leak into this handler. + storage.remove(key: .pushPendingUnregister) } let handler = PostHogPushSubscriptionHandler( api, @@ -84,13 +104,15 @@ optOut: Bool = false, enableSwizzling: Bool = true, capturePushNotificationOpened: Bool = false, - reuseAnonymousId: Bool = false + reuseAnonymousId: Bool = false, + pushIdentityProvider: ((String, String, @escaping (String?) -> Void) -> Void)? = nil ) -> PostHogSDK { let config = PostHogConfig(projectToken: testProjectToken, host: "http://localhost:9001") config.flushAt = 1 config.optOut = optOut config.reuseAnonymousId = reuseAnonymousId config.enableSwizzling = enableSwizzling + config.pushIdentityProvider = pushIdentityProvider config.captureApplicationLifecycleEvents = false config.captureScreenViews = false config.capturePushNotificationSubscriptions = false @@ -224,9 +246,10 @@ // MARK: - Unregister (decision 6) - @Test("unregister sends exactly one DELETE with the 5-field body and never retries (vector 7)") + @Test("unregister sends exactly one DELETE with the 5-field body per attempt (vector 7)") func unregisterFiresOneDeleteNoRetry() async throws { - // Even a 500 must not be retried — unregister is best-effort, single-shot. + // A 500 fires one DELETE per attempt (no immediate retry); the intent is kept and retried + // only on flush()/next launch, so no second DELETE appears within this call. server.pushSubscriptionStatusCode = 500 let (handler, _, _) = makeHandler(distinctIdProvider: { "user-1" }) @@ -318,6 +341,48 @@ try? await Task.sleep(nanoseconds: 250_000_000) #expect(!server.pushSubscriptionRequests.contains { $0.httpMethod == "DELETE" }) } + + @Test("reset(): DELETE reuses the old id's cached token, re-POST mints the anon id's (vector 11)") + func resetMintsPerLegIdentityTokens() async throws { + let recorder = MintRecorder() + let sut = getSDK(pushIdentityProvider: { distinctId, appId, completion in + _ = recorder.record(distinctId, appId) + completion("jwt-\(distinctId)") + }) + defer { sut.close() } + + sut.identify("user-A") + sut.registerPushNotificationToken("tokA", appId: "com.example.app") + #expect(await waitFor { + (sut.storage?.getDictionary(forKey: .pushSubscription) as? [String: String])?["deliveredForDistinctId"] == "user-A" + }) + server.pushSubscriptionRequests = [] + + sut.reset() + + #expect(await waitFor { + self.server.pushSubscriptionRequests.contains { $0.httpMethod == "DELETE" } + && self.server.pushSubscriptionRequests.contains { $0.httpMethod == "POST" } + }) + + // The DELETE leg reuses the token already cached for the delivered id (no re-mint); the + // re-POST leg mints a fresh token for the new anon id. The exact invocation count is not + // asserted: reset()'s context-change resend races the explicit DELETE/re-POST legs, so the + // cache hit for the DELETE leg is timing-dependent (unlike Android, where the single + // executor serializes both legs). + let del = try #require(server.pushSubscriptionRequests.first { $0.httpMethod == "DELETE" }) + let delBody = try #require(server.parseRequest(del)) + #expect(delBody["distinct_id"] as? String == "user-A") + #expect(delBody["identity_token"] as? String == "jwt-user-A") + + let post = try #require(server.pushSubscriptionRequests.first { $0.httpMethod == "POST" }) + let postBody = try #require(server.parseRequest(post)) + let anonId = try #require(postBody["distinct_id"] as? String) + #expect(anonId != "user-A") + #expect(postBody["identity_token"] as? String == "jwt-\(anonId)") + #expect(recorder.distinctIds.starts(with: ["user-A"])) + #expect(recorder.distinctIds.contains(anonId)) + } #endif @Test("reset() sends no push requests when no token was ever registered") @@ -479,6 +544,335 @@ #expect(!delivered(storage)) } + // MARK: - Identity verification (vectors 9–14) + + @Test("provider token lands as identity_token on register POST and unregister DELETE (vector 9)") + func identityTokenOnRegisterAndUnregister() async throws { + let (handler, storage, config) = makeHandler(distinctIdProvider: { "user-1" }) + // Complete from a foreign thread — the provider contract allows any thread. + config.pushIdentityProvider = { _, _, completion in + DispatchQueue.global().async { completion("jwt-abc") } + } + + handler.send(deviceToken: "tok", appId: "com.example.app") + #expect(await waitFor { self.delivered(storage) }) + + let post = try #require(server.pushSubscriptionRequests.first { $0.httpMethod == "POST" }) + let postBody = try #require(server.parseRequest(post)) + #expect(postBody["identity_token"] as? String == "jwt-abc") + #expect(postBody["api_key"] as? String == testProjectToken) + #expect(postBody["distinct_id"] as? String == "user-1") + #expect(postBody["device_token"] as? String == "tok") + #expect(postBody["platform"] as? String == "ios") + #expect(postBody["app_id"] as? String == "com.example.app") + + handler.unregisterCurrentToken() + + #expect(await waitFor { self.server.pushSubscriptionRequests.contains { $0.httpMethod == "DELETE" } }) + let del = try #require(server.pushSubscriptionRequests.first { $0.httpMethod == "DELETE" }) + #expect(try #require(server.parseRequest(del))["identity_token"] as? String == "jwt-abc") + } + + @Test("no provider: bodies contain no identity_token key (vector 10)") + func noProviderOmitsIdentityToken() async throws { + let (handler, storage, _) = makeHandler(distinctIdProvider: { "user-1" }) + + handler.send(deviceToken: "tok", appId: "com.example.app") + #expect(await waitFor { self.delivered(storage) }) + handler.unregisterCurrentToken() + #expect(await waitFor { self.server.pushSubscriptionRequests.count == 2 }) + + for request in server.pushSubscriptionRequests { + let body = try #require(server.parseRequest(request)) + #expect(!body.keys.contains("identity_token")) + } + } + + @Test("provider completing nil: body contains no identity_token key (vector 10)") + func nilCompletionOmitsIdentityToken() async throws { + let (handler, storage, config) = makeHandler() + config.pushIdentityProvider = { _, _, completion in completion(nil) } + + handler.send(deviceToken: "tok", appId: "app") + + #expect(await waitFor { self.delivered(storage) }) + let body = try #require(server.parseRequest(server.pushSubscriptionRequests[0])) + #expect(!body.keys.contains("identity_token")) + } + + @Test("a provider that never completes falls back to a token-less send instead of wedging") + func neverCompletingProviderFallsBackTokenLess() async throws { + let (handler, storage, config) = makeHandler() + handler.identityTokenMintTimeout = 0.05 + config.pushIdentityProvider = { _, _, _ in } // never calls completion + + handler.send(deviceToken: "tok-1", appId: "app") + #expect(await waitFor { self.delivered(storage) }) + let firstPost = try #require(server.pushSubscriptionRequests.first { $0.httpMethod == "POST" }) + #expect(!(try #require(server.parseRequest(firstPost))).keys.contains("identity_token")) + + // isSending was released by the fallback, so a later registration is not wedged. + handler.send(deviceToken: "tok-2", appId: "app") + #expect(await waitFor { self.server.pushSubscriptionRequests.filter { $0.httpMethod == "POST" }.count == 2 }) + let secondPost = server.pushSubscriptionRequests.filter { $0.httpMethod == "POST" }[1] + #expect(try #require(server.parseRequest(secondPost))["device_token"] as? String == "tok-2") + } + + @Test("only the first provider completion is honored") + func onlyFirstProviderCompletionHonored() async throws { + let (handler, storage, config) = makeHandler() + config.pushIdentityProvider = { _, _, completion in + completion("jwt-first") + completion("jwt-second") + } + + handler.send(deviceToken: "tok", appId: "app") + + #expect(await waitFor { self.delivered(storage) }) + #expect(server.pushSubscriptionRequests.count == 1) + let body = try #require(server.parseRequest(server.pushSubscriptionRequests[0])) + #expect(body["identity_token"] as? String == "jwt-first") + } + + @Test("a mint completing after opt-out is not cached; opt-in re-mints") + func lateMintAfterOptOutNotCached() async throws { + // The provider is invoked off the caller's thread, so guard the shared state with a lock + // and wait for each mint to land before driving its completion. + let lock = NSLock() + var allowed = true + var pendingCompletion: ((String?) -> Void)? + var mints = 0 + let (handler, storage, config) = makeHandler(isAllowedProvider: { lock.withLock { allowed } }) + config.pushIdentityProvider = { _, _, completion in + lock.withLock { mints += 1 + pendingCompletion = completion + } + } + + handler.send(deviceToken: "tok", appId: "app") + #expect(await waitFor { lock.withLock { mints } == 1 }) + lock.withLock { allowed = false } + handler.onOptOut() + lock.withLock { pendingCompletion }?("jwt-stale") + + lock.withLock { allowed = true } + handler.send(deviceToken: "tok", appId: "app") + #expect(await waitFor { lock.withLock { mints } == 2 }) + lock.withLock { pendingCompletion }?("jwt-fresh") + + #expect(await waitFor { self.delivered(storage) }) + #expect(lock.withLock { mints } == 2) + let post = try #require(server.pushSubscriptionRequests.last) + #expect(try #require(server.parseRequest(post))["identity_token"] as? String == "jwt-fresh") + } + + @Test("500 then 200: provider minted once, both attempts carry the same token (vector 12)") + func retryReusesCachedIdentityToken() async throws { + server.pushSubscriptionStatusHandler = { requestNumber in requestNumber == 1 ? 500 : 200 } + let recorder = MintRecorder() + let (handler, storage, config) = makeHandler() + config.pushIdentityProvider = { distinctId, appId, completion in + completion("jwt-mint-\(recorder.record(distinctId, appId))") + } + + handler.send(deviceToken: "tok", appId: "app") + #expect(await waitFor { handler.retryCountForTesting == 1 }) + + handler.clearBackoffForTesting() + handler.retryIfNeeded() + + #expect(await waitFor { self.delivered(storage) }) + #expect(server.pushSubscriptionRequests.count == 2) + #expect(recorder.count == 1) + for request in server.pushSubscriptionRequests { + let body = try #require(server.parseRequest(request)) + #expect(body["identity_token"] as? String == "jwt-mint-1") + } + } + + @Test("401 then 200: one fresh-token retry with a re-minted token succeeds (vector 13)") + func authRetryWithFreshTokenSucceeds() async throws { + server.pushSubscriptionStatusHandler = { requestNumber in requestNumber == 1 ? 401 : 200 } + let recorder = MintRecorder() + let (handler, storage, config) = makeHandler() + config.pushIdentityProvider = { distinctId, appId, completion in + completion("jwt-mint-\(recorder.record(distinctId, appId))") + } + + handler.send(deviceToken: "tok", appId: "app") + + #expect(await waitFor { self.delivered(storage) }) + #expect(server.pushSubscriptionRequests.count == 2) + #expect(recorder.count == 2) + let first = try #require(server.parseRequest(server.pushSubscriptionRequests[0])) + #expect(first["identity_token"] as? String == "jwt-mint-1") + let second = try #require(server.parseRequest(server.pushSubscriptionRequests[1])) + #expect(second["identity_token"] as? String == "jwt-mint-2") + } + + @Test("401 twice: terminal after exactly two requests and two mints, record kept (vector 13)") + func secondAuthFailureIsTerminal() async throws { + server.pushSubscriptionStatusCode = 401 + let recorder = MintRecorder() + let (handler, storage, config) = makeHandler() + config.pushIdentityProvider = { distinctId, appId, completion in + completion("jwt-mint-\(recorder.record(distinctId, appId))") + } + + handler.send(deviceToken: "tok", appId: "app") + + #expect(await waitFor { handler.isHaltedForTesting }) + #expect(server.pushSubscriptionRequests.count == 2) + #expect(recorder.count == 2) + + // No further in-session attempts while halted. + handler.clearBackoffForTesting() + handler.retryIfNeeded() + try await Task.sleep(nanoseconds: 200_000_000) + #expect(server.pushSubscriptionRequests.count == 2) + + #expect(record(storage)?["deviceToken"] == "tok") + #expect(!delivered(storage)) + } + + @Test("a resend coalesced during a 401 auth-retry doesn't reopen the one-retry cap (no mint loop)") + func coalescedResendDoesNotBreakAuthRetryCap() async throws { + // Every request 401s. Hold the first mint open so a second registration coalesces into + // pendingResend while the original send is in flight; releasing it drives the 401 cascade. + // Under the bug, servicing the coalesced resend reset didAuthRetry out from under the + // in-flight auth-retry, granting an unbounded chain of fresh-token retries that never halts. + server.pushSubscriptionStatusCode = 401 + let recorder = MintRecorder() + let lock = NSLock() + var firstCompletion: ((String?) -> Void)? + let (handler, _, config) = makeHandler() + config.pushIdentityProvider = { distinctId, appId, completion in + let n = recorder.record(distinctId, appId) + if n == 1 { + lock.withLock { firstCompletion = completion } + } else { + DispatchQueue.global().async { completion("jwt-mint-\(n)") } + } + } + + // `isSending` is claimed synchronously in send #1, so send #2 coalesces into pendingResend + // before mint #1 (which runs off-thread) completes. Wait for that mint, then release it. + handler.send(deviceToken: "tok-1", appId: "app") + handler.send(deviceToken: "tok-2", appId: "app") + #expect(await waitFor { recorder.count >= 1 }) + lock.withLock { firstCompletion }?("jwt-mint-1") + + // Fix: R1(tok-1) + its one auth-retry, then the resend cycle R3(tok-2) + its one + // auth-retry — four requests, then terminal. The bug never halts and requests run away. + #expect(await waitFor { handler.isHaltedForTesting }) + try await Task.sleep(nanoseconds: 300_000_000) + #expect(server.pushSubscriptionRequests.count == 4) + #expect(recorder.count <= 4) + } + + @Test("401 with no provider: terminal after one request, record kept (vector 14)") + func unauthorizedWithoutProviderIsTerminal() async throws { + server.pushSubscriptionStatusCode = 401 + let (handler, storage, _) = makeHandler() + + handler.send(deviceToken: "tok", appId: "app") + + #expect(await waitFor { handler.isHaltedForTesting }) + #expect(server.pushSubscriptionRequests.count == 1) + #expect(record(storage)?["deviceToken"] == "tok") + #expect(!delivered(storage)) + } + + @Test("unregister DELETE gets one 401 fresh-token retry then succeeds (mirrors register vector 13)") + func unregisterAuthRetryWithFreshTokenSucceeds() async throws { + // A same-process logout can carry an expired cached token; a 401 must re-mint and retry once. + server.pushSubscriptionStatusHandler = { requestNumber in requestNumber == 1 ? 401 : 200 } + let recorder = MintRecorder() + let (handler, _, config) = makeHandler(distinctIdProvider: { "user-1" }) + config.pushIdentityProvider = { distinctId, appId, completion in + completion("jwt-mint-\(recorder.record(distinctId, appId))") + } + + handler.unregister(distinctId: "user-1", deviceToken: "tok", appId: "app") + + #expect(await waitFor { self.server.pushSubscriptionRequests.filter { $0.httpMethod == "DELETE" }.count == 2 }) + #expect(recorder.count == 2) // cache cleared on the 401, so the retry re-mints + let deletes = server.pushSubscriptionRequests.filter { $0.httpMethod == "DELETE" } + #expect(try #require(server.parseRequest(deletes[0]))["identity_token"] as? String == "jwt-mint-1") + #expect(try #require(server.parseRequest(deletes[1]))["identity_token"] as? String == "jwt-mint-2") + // Success on the retry clears the durable intent. + #expect(await waitFor { !handler.hasPendingUnregisterForTesting }) + } + + @Test("unregister twice-401 is terminal: exactly one fresh-token retry, then intent dropped") + func unregisterAuthRetryTerminalDropsIntent() async throws { + server.pushSubscriptionStatusCode = 401 + let recorder = MintRecorder() + let (handler, _, config) = makeHandler(distinctIdProvider: { "user-1" }) + config.pushIdentityProvider = { distinctId, appId, completion in + completion("jwt-mint-\(recorder.record(distinctId, appId))") + } + + handler.unregister(distinctId: "user-1", deviceToken: "tok", appId: "app") + + #expect(await waitFor { self.server.pushSubscriptionRequests.filter { $0.httpMethod == "DELETE" }.count == 2 }) + try await Task.sleep(nanoseconds: 300_000_000) + #expect(server.pushSubscriptionRequests.filter { $0.httpMethod == "DELETE" }.count == 2) + #expect(recorder.count == 2) + // A terminal 401 is a best-effort ceiling — the intent is dropped, not retried on flush. + #expect(!handler.hasPendingUnregisterForTesting) + } + + @Test("unregister offline persists the intent and drains it on retryIfNeeded") + func unregisterOfflinePersistsAndDrains() async throws { + var connected = false + let (handler, _, _) = makeHandler(distinctIdProvider: { "user-1" }, isConnectedProvider: { connected }) + + handler.unregister(distinctId: "user-1", deviceToken: "tok", appId: "app") + + try await Task.sleep(nanoseconds: 200_000_000) + #expect(server.pushSubscriptionRequests.isEmpty) // no attempt while offline + #expect(handler.hasPendingUnregisterForTesting) // but the delete intent is durable + + connected = true + handler.retryIfNeeded() + #expect(await waitFor { self.server.pushSubscriptionRequests.contains { $0.httpMethod == "DELETE" } }) + #expect(await waitFor { !handler.hasPendingUnregisterForTesting }) // cleared on 2xx + } + + @Test("unregister drops the pending intent on a terminal 4xx") + func unregisterTerminalDropsIntent() async throws { + server.pushSubscriptionStatusCode = 400 + let (handler, _, _) = makeHandler(distinctIdProvider: { "user-1" }) + + handler.unregister(distinctId: "user-1", deviceToken: "tok", appId: "app") + + #expect(await waitFor { self.server.pushSubscriptionRequests.contains { $0.httpMethod == "DELETE" } }) + #expect(await waitFor { !handler.hasPendingUnregisterForTesting }) + } + + @Test("re-registering the same identity cancels a queued unregister for it") + func reRegisterCancelsPendingUnregister() async throws { + var connected = false + let (handler, _, _) = makeHandler(distinctIdProvider: { "user-A" }, isConnectedProvider: { connected }) + + // Offline logout queues a durable unregister for user-A. + handler.unregister(distinctId: "user-A", deviceToken: "tok", appId: "app") + try await Task.sleep(nanoseconds: 150_000_000) + #expect(handler.hasPendingUnregisterForTesting) + + // Re-login as user-A: a delivered registration must supersede the stale logout-DELETE. + connected = true + handler.send(deviceToken: "tok", appId: "app") + #expect(await waitFor { !handler.hasPendingUnregisterForTesting }) + + // A later flush must not delete the freshly re-registered subscription. + let deletesBefore = server.pushSubscriptionRequests.filter { $0.httpMethod == "DELETE" }.count + handler.retryIfNeeded() + try await Task.sleep(nanoseconds: 200_000_000) + #expect(server.pushSubscriptionRequests.filter { $0.httpMethod == "DELETE" }.count == deletesBefore) + } + // MARK: - Offline @Test("defers while offline without burning a retry attempt") diff --git a/PostHogTests/TestUtils/MockPostHogServer.swift b/PostHogTests/TestUtils/MockPostHogServer.swift index 8d4db6008..ad9284513 100644 --- a/PostHogTests/TestUtils/MockPostHogServer.swift +++ b/PostHogTests/TestUtils/MockPostHogServer.swift @@ -24,12 +24,24 @@ class MockPostHogServer { var logsExpectationCount: Int? var flagsExpectationCount: Int? var flagsRequests = [URLRequest]() - var pushSubscriptionRequests = [URLRequest]() + /// Appended from OHHTTPStubs' network queue while tests read/reset it from their own thread. The + /// push suite drives heavy cross-thread resend churn (mint queue + watchdog + context-change + /// resend), so unlike the other bare request arrays this one races — a lock keeps the concurrent + /// append from corrupting the array (segfault). Access only via `pushSubscriptionRequests`. + private let pushSubscriptionRequestsLock = NSLock() + private var _pushSubscriptionRequests = [URLRequest]() + var pushSubscriptionRequests: [URLRequest] { + get { pushSubscriptionRequestsLock.withLock { _pushSubscriptionRequests } } + set { pushSubscriptionRequestsLock.withLock { _pushSubscriptionRequests = newValue } } + } /// Forces `/push_subscriptions` to reply 500 (independent of the global `return500`). var returnPushSubscription500 = false /// When set, `/push_subscriptions` replies with this exact status code (takes precedence over the /// 500 toggles). Used to exercise non-retryable responses like 400. var pushSubscriptionStatusCode: Int? + /// When set, picks the `/push_subscriptions` status per request (1-based request number); takes + /// precedence over all fixed toggles. Used for scripted sequences (e.g. 401 then 200). + var pushSubscriptionStatusHandler: ((Int) -> Int)? /// When set, `/push_subscriptions` responses carry this `Retry-After` header value. var pushSubscriptionRetryAfter: String? private var stubDescriptors = [HTTPStubsDescriptor]() @@ -362,10 +374,15 @@ class MockPostHogServer { }) stubDescriptors.append(stub(condition: pathEndsWith("/push_subscriptions")) { request in - self.pushSubscriptionRequests.append(request) + let requestCount = self.pushSubscriptionRequestsLock.withLock { () -> Int in + self._pushSubscriptionRequests.append(request) + return self._pushSubscriptionRequests.count + } let status: Int - if let code = self.pushSubscriptionStatusCode { + if let handler = self.pushSubscriptionStatusHandler { + status = handler(requestCount) + } else if let code = self.pushSubscriptionStatusCode { status = code } else if self.return500 || self.returnPushSubscription500 { status = 500 @@ -526,6 +543,7 @@ class MockPostHogServer { batchResponseHandler = nil returnPushSubscription500 = false pushSubscriptionStatusCode = nil + pushSubscriptionStatusHandler = nil pushSubscriptionRetryAfter = nil } diff --git a/api/posthog-ios.public-api.txt b/api/posthog-ios.public-api.txt index 553ec4281..2f1ae4036 100644 --- a/api/posthog-ios.public-api.txt +++ b/api/posthog-ios.public-api.txt @@ -83,6 +83,7 @@ PostHog | PostHogConfig.personProfiles | property | @objc var personProfiles: Po PostHog | PostHogConfig.preloadFeatureFlags | property | @objc var preloadFeatureFlags: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)preloadFeatureFlags PostHog | PostHogConfig.projectToken | property | @objc let projectToken: String | c:@M@PostHog@objc(cs)PostHogConfig(py)projectToken PostHog | PostHogConfig.propertiesSanitizer | property | @objc var propertiesSanitizer: (any PostHogPropertiesSanitizer)? { get set } | c:@M@PostHog@objc(cs)PostHogConfig(py)propertiesSanitizer +PostHog | PostHogConfig.pushIdentityProvider | property | @objc var pushIdentityProvider: ((String, String, @escaping (String?) -> Void) -> Void)? | c:@M@PostHog@objc(cs)PostHogConfig(py)pushIdentityProvider PostHog | PostHogConfig.rageClickConfig | property | @objc let rageClickConfig: PostHogRageClickConfig | c:@M@PostHog@objc(cs)PostHogConfig(py)rageClickConfig PostHog | PostHogConfig.remoteConfig | property | @objc var remoteConfig: Bool { get set } | c:@M@PostHog@objc(cs)PostHogConfig(py)remoteConfig PostHog | PostHogConfig.requestHeaders | property | @objc var requestHeaders: [String : String]? | c:@M@PostHog@objc(cs)PostHogConfig(py)requestHeaders