diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index be8173de6..200aef9f2 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -253,6 +253,7 @@ 3CEE93572B7C78FD008440BD /* OneSignalCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE7D17E627026B95002D3A5D /* OneSignalCore.framework */; }; 3CEE93582B7C78FE008440BD /* OneSignalCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = DE7D17E627026B95002D3A5D /* OneSignalCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3CF11E3D2C6D6155002856F5 /* UserExecutorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CF11E3C2C6D6155002856F5 /* UserExecutorTests.swift */; }; + 354E0C59BA9B18437C36215B /* UserExecutorRetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C310B6C569E96C204F8CD68 /* UserExecutorRetryTests.swift */; }; 3CF11E402C6E6DE2002856F5 /* MockNewRecordsState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CF11E3F2C6E6DE2002856F5 /* MockNewRecordsState.swift */; }; 3CF1A5632C669EA40056B3AA /* OSNewRecordsState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CF1A5622C669EA40056B3AA /* OSNewRecordsState.swift */; }; 3CF8629E28A183F900776CA4 /* OSIdentityModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CF8629D28A183F900776CA4 /* OSIdentityModel.swift */; }; @@ -363,6 +364,7 @@ 7AFE856C2368DDB80091D6A5 /* OSFocusCallParams.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFE856A2368DDB80091D6A5 /* OSFocusCallParams.m */; }; 7AFE856D2368DDB80091D6A5 /* OSFocusCallParams.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFE856A2368DDB80091D6A5 /* OSFocusCallParams.m */; }; 7EB69F3B404D0AEF46EC1536 /* UserJwtLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */; }; + E42087CB1AB15481E55D34FE /* UserStateReportingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA6355B75EDC9A3100B98F5 /* UserStateReportingTests.swift */; }; 9345FA2E8878EA1B0216A49A /* UserJwtAskTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3567F486AA4335CCA79D659B /* UserJwtAskTests.swift */; }; 8D2F4893453206700BB60F85 /* OSOperationRepoTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5221EEDBA5A74BD565490D52 /* OSOperationRepoTestSupport.swift */; }; 8E949FF4C7A7A2C7182E53EA /* OSUserJwtConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9376A4957E9090C748BCB18 /* OSUserJwtConfigTests.swift */; }; @@ -1524,6 +1526,7 @@ 3CEE90A62BFE6ABD00B0FB5B /* OSPropertiesSupportedProperty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSPropertiesSupportedProperty.swift; sourceTree = ""; }; 3CEE90A82C000BD500B0FB5B /* OneSignalRequest+UnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OneSignalRequest+UnitTests.swift"; sourceTree = ""; }; 3CF11E3C2C6D6155002856F5 /* UserExecutorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserExecutorTests.swift; sourceTree = ""; }; + 6C310B6C569E96C204F8CD68 /* UserExecutorRetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserExecutorRetryTests.swift; sourceTree = ""; }; 3CF11E3F2C6E6DE2002856F5 /* MockNewRecordsState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNewRecordsState.swift; sourceTree = ""; }; 3CF1A5622C669EA40056B3AA /* OSNewRecordsState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSNewRecordsState.swift; sourceTree = ""; }; 3CF8629D28A183F900776CA4 /* OSIdentityModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSIdentityModel.swift; sourceTree = ""; }; @@ -1600,6 +1603,7 @@ 5BC1DE632C90BB9000CA8807 /* OSIamFetchReadyCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSIamFetchReadyCondition.swift; sourceTree = ""; }; 5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSConsistencyManagerTests.swift; sourceTree = ""; }; 5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserJwtLifecycleTests.swift; sourceTree = ""; }; + FDA6355B75EDC9A3100B98F5 /* UserStateReportingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserStateReportingTests.swift; sourceTree = ""; }; 3567F486AA4335CCA79D659B /* UserJwtAskTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserJwtAskTests.swift; sourceTree = ""; }; 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtConfig.swift; sourceTree = ""; }; 67ECA2928D863073B785F93F /* IamFetchIdentityVerificationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IamFetchIdentityVerificationTests.swift; sourceTree = ""; }; @@ -2539,6 +2543,7 @@ 047D8F5E1095A20C9C54FD33 /* OSRequestAuthTests.swift */, 3016921C1F6B7B7793F67567 /* RequestPathEncodingTests.swift */, 5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */, + FDA6355B75EDC9A3100B98F5 /* UserStateReportingTests.swift */, 3567F486AA4335CCA79D659B /* UserJwtAskTests.swift */, ); path = OneSignalUserTests; @@ -2556,6 +2561,7 @@ isa = PBXGroup; children = ( 3CF11E3C2C6D6155002856F5 /* UserExecutorTests.swift */, + 6C310B6C569E96C204F8CD68 /* UserExecutorRetryTests.swift */, 3CA93BC3300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift */, C95B2AD05FFF9FC12C9D5878 /* ExecutorReadYourWriteTests.swift */, 3C5181A1B2C3D4E5F6A7B801 /* SubscriptionCreateResponseTests.swift */, @@ -4714,6 +4720,7 @@ files = ( 3CB331682F281679000E1801 /* CustomEventsIntegrationTests.swift in Sources */, 3CF11E3D2C6D6155002856F5 /* UserExecutorTests.swift in Sources */, + 354E0C59BA9B18437C36215B /* UserExecutorRetryTests.swift in Sources */, 3C67F77A2BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift in Sources */, 3CC063EE2B6D7FE8002BB07F /* OneSignalUserTests.swift in Sources */, 3CA93BC7300B0100000724B3 /* SubscriptionModelConcurrencyTests.swift in Sources */, @@ -4733,6 +4740,7 @@ AAFA2D46E6C5FD3D14D39F27 /* OSRequestAuthTests.swift in Sources */, 23D66BEB40CE76DFF89744A3 /* RequestPathEncodingTests.swift in Sources */, 7EB69F3B404D0AEF46EC1536 /* UserJwtLifecycleTests.swift in Sources */, + E42087CB1AB15481E55D34FE /* UserStateReportingTests.swift in Sources */, 9345FA2E8878EA1B0216A49A /* UserJwtAskTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift b/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift index 1b5288797..1c865af66 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift @@ -283,4 +283,9 @@ extension MockOneSignalClient { request.isKind(of: type) }.count } + + /// Held requests count as in flight. Read under the lock, so safe from the test thread. + public var isIdle: Bool { + return lock.withLock { startedRequests.count == completedRequests.count } + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift index 4dae89435..31a65c3ea 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift @@ -55,4 +55,12 @@ extension OSOperationRepo { } paused = false } + + /** + The queue as of right now. Tests poll it while the repo appends on its own queue, so reading + `deltaQueue` directly is a data race even when only the count is wanted. + */ + public func snapshotDeltaQueue() -> [OSDelta] { + return dispatchQueue.sync { deltaQueue } + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift index 2e01ca899..52cc1c79d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift @@ -29,6 +29,7 @@ import Foundation import XCTest import OneSignalCore import OneSignalCoreMocks +import OneSignalOSCoreMocks @testable import OneSignalOSCore /// Covers `flushDeltaQueue` routing: matched deltas go to executors and leave the repo diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift index 47ad71e0d..7b3e405c3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift @@ -29,6 +29,7 @@ import Foundation import XCTest import OneSignalCore import OneSignalCoreMocks +import OneSignalOSCoreMocks import OneSignalKMP @testable import OneSignalOSCore diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift index fc84a9fba..bb84d4ec3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift @@ -71,16 +71,6 @@ enum OSOperationRepoTestEnvironment { } } -extension OSOperationRepo { - /** - The queue as of right now. Tests poll it while the repo appends on its own queue, so reading - `deltaQueue` directly is a data race even when only the count is wanted. - */ - func snapshotDeltaQueue() -> [OSDelta] { - return dispatchQueue.sync { deltaQueue } - } -} - /// Records what the Operation Repo hands it, so tests can assert on routing rather than on requests. final class MockOperationExecutor: OSOperationExecutor { let supportedDeltas: [String] diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift index 78fb01c8d..aeee2c099 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift @@ -440,9 +440,8 @@ extension OSSubscriptionOperationExecutor { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionOperationExecutor delete subscription request failed with error: \(error.debugDescription)") self.dispatchQueue.async { let responseType = OSNetworkingUtils.getResponseStatusType(error.code) - if responseType == .unauthorized, self.auth.handleUnauthorized(request) { - OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSubscriptionOperationExecutor holding \(request) for a new token") - } else if responseType != .retryable { + // No token handling: the delete is never signed, so a 401 here is not about the user's JWT. + if responseType != .retryable { // Fail, no retry, remove from cache and queue // If this request returns a missing status, that is ok as this is a delete request self.removeRequestQueue.removeAll(where: { $0 == request}) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift index 60c516520..392fb3408 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift @@ -68,9 +68,13 @@ class OSUserExecutor { Runs on every send rather than only when the requirement hydrates, and reads the live model: this executor sends nothing while `requirement` is unknown, so the queue is always judged against a known value, and the check is cheap. + + With the requirement off there is nothing to reshape, but a login kept at start while the requirement + was still unknown may never become sendable; see `dropIdentifyUsersThatCanNeverPrepare`. */ private func reshapeInvalidRequests() { guard identityVerificationService.ivBehaviorActive else { + dropIdentifyUsersThatCanNeverPrepare() return } @@ -121,6 +125,43 @@ class OSUserExecutor { return request is OSRequestFetchIdentityBySubscription } + /** + An Identify User whose user has no `onesignal_id`, and no queued Create User or Fetch Identity By + Subscription to supply one, can never prepare, so drop it rather than let it block the logins behind it. + Runs on every send, so a handler that supplies an id must hydrate before it removes its Request from + the queue; a pass between the two would see no supplier and drop the login. + */ + private func dropIdentifyUsersThatCanNeverPrepare() { + guard identityVerificationService.requirement == .off else { + return + } + let modelIdsAwaitingAnId = Set(userRequestQueue.compactMap { request -> String? in + if let createUser = request as? OSRequestCreateUser { + return createUser.identityModel.modelId + } + if let fetchIdentity = request as? OSRequestFetchIdentityBySubscription { + return fetchIdentity.identityModel.modelId + } + return nil + }) + let kept = userRequestQueue.filter { request in + guard let identifyUser = request as? OSRequestIdentifyUser, + identifyUser.identityModelToIdentify.onesignalId == nil, + !modelIdsAwaitingAnId.contains(identifyUser.identityModelToIdentify.modelId) + else { + return true + } + let reason = "its user never received an onesignal_id and nothing queued can supply one" + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserExecutor dropped \(identifyUser), \(reason)") + return false + } + guard kept.count != userRequestQueue.count else { + return + } + userRequestQueue = kept + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, withValue: userRequestQueue) + } + /// Read in requests from the cache, do not read in FetchUser requests as this is not needed. private func uncacheUserRequests() { var userRequestQueue: [OSUserRequest] = [] @@ -160,9 +201,15 @@ class OSUserExecutor { req.identityModelToUpdate = updateInRepo } - // `prepareForExecution` is false under IV so `reshapeInvalidRequests` can promote - // this login; do not treat that as a permanent drop. - if auth.ivBehaviorActive || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // Keep the login when the user it identifies is known to the repo: a restored Create User + // ahead of it put the model there, and that response supplies the `onesignal_id` prepare + // needs. Keep it too while the requirement is not known to be off, since + // `reshapeInvalidRequests` decides what a login made under Identity Verification becomes + // once the requirement is known. Otherwise only a Request that can be sent as is stays: + // one that can never prepare would hold the queue and block the logins behind it. + if identifyInRepo != nil + || identityVerificationService.requirement != .off + || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { if identifyInRepo == nil { addIdentityModel(req.identityModelToIdentify) } @@ -253,7 +300,9 @@ class OSUserExecutor { else { // Only the app can end this wait (`updateUserJwt` → `storeJwt`); do not poll for it. // A login for another user behind this one must not be stranded, so step over it. - if self.auth.awaitsToken(request) { + // Anything else that stops a prepare, the cool-down or an id still to arrive, resolves + // on its own, and the delayed retry below is what picks it up. + if self.auth.parkedForToken(request) { awaitingToken = true continue } @@ -339,8 +388,6 @@ extension OSUserExecutor { request.sentToClient = true OneSignalCoreImpl.sharedClient().execute(request) { response in - self.removeFromQueue(request) - // Create User's response won't send us the user's complete info if this user already exists if let response = response { // Parse the response for any data we need to update @@ -350,6 +397,8 @@ extension OSUserExecutor { originalPushToken: request.originalPushToken, addNewRecords: request.addsNewRecords ) + // Must follow the hydrate; see `dropIdentifyUsersThatCanNeverPrepare`. + self.removeFromQueue(request) // If this user already exists and we logged into an external_id, fetch the user data // Fetch the user only if its the current user and non-anonymous @@ -371,6 +420,8 @@ extension OSUserExecutor { ) OSConsistencyManager.shared.setRywTokenAndDelay(id: onesignalId, key: OSIamFetchOffsetKey.userCreate, value: rywData) } + } else { + self.removeFromQueue(request) } OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = false } onFailure: { error in @@ -417,11 +468,12 @@ extension OSUserExecutor { request.sentToClient = true OneSignalCoreImpl.sharedClient().execute(request) { response in - self.removeFromQueue(request) - if let identityObject = self.parseIdentityObjectResponse(response), let onesignalId = identityObject[OS_ONESIGNAL_ID] { request.identityModel.hydrate(identityObject) + OSUserStateSnapshot.fireUserStateChangedIfCurrent(request.identityModel) + // Must follow the hydrate; see `dropIdentifyUsersThatCanNeverPrepare`. + self.removeFromQueue(request) // Fetch this user's data if it is the current user guard OneSignalUserManagerImpl.sharedInstance.currentUser(matching: request.identityModel.modelId) != nil @@ -431,6 +483,8 @@ extension OSUserExecutor { } self.fetchUser(aliasLabel: OS_ONESIGNAL_ID, aliasId: onesignalId, identityModel: request.identityModel) + } else { + self.removeFromQueue(request) } } onFailure: { error in OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserExecutor executeFetchIdentityBySubscriptionRequest failed with error: \(error.debugDescription)") @@ -470,10 +524,9 @@ extension OSUserExecutor { request.sentToClient = true OneSignalCoreImpl.sharedClient().execute(request) { _ in - self.removeFromQueue(request) - guard let onesignalId = request.identityModelToIdentify.onesignalId else { OneSignalLog.onesignalLog(.LL_ERROR, message: "executeIdentifyUserRequest succeeded but is now missing OneSignal ID!") + self.removeFromQueue(request) self.executePendingRequests() return } @@ -484,6 +537,9 @@ extension OSUserExecutor { request.aliasLabel: request.aliasId ] request.identityModelToUpdate.hydrate(aliases) + OSUserStateSnapshot.fireUserStateChangedIfCurrent(request.identityModelToUpdate) + // Must follow the hydrate; see `dropIdentifyUsersThatCanNeverPrepare`. + self.removeFromQueue(request) // the anonymous user has been identified, still need to Fetch User as we cleared local data if OneSignalUserManagerImpl.sharedInstance.currentUser(matching: request.identityModelToUpdate.modelId) != nil { @@ -608,8 +664,10 @@ extension OSUserExecutor { // If this was a create user, it hydrates the onesignal_id of the request's identityModel // The model in the store may be different, and it may be waiting on the onesignal_id of this previous model + // Only a current user is reported to the app; a parked Create User can complete after a switch. if let identityObject = parseIdentityObjectResponse(response) { identityModel.hydrate(identityObject) + OSUserStateSnapshot.fireUserStateChangedIfCurrent(identityModel) if addNewRecords, let onesignalId = identityObject[OS_ONESIGNAL_ID] { newRecordsState.add(onesignalId) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift index 6df132faf..06a362887 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift @@ -133,11 +133,15 @@ class OSIdentityModel: OSModel { } /** - Called to clear the model's data in preparation for hydration via a fetch user call. + Keeps `onesignal_id` and `external_id` and drops every other alias, ahead of the Fetch User response + that fills the model back in. The fetch is addressed by one of those two, so neither can change, and + work built on another queue before the response lands has to keep reading this user as created and + identified. A response that omits `external_id` therefore leaves it in place; a same-user `login` is + a no-op, so only a login as someone else replaces it. */ func clearData() { lock.withLock { - self.aliases = [:] + self.aliases = self.aliases.filter { $0.key == OS_ONESIGNAL_ID || $0.key == OS_EXTERNAL_ID } } } @@ -161,21 +165,34 @@ class OSIdentityModel: OSModel { } OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSIdentityModel hydrateModel with aliases: \(remoteAliases)") - let newOnesignalId = remoteAliases[OS_ONESIGNAL_ID] - let newExternalId = remoteAliases[OS_EXTERNAL_ID] - + // Reporting the user to the app is the executor's call, since only a current user may be reported. internalAddAliases(remoteAliases) - OSUserStateSnapshot.fireUserStateChanged(newOnesignalId: newOnesignalId, newExternalId: newExternalId) } } /** Owns the last user state the app was told about, so the observer only hears real changes. - Hydration is the usual source, but `logout` under Identity Verification also reports here: it creates - no user on the server, so there is no hydration to carry the news that nobody is signed in. + The User executor reports a hydrated current user through `fireUserStateChangedIfCurrent`, and `logout` + under Identity Verification also reports here: it creates no user on the server, so there is no + hydration to carry the news that nobody is signed in. */ enum OSUserStateSnapshot { + /** + Reports the hydrated user to the app, but only while that user is still current. A Create User or + Identify User for a user the app has since switched away from still hydrates its model, since the + Requests queued behind it need the `onesignal_id`, but the app must not hear that user as signed in, + and the persisted pair must keep naming the current user, or the current user's real state would + later read as unchanged and go unreported. + */ + static func fireUserStateChangedIfCurrent(_ identityModel: OSIdentityModel) { + guard OneSignalUserManagerImpl.sharedInstance.currentUser(matching: identityModel.modelId) != nil else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSUserStateSnapshot not reporting a hydrated user who is no longer current") + return + } + fireUserStateChanged(newOnesignalId: identityModel.onesignalId, newExternalId: identityModel.externalId) + } + /// Fires the user observer if `onesignal_id` OR `external_id` differs from the last reported pair. static func fireUserStateChanged(newOnesignalId: String?, newExternalId: String?) { let prevOnesignalId = OneSignalUserDefaults.initShared().getSavedString(forKey: OS_SNAPSHOT_ONESIGNAL_ID, defaultValue: nil) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift index 7aba9299c..42386565b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift @@ -53,17 +53,19 @@ protocol OSRequestAuthorizing: AnyObject { /// The same decision for endpoints that take a token but no alias, because their path names a /// subscription or the app. Returns `false` under the same conditions as `authorizeUserScoped`, - /// except that a `sendsUnsigned` Request with no owner is allowed through. + /// except that a `sendsUnsigned` Request always goes through with no header, owner or not. func authorize(_ request: OSUserRequest) -> Bool /** - Returns `true` if the Request's owner has no token to sign with, which is why the two methods above - parked it. Reads only: it does not ask the app for a token, so call it after one of them has. + Whether the last authorization of this Request parked it, because its owner has no token to sign + with. Consumes the answer, so ask once per `prepareForExecution`, right after it returns false. Lets a caller that stops at its first unsendable Request tell "nothing can send until the app hands - over a token for this user" from "not addressable yet", which resolves on its own. + over a token for this user" from "not addressable yet", which resolves on its own: an app id that has + not arrived, a user still inside the new-records cool-down, or an `onesignal_id` a queued Create User + has yet to supply. Those fail before the two methods above run, so only a park may skip the retry. */ - func awaitsToken(_ request: OSUserRequest) -> Bool + func parkedForToken(_ request: OSUserRequest) -> Bool /** Parks the token an unauthorized response rejected and clears `sentToClient` so the Request is @@ -119,6 +121,15 @@ final class OSRequestAuth: OSRequestAuthorizing { private let identityVerificationService: OSIdentityVerificationService private let jwt: OSUserJwtProviding + /** + Requests whose last authorization `park` held. Weak, so a Request that leaves its queue leaves this + too. Every authorization starts by forgetting the Request, so the entry reflects the latest attempt, + and `parkedForToken` removes it, so a prepare that fails before authorizing reads as not parked. + Shared by every executor's queue, hence the lock. + */ + private let parkedRequests = NSHashTable.weakObjects() + private let parkedRequestsLock = NSLock() + var ivBehaviorActive: Bool { return identityVerificationService.ivBehaviorActive } @@ -129,6 +140,7 @@ final class OSRequestAuth: OSRequestAuthorizing { } func authorizeUserScoped(_ request: OSUserRequest, legacyAlias: OSAliasPair) -> OSAliasPair? { + forgetPark(of: request) guard ivBehaviorActive else { return legacyAlias } @@ -148,16 +160,15 @@ final class OSRequestAuth: OSRequestAuthorizing { } func authorize(_ request: OSUserRequest) -> Bool { - guard ivBehaviorActive else { + forgetPark(of: request) + // An exempt Request may carry an owner for the purge; it still goes out unsigned. + guard ivBehaviorActive, !request.sendsUnsigned else { return true } guard let externalId = request.ownerExternalId else { // Anything not exempt is a leftover the purge has yet to clear, and unsendable until it does. - guard request.sendsUnsigned else { - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSRequestAuth: refusing \(request), it has no owner under Identity Verification") - return false - } - return true + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSRequestAuth: refusing \(request), it has no owner under Identity Verification") + return false } guard let token = jwt.validJwt(externalId: externalId) else { park(request, ownedBy: externalId) @@ -167,11 +178,18 @@ final class OSRequestAuth: OSRequestAuthorizing { return true } - func awaitsToken(_ request: OSUserRequest) -> Bool { - guard ivBehaviorActive, let externalId = request.ownerExternalId else { - return false + func parkedForToken(_ request: OSUserRequest) -> Bool { + return parkedRequestsLock.withLock { + guard parkedRequests.contains(request) else { + return false + } + parkedRequests.remove(request) + return true } - return jwt.validJwt(externalId: externalId) == nil + } + + private func forgetPark(of request: OSUserRequest) { + parkedRequestsLock.withLock { parkedRequests.remove(request) } } /** @@ -181,6 +199,7 @@ final class OSRequestAuth: OSRequestAuthorizing { is stored or a `login` as that user rearms it. */ private func park(_ request: OSUserRequest, ownedBy externalId: String) { + parkedRequestsLock.withLock { parkedRequests.add(request) } // Log only on the ask that reaches the app; later prepareForExecution retries stay quiet. if jwt.askForToken(externalId: externalId) { OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSRequestAuth: holding \(request) until \(externalId) has a token") diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestDeleteSubscription.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestDeleteSubscription.swift index cf162e809..c67e0aa8e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestDeleteSubscription.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestDeleteSubscription.swift @@ -45,10 +45,14 @@ class OSRequestDeleteSubscription: OneSignalRequest, OSUserRequest { /** See the ownership convention in `OSUserRequest.swift`. Removing an email or SMS subscription is a deliberate action on one user, so an anonymous one is dropped under Identity Verification even - though the path addresses a subscription rather than a user. + though the path addresses a subscription rather than a user. The owner serves that purge only; + see `sendsUnsigned`. */ let ownerExternalId: String? + /// The endpoint takes no user JWT, so waiting for a token would only delay the unsubscribe, forever after a logout. + var sendsUnsigned: Bool { return true } + // Need the subscription_id func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { if let subscriptionId = subscriptionModel.subscriptionId, diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift index 7842b4708..ac02149fe 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift @@ -34,9 +34,9 @@ protocol OSUserRequest: OneSignalRequest, NSCoding { /// The user this Request belongs to; also selects its token. See the ownership convention below. var ownerExternalId: String? { get } - /// Whether this Request may still be sent with no `Authorization` header once Identity Verification - /// is in effect. Only Update Subscription may: its path names a subscription rather than a user, so - /// there is no user for the server to authorize. Everything else with no owner is refused. + /// Whether this Request goes out with no `Authorization` header even under Identity Verification: + /// Update Subscription and Delete Subscription, whose endpoints are addressed by subscription ID and + /// take no user JWT. Everything else with no owner is refused. var sendsUnsigned: Bool { get } /// Builds the path and resolves authorization. `false` leaves the Request queued, whether it is @@ -55,10 +55,11 @@ extension OSUserRequest { owner's `external_id` as of when the Request was built, and both the purge and the token lookup judge it by that rather than by its `identityModel`. - The live model cannot answer the question. `clearUserData` empties an Identity Model's aliases before - a fetch response hydrates them, so for that window an identified user reads as anonymous and a purge - running alongside it would delete signed work. The stamp also matches how `OSDelta` carries - `externalId`, which keeps a Delta and the Request built from it judged the same way. + The live model is not the record of who the work was for. Its aliases are cleared and hydrated again + around every fetch (only the two ids survive the clear, see `OSIdentityModel.clearData`), and the + owner has to be what it was when the work was built, not what the model reads later. The stamp also + matches how `OSDelta` carries `externalId`, which keeps a Delta and the Request built from it judged + the same way. nil means anonymous, including for caches written before ownership was stamped. @@ -68,7 +69,9 @@ extension OSUserRequest { Three Requests are nil by construction and so are never signed: Identify User and Fetch Identity By Subscription both address a user that has no `external_id` yet, and Update Subscription is the - device's own push subscription. Each says why at its declaration. + device's own push subscription. Delete Subscription carries an owner for the purge but declares + `sendsUnsigned`, since its endpoint is addressed by subscription ID and takes no user JWT. Each says + why at its declaration. */ internal extension OneSignalRequest { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift index f798e56ee..f4c901715 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift @@ -28,6 +28,7 @@ import XCTest import OneSignalCore import OneSignalCoreMocks +import OneSignalOSCoreMocks import OneSignalUserMocks @testable import OneSignalOSCore @testable import OneSignalUser @@ -298,6 +299,6 @@ final class DeltaOwnershipTests: XCTestCase { } private func queuedDelta(named name: String, property: String) -> OSDelta? { - return OneSignalUserManagerImpl.sharedInstance.operationRepo.deltaQueue.first { $0.name == name && $0.property == property } + return OneSignalUserManagerImpl.sharedInstance.operationRepo.snapshotDeltaQueue().first { $0.name == name && $0.property == property } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift index ff103ab63..6509b847b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift @@ -349,6 +349,26 @@ final class ExecutorAnonymousPurgeTests: XCTestCase { XCTAssertEqual(cachedRequestOwners(OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, of: OSRequestDeleteSubscription.self), [userA_EUID]) } + /// The delete endpoint is addressed by subscription ID and takes no user JWT, so an owned delete goes + /// out unsigned even when its owner has no token, and nobody is asked for one. Parking it would hold + /// an unsubscribe on a credential the server does not read. + func testTheSubscriptionExecutorSendsAnOwnedDeleteUnsignedWithoutAskingForAToken() { + let tokenless = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userB_OSID, OS_EXTERNAL_ID: userB_EUID], changeNotifier: OSEventProducer()) + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(tokenless) + let executor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, auth: auth) + + let removal = subscriptionDelta(OS_REMOVE_SUBSCRIPTION_DELTA, for: tokenless, subscription: subscription(id: "tokenless-subscription-id")) + executor.enqueueDelta(removal) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The owned delete was not sent") { + self.client.hasExecutedRequestOfType(OSRequestDeleteSubscription.self, expectedCount: 1) + } + + XCTAssertNil(client.executedRequests.first?.additionalHeaders?["Authorization"]) + XCTAssertTrue(OneSignalUserManagerImpl.sharedInstance.userJwtRepo.pendingTokenAsks().isEmpty, + "nobody may be asked for a token the endpoint does not read") + } + /// An Update Subscription is addressed by subscription ID and never signed, so it has no owner to be /// judged by and the purge has to leave that queue alone: `logout()`'s unsubscribe travels in it. func testTheSubscriptionExecutorKeepsEveryUpdateRequest() { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorRetryTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorRetryTests.swift new file mode 100644 index 000000000..a621edb58 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorRetryTests.swift @@ -0,0 +1,91 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import XCTest +import OneSignalCore +import OneSignalOSCore +import OneSignalCoreMocks +import OneSignalOSCoreMocks +import OneSignalUserMocks +@testable import OneSignalUser + +/** + What the User executor retries on its own, and what it waits on the app for. A parked Request waits + for a token that only `updateUserJwt` can supply; anything else that stops a prepare resolves on its + own and has to be retried, or a login sits in the queue until the next launch. + */ +final class UserExecutorRetryTests: XCTestCase { + private var client = MockOneSignalClient() + private var newRecordsState = MockNewRecordsState() + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + + client = MockOneSignalClient() + OneSignalCoreImpl.setSharedClient(client) + newRecordsState = MockNewRecordsState() + // Presence is the hold: the production timer is a no-op under TEST. + newRecordsState.holdWhilePresent = true + } + + override func tearDownWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + } + + private func makeExecutor() -> OSUserExecutor { + return OSUserExecutor( + newRecordsState: newRecordsState, + identityVerificationService: OneSignalUserManagerImpl.sharedInstance.identityVerificationService, + auth: OneSignalUserManagerImpl.sharedInstance.requestAuth + ) + } + + /** + A Create User held by the new-records cool-down on its push subscription fails to prepare before + the auth layer can park it, so nobody is asked for a token. It has to be retried once the cool-down + passes, at which point it parks and asks, rather than be stepped over as if it were already parked. + */ + func testACreateUserHeldByTheCoolDownIsRetriedUntilItCanParkAndAsk() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + let jwtRepo = OneSignalUserManagerImpl.sharedInstance.userJwtRepo + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: nil) + newRecordsState.add(testPushSubId) + let executor = makeExecutor() + + executor.createUser(user) + allowAsyncWorkToRun() + XCTAssertFalse(jwtRepo.pendingTokenAsks().contains(userA_EUID), "held by the cool-down, so nobody may be asked yet") + + newRecordsState.holdWhilePresent = false + OneSignalCoreMocks.waitUntil("The app was not asked for a token once the cool-down passed") { + jwtRepo.pendingTokenAsks().contains(userA_EUID) + } + XCTAssertFalse(client.hasExecutedRequestOfType(OSRequestCreateUser.self), "nothing signs a Create User whose owner has no token") + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift index 27e5a82de..e313a233f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift @@ -612,6 +612,131 @@ final class UserExecutorTests: XCTestCase { } } +/// Logins restored from the archive of a launch that ended before its requests went out. Split from +/// `UserExecutorTests` at SwiftLint's type body limit. +final class UserExecutorRestoredLoginTests: XCTestCase { + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + } + + /// The archive an offline first launch with a `login` leaves behind: the anonymous Create User has not + /// been sent, so its user has no `onesignal_id` yet. The Identify User behind it has to wait for that + /// response rather than be dropped at start. + func testRestoredIdentifyUserBehindItsUnsentCreateUserIsSentOnceTheCreateUserCompletes() { + /* Setup */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: nil) + let createUser = makeUnsentAnonymousCreateUserRequest() + cacheUserRequests([createUser, makeIdentifyUserRequest(identifying: createUser.identityModel, updating: user.identityModel)]) + + /* When */ + let mocks = Mocks { + MockUserRequests.setDefaultCreateAnonUserResponses(with: $0) + MockUserRequests.setDefaultIdentifyUserResponses(with: $0, externalId: userA_EUID, conflicted: false) + } + OneSignalCoreMocks.waitUntil("Restored Identify User was not sent after its Create User") { + mocks.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self) + } + + /* Then */ + XCTAssertTrue(mocks.client.executedRequests.first is OSRequestCreateUser, "the Create User has to go out first") + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self, expectedCount: 1)) + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self, expectedCount: 1)) + } + + /// Same archive with the requirement unknown at start. Once auth turns out to be required, reshape + /// drops the anonymous Create User and promotes the Identify User into the Create User `login` would + /// have made. + func testRestoredIdentifyUserBehindItsUnsentCreateUserBecomesACreateUserWhenAuthIsRequired() { + /* Setup */ + makeRequirementUnknown() + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: nil) + user.identityModel.jwtBearerToken = "token-a" + let createUser = makeUnsentAnonymousCreateUserRequest() + cacheUserRequests([createUser, makeIdentifyUserRequest(identifying: createUser.identityModel, updating: user.identityModel)]) + let mocks = Mocks { MockUserRequests.setDefaultCreateUserResponses(with: $0, externalId: userA_EUID) } + allowAsyncWorkToRun() + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self), + "nothing may be sent while the requirement is unknown") + + /* When */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalCoreMocks.waitUntil("Restored Identify User was not reshaped into a Create User") { + mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self) + } + + /* Then */ + let createUsers = mocks.client.executedRequests.compactMap { $0 as? OSRequestCreateUser } + XCTAssertEqual(createUsers.map { $0.identityModel.externalId }, [userA_EUID], "only the promoted Create User for A may go out") + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + } + + /// A login kept at start while the requirement was unknown, whose user never received an + /// `onesignal_id` and has no Create User left to supply one, can never prepare once auth is known to + /// be off. It has to be dropped rather than hold the queue, or every login behind it is stranded. + func testRestoredIdentifyUserThatCanNeverPrepareIsDroppedOnceAuthIsKnownToBeOff() { + /* Setup */ + makeRequirementUnknown() + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: nil) + let neverCreated = OSIdentityModel(aliases: nil, changeNotifier: OSEventProducer()) + cacheUserRequests([makeIdentifyUserRequest(identifying: neverCreated, updating: user.identityModel)]) + let mocks = Mocks { MockUserRequests.setDefaultCreateUserResponses(with: $0, externalId: userB_EUID) } + + /* When */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + // A login behind the dead Identify User has to go out. + let userB = mocks.createUserInstance(externalId: userB_EUID) + OneSignalUserManagerImpl.sharedInstance._user = userB + mocks.userExecutor.createUser(userB) + OneSignalCoreMocks.waitUntil("Create User behind the dead Identify User was not sent") { + mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self) + } + + /* Then */ + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self, expectedCount: 1)) + } + + private func cacheUserRequests(_ requests: [OSUserRequest]) { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, withValue: requests) + } + + private func makeIdentifyUserRequest( + identifying identityModelToIdentify: OSIdentityModel, + updating identityModelToUpdate: OSIdentityModel + ) -> OSRequestIdentifyUser { + return OSRequestIdentifyUser( + aliasLabel: OS_EXTERNAL_ID, + aliasId: userA_EUID, + identityModelToIdentify: identityModelToIdentify, + identityModelToUpdate: identityModelToUpdate + ) + } + + /// A Create User for an anonymous user that has not been sent, so its user has no `onesignal_id`. + private func makeUnsentAnonymousCreateUserRequest() -> OSRequestCreateUser { + let pushModel = OSSubscriptionModel( + type: .push, address: nil, subscriptionId: nil, reachable: false, isDisabled: false, changeNotifier: OSEventProducer() + ) + return OSRequestCreateUser( + identityModel: OSIdentityModel(aliases: nil, changeNotifier: OSEventProducer()), + propertiesModel: OSPropertiesModel(changeNotifier: OSEventProducer()), + pushSubscriptionModel: pushModel, + originalPushToken: nil + ) + } + + /// `OneSignalUserMocks.reset()` hydrates the requirement off for non-IV tests, so a test that starts + /// unknown has to clear both the shared config and its cache. + private func makeRequirementUnknown() { + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_USE_IDENTITY_VERIFICATION) + OSCoreMocks.resetSharedJwtConfig() + } +} + /// Upgrade decode of `addsNewRecords` on a cached Create User. final class OSRequestCreateUserArchiveTests: XCTestCase { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift index a29434e21..01dbf284d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift @@ -33,7 +33,7 @@ import OneSignalUserMocks @testable import OneSignalUser /// Covers the JWT bearer token on `OSIdentityModel`: which tokens count as usable, the -/// compare-and-set on invalidation, and what survives an archive round trip. +/// compare-and-set on invalidation, and what survives an archive round trip. Also what `clearData` keeps. final class OSIdentityModelTests: XCTestCase { override func setUpWithError() throws { @@ -59,6 +59,37 @@ final class OSIdentityModelTests: XCTestCase { return try XCTUnwrap(unarchiver.decodeObject(forKey: NSKeyedArchiveRootObjectKey) as? OSIdentityModel) } + // MARK: - clearData() + + /// Work built before the fetch response lands must still read this user as created and identified. + func testClearDataKeepsBothIdsAndDropsEveryOtherAlias() { + let model = OSIdentityModel( + aliases: [OS_ONESIGNAL_ID: userA_OSID, OS_EXTERNAL_ID: userA_EUID, "stale_label": "stale_value"], + changeNotifier: OSEventProducer() + ) + + model.clearData() + + XCTAssertEqual(model.onesignalId, userA_OSID) + XCTAssertEqual(model.externalId, userA_EUID) + XCTAssertNil(model.aliases["stale_label"]) + } + + /// The fetch response merges into what the clear kept, so one without `external_id` leaves the user + /// identified. + func testHydrateAfterClearDataMergesIntoTheKeptExternalId() { + let model = OSIdentityModel( + aliases: [OS_ONESIGNAL_ID: userA_OSID, OS_EXTERNAL_ID: userA_EUID], + changeNotifier: OSEventProducer() + ) + model.clearData() + + model.hydrate([OS_ONESIGNAL_ID: userA_OSID]) + + XCTAssertEqual(model.onesignalId, userA_OSID) + XCTAssertEqual(model.externalId, userA_EUID) + } + // MARK: - getValidJwt() func testGetValidJwtReturnsNilWhenTokenIsNil() { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSRequestAuthTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSRequestAuthTests.swift index 62455a1d7..fe73e7e25 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSRequestAuthTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSRequestAuthTests.swift @@ -238,6 +238,27 @@ final class OSRequestAuthTests: XCTestCase { XCTAssertNil(request.authorizationHeader) } + /// Delete Subscription keeps an owner for the purge; a missing token must not park it or ask the app. + func testAuthorizeSendsAnOwnedExemptRequestUnsignedWithoutAsking() { + let auth = makeAuth(requiresUserAuth: true) + let request = StubUserRequest(ownerExternalId: "user-a", sendsUnsigned: true) + + XCTAssertTrue(auth.authorize(request)) + XCTAssertNil(request.authorizationHeader) + XCTAssertTrue(jwt.askedFor.isEmpty) + XCTAssertFalse(auth.parkedForToken(request)) + } + + /// Not signed even with a token on hand; the endpoint ignores the header. + func testAuthorizeDoesNotSignAnOwnedExemptRequestThatHasAToken() { + let auth = makeAuth(requiresUserAuth: true) + jwt.tokens["user-a"] = "token-a" + let request = StubUserRequest(ownerExternalId: "user-a", sendsUnsigned: true) + + XCTAssertTrue(auth.authorize(request)) + XCTAssertNil(request.authorizationHeader) + } + /// Everything else with no owner is a leftover the purge has yet to clear, and must not go out unsigned. func testAuthorizeRefusesAnUnownedRequestThatIsNotExempt() { let auth = makeAuth(requiresUserAuth: true) @@ -255,6 +276,41 @@ final class OSRequestAuthTests: XCTestCase { XCTAssertNil(request.authorizationHeader) } + // MARK: - parkedForToken + + /// The answer is consumed, so the executor asks once per failed prepare and sees a fresh answer next time. + func testParkedForTokenIsTrueOnceAfterAPark() { + let auth = makeAuth(requiresUserAuth: true) + let request = StubUserRequest(ownerExternalId: "user-a") + + XCTAssertFalse(auth.parkedForToken(request), "nothing has parked it yet") + XCTAssertFalse(auth.authorize(request)) + XCTAssertTrue(auth.parkedForToken(request)) + XCTAssertFalse(auth.parkedForToken(request), "consumed by the read before") + } + + /// A signed authorization is not a park, so a prepare that fails after it failed for some other reason. + func testParkedForTokenIsFalseAfterASignedAuthorization() { + let auth = makeAuth(requiresUserAuth: true) + jwt.tokens["user-a"] = "token-a" + let request = StubUserRequest(ownerExternalId: "user-a") + + XCTAssertTrue(auth.authorize(request)) + XCTAssertFalse(auth.parkedForToken(request)) + } + + /// The entry reflects the latest authorization: a park does not outlive a later signed attempt. + func testASignedAuthorizationClearsAnEarlierPark() { + let auth = makeAuth(requiresUserAuth: true) + let request = StubUserRequest(ownerExternalId: "user-a") + XCTAssertFalse(auth.authorize(request)) + + jwt.tokens["user-a"] = "token-a" + XCTAssertTrue(auth.authorizeUserScoped(request, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, "osid")) != nil) + + XCTAssertFalse(auth.parkedForToken(request)) + } + // MARK: - handleUnauthorized func testHandleUnauthorizedInvalidatesTheSignedTokenAndRequeuesTheRequest() { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtLifecycleTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtLifecycleTests.swift index 58514b954..01401ee1b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtLifecycleTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtLifecycleTests.swift @@ -109,7 +109,7 @@ final class UserJwtLifecycleTests: XCTestCase { /// The Delta `logout()` produces by silencing the push subscription. private func silencingDelta() -> OSDelta? { - return OneSignalUserManagerImpl.sharedInstance.operationRepo.deltaQueue.first { + return OneSignalUserManagerImpl.sharedInstance.operationRepo.snapshotDeltaQueue().first { $0.name == OS_UPDATE_SUBSCRIPTION_DELTA && $0.property == "isDisabledInternally" } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserStateReportingTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserStateReportingTests.swift new file mode 100644 index 000000000..1af0598be --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserStateReportingTests.swift @@ -0,0 +1,211 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import XCTest +import OneSignalCore +import OneSignalCoreMocks +import OneSignalOSCoreMocks +import OneSignalUserMocks +@testable import OneSignalOSCore +@testable import OneSignalUser + +private class MockUserStateObserver: NSObject, OSUserStateObserver { + private let lock = NSLock() + private var reported: [OSUserState] = [] + + /// Read on the test thread while the SDK reports from its response threads. + var states: [OSUserState] { + return lock.withLock { reported } + } + + func onUserStateDidChange(state: OSUserChangedState) { + lock.withLock { reported.append(state.current) } + } +} + +/** + What the app's `OSUserStateObserver` hears, and what the persisted snapshot names, once a Request + completes for a user who is no longer current. One test per executor site that hydrates an identity model. + */ +final class UserStateReportingTests: XCTestCase { + private var client = MockOneSignalClient() + private var observer = MockUserStateObserver() + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + + client = MockOneSignalClient() + MockUserRequests.setDefaultCreateUserResponses(with: client, externalId: userA_EUID) + MockUserRequests.setDefaultCreateUserResponses(with: client, externalId: userB_EUID) + OneSignalCoreImpl.setSharedClient(client) + + // Held strongly for the test's lifetime: OSObservable keeps observers weakly. + observer = MockUserStateObserver() + OneSignalUserManagerImpl.sharedInstance.addObserver(observer) + } + + override func tearDownWithError() throws { + // A Request still in flight would land mid-next-test and hydrate the shared models under it. + OneSignalCoreMocks.waitUntil("A Request was still in flight at teardown") { self.client.isIdle } + OneSignalUserManagerImpl.sharedInstance.removeObserver(observer) + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = false + OneSignalCoreMocks.clearUserDefaults() + } + + /** + Stepping over a parked Create User lets a later login proceed, so the parked one can complete after + another user is current. Its model still hydrates, since Requests queued behind it need the + `onesignal_id`, but the app has to keep hearing the current user, and the persisted pair has to keep + naming the current user, or the app is told the wrong user is signed in. + */ + func testAParkedCreateUserThatCompletesAfterAUserSwitchDoesNotReportThatUser() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + + // Parks for want of a token, which asks the app. + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + allowAsyncWorkToRun() + // Steps over the parked Create User and becomes the current, reported user. + OneSignalUserManagerImpl.sharedInstance.login(externalId: userB_EUID, token: "token-b") + waitForTheLoginToSettle() + XCTAssertEqual(observer.states.last?.externalId, userB_EUID) + let reportsBeforeA = observer.states.count + + // Answers the ask, so A's Create User goes out while B is current. + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitUntil("A's parked Create User was not sent") { + self.client.executedRequests.contains { ($0 as? OSRequestCreateUser)?.identityModel.externalId == userA_EUID } + } + // The report fires inside the response block, before the mock counts it complete, so idle means decided. + OneSignalCoreMocks.waitUntil("A's Create User was still in flight") { self.client.isIdle } + // Drains the executor queue before teardown. + allowAsyncWorkToRun(seconds: 0.1) + + // Hydrated, so anything queued for A has its onesignal_id. + XCTAssertEqual(OneSignalUserManagerImpl.sharedInstance.identityModelRepo.get(externalId: userA_EUID)?.onesignalId, userA_OSID) + // Not reported: the app hears nothing new, and the persisted pair still names B. + XCTAssertEqual(observer.states.count, reportsBeforeA, "the app must not hear about A: \(observer.states)") + XCTAssertEqual(observer.states.last?.externalId, userB_EUID) + assertPersistedSnapshotNames(externalId: userB_EUID, onesignalId: userB_OSID) + } + + /// A second `login` before the first one's Identify User returns makes another user current. The + /// response still hydrates the first user's model, but the app must not hear that user. + func testAnIdentifyUserThatCompletesAfterAUserSwitchDoesNotReportThatUser() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + MockUserRequests.setDefaultCreateAnonUserResponses(with: client) + MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: userA_EUID) + // The Identify User addresses the anonymous user by onesignal_id, so let its Create User finish. + OneSignalUserManagerImpl.sharedInstance.start() + OneSignalCoreMocks.waitUntil("The anonymous user was not created") { + OneSignalUserManagerImpl.sharedInstance.user.identityModel.onesignalId == anonUserOSID && self.client.isIdle + } + + client.holdResponses = true + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalCoreMocks.waitUntil("The Identify User was not started") { + self.client.startedRequestCount(ofType: OSRequestIdentifyUser.self) == 1 + } + // B becomes current while A's Identify User is in flight. + OneSignalUserManagerImpl.sharedInstance.login(externalId: userB_EUID, token: nil) + client.releaseHeldResponses() + waitForTheLoginToSettle() + + // Still hydrated, for anything queued behind it. + XCTAssertEqual(OneSignalUserManagerImpl.sharedInstance.identityModelRepo.get(externalId: userA_EUID)?.onesignalId, anonUserOSID) + XCTAssertFalse(observer.states.contains { $0.externalId == userA_EUID }, "the app must not hear about A: \(observer.states)") + XCTAssertEqual(observer.states.last?.externalId, userB_EUID) + assertPersistedSnapshotNames(externalId: userB_EUID, onesignalId: userB_OSID) + } + + /// The 3.x upgrade path. A `login` before the legacy player's fetch returns makes an identified user + /// current; the response still hydrates the anonymous model, but the app must not hear that user. + func testAFetchIdentityBySubscriptionThatCompletesAfterALoginDoesNotReportTheAnonymousUser() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + let legacyPlayerId = "legacy_player_id" + let legacyOnesignalId = "legacy_player_onesignal_id" + OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: legacyPlayerId) + client.setMockResponseForRequest( + request: "OSRequestFetchIdentityBySubscription with subscriptionId: \(legacyPlayerId)", + response: MockUserRequests.testIdentityPayload(onesignalId: legacyOnesignalId, externalId: nil) + ) + client.setMockResponseForRequest( + request: "", + response: MockUserRequests.testIdentityPayload(onesignalId: legacyOnesignalId, externalId: userA_EUID) + ) + client.setMockResponseForRequest( + request: "", + response: MockUserRequests.testIdentityPayload(onesignalId: legacyOnesignalId, externalId: userA_EUID) + ) + client.holdResponses = true + + // Migrates the legacy player; its identity fetch is held. + OneSignalUserManagerImpl.sharedInstance.start() + let anonymousModel = OneSignalUserManagerImpl.sharedInstance.user.identityModel + XCTAssertNil(anonymousModel.onesignalId) + OneSignalCoreMocks.waitUntil("The Fetch Identity By Subscription was not started") { + self.client.startedRequestCount(ofType: OSRequestFetchIdentityBySubscription.self) == 1 + } + // A becomes current while the fetch is in flight. + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + client.releaseHeldResponses() + waitForTheLoginToSettle() + + // Still hydrated, for the Identify User behind it. + XCTAssertEqual(anonymousModel.onesignalId, legacyOnesignalId) + XCTAssertFalse(observer.states.contains { $0.externalId == nil }, "the app must not hear the anonymous user: \(observer.states)") + XCTAssertEqual(observer.states.last?.externalId, userA_EUID) + assertPersistedSnapshotNames(externalId: userA_EUID, onesignalId: legacyOnesignalId) + } + + // MARK: - Helpers + + private func assertPersistedSnapshotNames( + externalId: String, onesignalId: String, file: StaticString = #filePath, line: UInt = #line + ) { + let defaults = OneSignalUserDefaults.initShared() + XCTAssertEqual(defaults.getSavedString(forKey: OS_SNAPSHOT_EXTERNAL_ID, defaultValue: nil), externalId, file: file, line: line) + XCTAssertEqual(defaults.getSavedString(forKey: OS_SNAPSHOT_ONESIGNAL_ID, defaultValue: nil), onesignalId, file: file, line: line) + } + + /** + Outlasts every effect of an identified login, including the Fetch User its Create User or Identify + User starts. A fetch landing later would re-report the current user and hide a wrong report made in + between. + */ + private func waitForTheLoginToSettle() { + OneSignalCoreMocks.waitUntil("The login did not reach a reported user") { + OneSignalUserManagerImpl.sharedInstance.user.identityModel.onesignalId != nil + && self.observer.states.contains { $0.onesignalId != nil } + && self.client.hasCompletedRequestOfType(OSRequestFetchUser.self) + && self.client.isIdle + } + allowAsyncWorkToRun(seconds: 0.1) + OneSignalCoreMocks.waitUntil("The login left a Request in flight") { self.client.isIdle } + } +}