diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 0e4b40081..264c2e9cd 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -298,6 +298,7 @@ 5BC1DE602C90B83900CA8807 /* OSConsistencyKeyEnum.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC1DE5F2C90B83900CA8807 /* OSConsistencyKeyEnum.swift */; }; 5BC1DE622C90B85A00CA8807 /* OSIamFetchOffsetKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC1DE612C90B85A00CA8807 /* OSIamFetchOffsetKey.swift */; }; 5BC1DE642C90BB9000CA8807 /* OSIamFetchReadyCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC1DE632C90BB9000CA8807 /* OSIamFetchReadyCondition.swift */; }; + 6F894909CB6D09258DCCA1C9 /* IamFetchIdentityVerificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67ECA2928D863073B785F93F /* IamFetchIdentityVerificationTests.swift */; }; 7A123295235DFE3B002B6CE3 /* OutcomeTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 7A123294235DFE3B002B6CE3 /* OutcomeTests.m */; }; 7A1232A2235E1743002B6CE3 /* OneSignal.m in Sources */ = {isa = PBXBuildFile; fileRef = 912411F11E73342200E41FD7 /* OneSignal.m */; }; 7A2E90622460DA1500B3428C /* OutcomeIntegrationV2Tests.m in Sources */ = {isa = PBXBuildFile; fileRef = 7A2E90612460DA1500B3428C /* OutcomeIntegrationV2Tests.m */; }; @@ -1572,6 +1573,7 @@ 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 = ""; }; 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 = ""; }; 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtRepo.swift; sourceTree = ""; }; 7A123294235DFE3B002B6CE3 /* OutcomeTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OutcomeTests.m; sourceTree = ""; }; 7A12EBD523060A6F005C4FA5 /* OSSessionManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OSSessionManager.m; sourceTree = ""; }; @@ -2292,6 +2294,7 @@ 3C30FE352F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift */, 3CB35FCA2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift */, 3C7021E72ECF0CF3001768C6 /* OneSignalInAppMessagesTests-Bridging-Header.h */, + 67ECA2928D863073B785F93F /* IamFetchIdentityVerificationTests.swift */, ); path = OneSignalInAppMessagesTests; sourceTree = ""; @@ -4521,6 +4524,7 @@ 3C7021E92ECF0CF4001768C6 /* IAMIntegrationTests.swift in Sources */, 3C01519C2C2E29F90079E076 /* IAMRequestTests.m in Sources */, 3CB35FCB2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift in Sources */, + 6F894909CB6D09258DCCA1C9 /* IamFetchIdentityVerificationTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m index 067787d89..dc7cf17ac 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m @@ -146,8 +146,16 @@ @interface OSMessagingController () @property (nonatomic) BOOL calledLoadTags; -/// set when we attempt getInAppMessagesFromServer and no onesignal ID is available yet -@property (strong, nonatomic, nullable) NSString *shouldFetchOnUserChangeWithSubscriptionID; +/** + Set when a fetch could not go out: no onesignal ID yet, or Identity Verification has not answered what + the call should be addressed and signed with. Read through `takeDeferredFetchSubscriptionId`, since a + user change, a hydration and a new token can all arrive at once and only one of them should refetch. + */ +@property (strong, nonatomic, nullable) NSString *deferredFetchSubscriptionId; + +/// Bumped on login and logout while `newCodePathsRun`. A fetch carries the value it started with, so a +/// response that arrives after the user changed is discarded instead of showing one user's messages to another. +@property (nonatomic) NSUInteger userGeneration; /// Tracks whether the first IAM fetch has completed since this cold start @property (nonatomic) BOOL hasCompletedFirstFetch; @@ -242,6 +250,11 @@ - (instancetype)init { _isInAppMessagingPaused = false; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleIAMPreview:) name:ONESIGNAL_POST_PREVIEW_IAM object:nil]; + // A deferred fetch waits on how to address it and what to sign it with; hydration answers the + // first, a supplied token the second. + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(retryDeferredFetch) name:OS_ON_JWT_CONFIG_HYDRATED object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(retryDeferredFetch) name:OS_ON_USER_JWT_UPDATED object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onUserWillChange) name:OS_ON_USER_WILL_CHANGE object:nil]; } return self; @@ -255,7 +268,15 @@ - (void)initializeTriggerController { dateFromString:timeSinceLastMessage]]; } +/** + Fetches in-app messages for `subscriptionId`, addressed as whoever is the current user. + + Nothing checks that the server has those two paired — the subscription comes from the caller and can + predate a login, while the alias is read here — so a well-formed fetch can still be refused. Pairing + them would mean tracking subscription ownership, which the SDK does not do. + */ - (void)getInAppMessagesFromServer:(NSString *)subscriptionId { + NSUInteger generation = [self currentUserGeneration]; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"getInAppMessagesFromServer"]; @@ -269,7 +290,12 @@ - (void)getInAppMessagesFromServer:(NSString *)subscriptionId { // NOTE: Check for subscription ID above first, before checking for OneSignal ID next if (!onesignalId) { [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Failed to get in app messages due to no OneSignal ID, will reattempt"]; - self.shouldFetchOnUserChangeWithSubscriptionID = subscriptionId; + [self deferFetchWithSubscriptionId:subscriptionId]; + return; + } + + // Park before the wait when the fetch cannot go out; attemptFetchWithRetries reads again after. + if (![self authorizationForFetchOrDefer:subscriptionId]) { return; } @@ -290,22 +316,156 @@ - (void)getInAppMessagesFromServer:(NSString *)subscriptionId { [self attemptFetchWithRetries:subscriptionId rywData:rywData attempts:@0 // Starting with 0 attempts - retryLimit:nil]; // Retry limit to be set dynamically on first failure + retryLimit:nil // Retry limit to be set dynamically on first failure + userGeneration:generation]; }); }); } +- (NSUInteger)currentUserGeneration { + @synchronized (self) { + return self.userGeneration; + } +} + +/// Whether a fetch that started in `generation` is still for the user it was addressed and signed for. +- (BOOL)isCurrentUserGeneration:(NSUInteger)generation { + @synchronized (self) { + return self.userGeneration == generation; + } +} + +/** + Drops the outgoing user's in-app messages and invalidates any fetch still in flight for them, then + queues one for whoever is signing in. Only while `newCodePathsRun`. The new user's fetch goes out + from `onUserStateDidChange`, once there is a `onesignal_id` to address it by. + */ +- (void)onUserWillChange { + if (!OneSignalUserManagerImpl.sharedInstance.newCodePathsRun) { + return; + } + @synchronized (self) { + self.userGeneration += 1; + } + [self deferFetchWithSubscriptionId:OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId]; + // On main, where every other write to `messages` happens. + dispatch_async(dispatch_get_main_queue(), ^{ + self.messages = @[]; + [self dismissOutgoingUserInAppMessages]; + }); +} + +/// Leaves preview IAMs; dismisses a showing non-preview so it cannot stay up under the next user. +- (void)dismissOutgoingUserInAppMessages { + BOOL shouldDismiss = NO; + @synchronized (self.messageDisplayQueue) { + OSInAppMessageInternal *showing = nil; + if (self.isInAppMessageShowing && self.messageDisplayQueue.count > 0) { + OSInAppMessageInternal *first = self.messageDisplayQueue.firstObject; + if (!first.isPreview) { + showing = first; + } + } + NSMutableArray *kept = [NSMutableArray new]; + if (showing) { + [kept addObject:showing]; + } + for (OSInAppMessageInternal *message in self.messageDisplayQueue) { + if (message.isPreview) { + [kept addObject:message]; + } + } + [self.messageDisplayQueue setArray:kept]; + shouldDismiss = showing != nil; + } + if (shouldDismiss) { + [self.viewController dismissCurrentInAppMessage]; + } +} + +- (void)deferFetchWithSubscriptionId:(NSString *)subscriptionId { + @synchronized (self) { + self.deferredFetchSubscriptionId = subscriptionId; + } +} + +/// The deferred subscription ID, if there is one, taken so that two signals arriving together refetch once. +- (NSString * _Nullable)takeDeferredFetchSubscriptionId { + @synchronized (self) { + NSString *subscriptionId = self.deferredFetchSubscriptionId; + self.deferredFetchSubscriptionId = nil; + return subscriptionId; + } +} + +- (void)retryDeferredFetch { + NSString *subscriptionId = [self takeDeferredFetchSubscriptionId]; + if (subscriptionId) { + [self getInAppMessagesFromServer:subscriptionId]; + } +} + +/// How to address and sign this fetch. Nil means it is parked until hydration or a token. +- (OSUserRequestAuthorization *)authorizationForFetchOrDefer:(NSString *)subscriptionId { + OSUserRequestAuthorization *authorization = [OneSignalUserManagerImpl.sharedInstance authorizationForCurrentUser]; + if (authorization) { + return authorization; + } + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Failed to get in app messages due to Identity Verification, will reattempt"]; + [self deferFetchWithSubscriptionId:subscriptionId]; + // OS_ON_USER_JWT_UPDATED can fire before deferredFetchSubscriptionId is set. + authorization = [OneSignalUserManagerImpl.sharedInstance authorizationForCurrentUser]; + if (authorization) { + [self takeDeferredFetchSubscriptionId]; + } + return authorization; +} + +/** + Parks the fetch for a later token to reattempt, without reporting the one it was signed with. + + A rejection here is ambiguous: the path names a user and a subscription the server may simply not have + paired, and a replacement token would not change that. Invalidating is left to the user requests, which + address a user alone; the token the app supplies after one of those releases what is parked here. + */ +- (void)handleUnauthorizedFetch:(OSUserRequestAuthorization *)authorization subscriptionId:(NSString *)subscriptionId { + // An unsigned fetch has no token to replace, so there would be nothing different to reattempt with. + if (!authorization.token) { + return; + } + OSUserRequestAuthorization *current = [OneSignalUserManagerImpl.sharedInstance authorizationForCurrentUser]; + if (current.token.length && ![current.token isEqualToString:authorization.token]) { + // OS_ON_USER_JWT_UPDATED already fired with nothing parked. + [self getInAppMessagesFromServer:subscriptionId]; + return; + } + [self deferFetchWithSubscriptionId:subscriptionId]; +} + - (void)attemptFetchWithRetries:(NSString *)subscriptionId rywData:(OSReadYourWriteData *)rywData attempts:(NSNumber *)attempts - retryLimit:(NSNumber *)retryLimit { + retryLimit:(NSNumber *)retryLimit + userGeneration:(NSUInteger)generation { + if (![self isCurrentUserGeneration:generation]) { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Abandoning an in app message fetch for a previous user"]; + return; + } + + OSUserRequestAuthorization *authorization = [self authorizationForFetchOrDefer:subscriptionId]; + if (!authorization) { + return; + } + NSNumber *sessionDuration = @([OSSessionManager.sharedSessionManager getTimeFocusedElapsed]); NSString *rywToken = rywData.rywToken; NSNumber *rywDelay = rywData.rywDelay; // Create the request with the current attempt count OSRequestGetInAppMessages *request = [OSRequestGetInAppMessages withSubscriptionId:subscriptionId + withAlias:authorization.alias + withUserHeaders:authorization.headers withSessionDuration:sessionDuration withRetryCount:attempts withRywToken:rywToken]; @@ -316,6 +476,10 @@ - (void)attemptFetchWithRetries:(NSString *)subscriptionId onSuccess:^(NSDictionary *result) { dispatch_async(dispatch_get_main_queue(), ^{ [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"getInAppMessagesFromServer success"]; + if (![self isCurrentUserGeneration:generation]) { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Discarding in app messages fetched for a previous user"]; + return; + } if (result[@"in_app_messages"]) { NSMutableArray *messages = [NSMutableArray new]; @@ -335,7 +499,12 @@ - (void)attemptFetchWithRetries:(NSString *)subscriptionId NSDictionary* responseHeaders = error.responseHeaders; [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"getInAppMessagesFromServer failure: %@", error.description]]; - + + // A retry, and the token this was signed with, both belong to the user it started for. + if (![self isCurrentUserGeneration:generation]) { + return; + } + if (error.code == 425 || error.code == 429) { // 425 Too Early or 429 Too Many Requests NSInteger retryAfter = [responseHeaders[@"Retry-After"] integerValue] ?: DEFAULT_RETRY_AFTER_SECONDS; @@ -350,11 +519,14 @@ - (void)attemptFetchWithRetries:(NSString *)subscriptionId subscriptionId:subscriptionId rywData:rywData attempts:@(nextAttempt) - retryLimit:blockRetryLimit]; + retryLimit:blockRetryLimit + userGeneration:generation]; } else { // Final attempt without rywToken - [self fetchInAppMessagesWithoutToken:subscriptionId]; + [self fetchInAppMessagesWithoutToken:subscriptionId userGeneration:generation]; } + } else if ([OSNetworkingUtils getResponseStatusType:error.code] == OSResponseStatusUnauthorized) { + [self handleUnauthorizedFetch:authorization subscriptionId:subscriptionId]; } else if (error.code >= 500 && error.code <= 599) { [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Server error, skipping retries"]; } @@ -365,21 +537,36 @@ - (void)retryAfterDelay:(NSInteger)retryAfter subscriptionId:(NSString *)subscriptionId rywData:(OSReadYourWriteData *)rywData attempts:(NSNumber *)attempts - retryLimit:(NSNumber *)retryLimit { + retryLimit:(NSNumber *)retryLimit + userGeneration:(NSUInteger)generation { dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(retryAfter * NSEC_PER_SEC)), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ [self attemptFetchWithRetries:subscriptionId rywData:rywData attempts:attempts - retryLimit:retryLimit]; + retryLimit:retryLimit + userGeneration:generation]; }); } -- (void)fetchInAppMessagesWithoutToken:(NSString *)subscriptionId { +- (void)fetchInAppMessagesWithoutToken:(NSString *)subscriptionId + userGeneration:(NSUInteger)generation { + if (![self isCurrentUserGeneration:generation]) { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Abandoning an in app message fetch for a previous user"]; + return; + } + + OSUserRequestAuthorization *authorization = [self authorizationForFetchOrDefer:subscriptionId]; + if (!authorization) { + return; + } + NSNumber *sessionDuration = @([OSSessionManager.sharedSessionManager getTimeFocusedElapsed]); OSRequestGetInAppMessages *request = [OSRequestGetInAppMessages withSubscriptionId:subscriptionId + withAlias:authorization.alias + withUserHeaders:authorization.headers withSessionDuration:sessionDuration withRetryCount:nil withRywToken:nil]; // No retries for the final attempt @@ -388,6 +575,10 @@ - (void)fetchInAppMessagesWithoutToken:(NSString *)subscriptionId { onSuccess:^(NSDictionary *result) { dispatch_async(dispatch_get_main_queue(), ^{ [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Final attempt without token success"]; + if (![self isCurrentUserGeneration:generation]) { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Discarding in app messages fetched for a previous user"]; + return; + } if (result[@"in_app_messages"]) { NSMutableArray *messages = [NSMutableArray new]; @@ -404,6 +595,12 @@ - (void)fetchInAppMessagesWithoutToken:(NSString *)subscriptionId { }); } onFailure:^(OneSignalClientError *error) { [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"getInAppMessagesFromServer failure: %@", error.description]]; + if (![self isCurrentUserGeneration:generation]) { + return; + } + if ([OSNetworkingUtils getResponseStatusType:error.code] == OSResponseStatusUnauthorized) { + [self handleUnauthorizedFetch:authorization subscriptionId:subscriptionId]; + } }]; } @@ -1237,11 +1434,9 @@ - (void)onPushSubscriptionDidChangeWithState:(OSPushSubscriptionChangedState * _ } - (void)onUserStateDidChangeWithState:(OSUserChangedState * _Nonnull)state { - if (state.current.onesignalId != nil && self.shouldFetchOnUserChangeWithSubscriptionID) { + if (state.current.onesignalId != nil) { [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"OSMessagingController onUserStateDidChangeWithState: changed to new valid onesignal id"]; - NSString *subscriptionID = self.shouldFetchOnUserChangeWithSubscriptionID; - self.shouldFetchOnUserChangeWithSubscriptionID = nil; - [self getInAppMessagesFromServer:subscriptionID]; + [self retryDeferredFetch]; } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.h b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.h index 047592e63..6be5af702 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.h +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.h @@ -28,8 +28,12 @@ #import #import "OSInAppMessageClickResult.h" +@class OSAliasPair; + @interface OSRequestGetInAppMessages : OneSignalRequest -+ (instancetype _Nonnull)withSubscriptionId:(NSString * _Nonnull)subscriptionId withSessionDuration:(NSNumber * _Nonnull)sessionDuration withRetryCount:(NSNumber *)retryCount withRywToken:(NSString *)rywToken; +/// A nil `alias` addresses the subscription on its own, which is how this was addressed before Identity +/// Verification; `userHeaders` carries the Bearer when the call is signed. ++ (instancetype _Nonnull)withSubscriptionId:(NSString * _Nonnull)subscriptionId withAlias:(OSAliasPair * _Nullable)alias withUserHeaders:(NSDictionary * _Nullable)userHeaders withSessionDuration:(NSNumber * _Nonnull)sessionDuration withRetryCount:(NSNumber *)retryCount withRywToken:(NSString *)rywToken; @end @interface OSRequestInAppMessageViewed : OneSignalRequest diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.m index 6cf041dae..574b9854b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.m @@ -35,13 +35,15 @@ - (NSString *)description { } + (instancetype _Nonnull) withSubscriptionId:(NSString * _Nonnull)subscriptionId + withAlias:(OSAliasPair * _Nullable)alias + withUserHeaders:(NSDictionary * _Nullable)userHeaders withSessionDuration:(NSNumber * _Nonnull)sessionDuration withRetryCount:(NSNumber *)retryCount withRywToken:(NSString *)rywToken { let request = [OSRequestGetInAppMessages new]; request.method = GET; - let headers = [NSMutableDictionary new]; + NSMutableDictionary *headers = userHeaders ? [userHeaders mutableCopy] : [NSMutableDictionary new]; if (sessionDuration != nil) { // convert to ms & round @@ -56,7 +58,17 @@ + (instancetype _Nonnull) withSubscriptionId:(NSString * _Nonnull)subscription request.additionalHeaders = headers; NSString *appId = OneSignalIdentifiers.currentAppId; - request.path = [NSString stringWithFormat:@"apps/%@/subscriptions/%@/iams", appId, subscriptionId]; + if (alias) { + // Encode so an app-chosen external_id cannot change which endpoint the path names. + NSString *encodedAliasId = [OSUrlPath segment:alias.id]; + if (!encodedAliasId) { + [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:@"OSRequestGetInAppMessages: cannot encode alias id for path"]; + return request; + } + request.path = [NSString stringWithFormat:@"apps/%@/users/by/%@/%@/subscriptions/%@/iams", appId, alias.label, encodedAliasId, subscriptionId]; + } else { + request.path = [NSString stringWithFormat:@"apps/%@/subscriptions/%@/iams", appId, subscriptionId]; + } return request; } @end diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IamFetchIdentityVerificationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IamFetchIdentityVerificationTests.swift new file mode 100644 index 000000000..52031a37a --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IamFetchIdentityVerificationTests.swift @@ -0,0 +1,309 @@ +/* + 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 +import OneSignalInAppMessagesMocks +@testable import OneSignalUser + +/** + How the in-app message fetch is addressed and signed. It does not travel through the Request queues, so + it makes the Identity Verification decision itself and holds the fetch until the answer arrives. + */ +final class IamFetchIdentityVerificationTests: XCTestCase { + private let appId = "test-app-id" + + private var client = MockOneSignalClient() + private var jwtListener = MockUserJwtInvalidatedListener() + + private var legacyPath: String { "apps/\(appId)/subscriptions/\(testPushSubId)/iams" } + private var anonymousUserPath: String { userPath(alias: OS_ONESIGNAL_ID, id: anonUserOSID) } + private var identifiedUserPath: String { userPath(alias: OS_EXTERNAL_ID, id: userA_EUID) } + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + ConsistencyManagerTestHelpers.reset() + OSMessagingController.removeInstance() + OneSignalIdentifiers.currentAppId = appId + + client = MockOneSignalClient() + MockUserRequests.setDefaultCreateAnonUserResponses(with: client) + MockUserRequests.setDefaultCreateUserResponses(with: client, externalId: userA_EUID) + OneSignalCoreImpl.setSharedClient(client) + for path in [legacyPath, anonymousUserPath, identifiedUserPath] { + respondToFetch(from: path) + } + + // Held strongly for the test's lifetime: the observable keeps listeners weakly. + jwtListener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(jwtListener) + + OSMessagingController.start() + } + + override func tearDownWithError() throws { + OneSignalUserManagerImpl.sharedInstance.removeUserJwtInvalidatedListener(jwtListener) + OSMessagingController.removeInstance() + OSFeatureManager.shared.setEnabledFeatureKeys([]) + OneSignalCoreMocks.clearUserDefaults() + } + + // MARK: - Setup helpers + + private func userPath(alias: String, id: String) -> String { + return "apps/\(appId)/users/by/\(alias)/\(id)/subscriptions/\(testPushSubId)/iams" + } + + private func respondToFetch(from path: String) { + client.setMockResponseForRequest( + request: "", + response: IAMTestHelpers.testFetchMessagesResponse(messages: [])) + } + + private func rejectFetch(from path: String) { + client.setMockFailureResponseForRequest( + request: "", + error: OneSignalClientError(code: 401, message: "unauthorized", responseHeaders: nil, response: nil, underlyingError: nil)) + } + + private func turnOnTheRolloutFlag() { + OSFeatureManager.shared.setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) + } + + /// An anonymous user with an `onesignal_id`, which the fetch needs before it does anything else. + private func startAnonymousUser() { + ConsistencyManagerTestHelpers.setDefaultRywToken(id: anonUserOSID) + OneSignalUserManagerImpl.sharedInstance.start() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + } + + private func login(token: String?) { + ConsistencyManagerTestHelpers.setDefaultRywToken(id: userA_OSID) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: token) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + } + + private func fetch() { + OneSignalInAppMessages.getFromServer(testPushSubId) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + } + + // MARK: - Assertion helpers + + private func executedFetches() -> [OneSignalRequest] { + return client.executedRequests.filter { $0 is OSRequestGetInAppMessages } + } + + private func startedFetches() -> [OneSignalRequest] { + return client.startedRequests.filter { $0 is OSRequestGetInAppMessages } + } + + private func lastFetchPath() -> String? { + return executedFetches().last?.path + } + + private func lastFetchAuthorization() -> String? { + return executedFetches().last?.additionalHeaders?["Authorization"] + } + + private func deferredSubscriptionId() -> String? { + return OSMessagingController.sharedInstance().deferredFetchSubscriptionId + } + + // MARK: - How the fetch is addressed + + func testTheFetchAddressesTheSubscriptionAloneWhileTheNewCodePathsAreOff() { + startAnonymousUser() + + fetch() + + XCTAssertEqual(lastFetchPath(), legacyPath) + XCTAssertNil(lastFetchAuthorization()) + } + + func testTheFetchAddressesTheOnesignalIdWhileIdentityVerificationIsOff() { + turnOnTheRolloutFlag() + startAnonymousUser() + + fetch() + + XCTAssertEqual(lastFetchPath(), anonymousUserPath) + XCTAssertNil(lastFetchAuthorization()) + } + + func testTheFetchAddressesTheExternalIdAndIsSignedUnderIdentityVerification() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + login(token: "token-a") + + fetch() + + XCTAssertEqual(lastFetchPath(), identifiedUserPath) + XCTAssertEqual(lastFetchAuthorization(), "Bearer token-a") + } + + // MARK: - Holding the fetch until Identity Verification answers + + /// An unsigned fetch on behalf of an app that turns out to require auth would be rejected, so a fetch + /// that runs before remote params answer waits for them — including when the rollout flag is off. + func testAFetchHeldForAnUnknownRequirementGoesOutOnHydration() { + startAnonymousUser() + let fetchesBefore = executedFetches().count + OSCoreMocks.resetSharedJwtConfig() + + fetch() + + XCTAssertEqual(executedFetches().count, fetchesBefore) + XCTAssertEqual(deferredSubscriptionId(), testPushSubId) + + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(lastFetchPath(), legacyPath) + XCTAssertNil(deferredSubscriptionId()) + } + + /// A user whose token was rejected has none until the app supplies another, and the fetch has to wait + /// rather than fall back to sending unsigned. + func testAFetchIsHeldRatherThanSentUnsignedWhenTheUserHasNoToken() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + login(token: "token-a") + OneSignalUserManagerImpl.sharedInstance.userJwtRepo.invalidateJwt(externalId: userA_EUID, rejectedToken: "token-a") + let fetchesBefore = executedFetches().count + + fetch() + + XCTAssertEqual(executedFetches().count, fetchesBefore) + XCTAssertEqual(deferredSubscriptionId(), testPushSubId) + + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userA_EUID, token: "token-b") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(lastFetchPath(), identifiedUserPath) + XCTAssertEqual(lastFetchAuthorization(), "Bearer token-b") + } + + // MARK: - A rejected fetch + + /// The fetch is not a source of truth for whether a token is good, because the server can refuse it + /// over a user and subscription it does not have paired. It parks, and the next token releases it. + func testARejectedFetchParksWithoutReportingTheTokenItUsed() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + login(token: "token-a") + rejectFetch(from: identifiedUserPath) + let fetchesBefore = executedFetches().count + + fetch() + + XCTAssertEqual(executedFetches().count, fetchesBefore + 1) + XCTAssertEqual(jwtListener.invalidatedExternalIds, []) + XCTAssertEqual(deferredSubscriptionId(), testPushSubId) + + // A token the app supplies for its own reasons, since the fetch never asked for one. + respondToFetch(from: identifiedUserPath) + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userA_EUID, token: "token-b") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(executedFetches().count, fetchesBefore + 2) + XCTAssertEqual(lastFetchAuthorization(), "Bearer token-b") + } + + /// A replacement that lands while the fetch is in flight is not parked: `OS_ON_USER_JWT_UPDATED` + /// already fired, and waiting for another would leave the fetch stuck. + func testARejectedFetchRefetchesWhenAReplacementTokenAlreadyLanded() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + login(token: "token-a") + rejectFetch(from: identifiedUserPath) + let startedBefore = startedFetches().count + let executedBefore = executedFetches().count + + client.holdResponses = true + OneSignalInAppMessages.getFromServer(testPushSubId) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(startedFetches().count, startedBefore + 1) + XCTAssertEqual(executedFetches().count, executedBefore) + + client.holdResponses = false + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userA_EUID, token: "token-b") + client.releaseHeldResponses() + respondToFetch(from: identifiedUserPath) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(executedFetches().count, executedBefore + 2) + XCTAssertEqual(lastFetchAuthorization(), "Bearer token-b") + XCTAssertNil(deferredSubscriptionId()) + XCTAssertEqual(jwtListener.invalidatedExternalIds, []) + } + + /// The token the fetch was signed with stays usable, so everything else keeps going out signed. + func testARejectedFetchLeavesTheTokenInPlaceForTheRequests() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + login(token: "token-a") + rejectFetch(from: identifiedUserPath) + + fetch() + + XCTAssertEqual(lastFetchAuthorization(), "Bearer token-a") + XCTAssertEqual(OneSignalUserManagerImpl.sharedInstance.user.identityModel.jwtBearerToken, "token-a") + } + + /// With the gate off a 401 stays as it was: logged and dropped, with no reattempt left pending. + func testARejectedFetchIsLeftAloneWhileTheNewCodePathsAreOff() { + startAnonymousUser() + rejectFetch(from: legacyPath) + let fetchesBefore = executedFetches().count + + fetch() + + XCTAssertEqual(executedFetches().count, fetchesBefore + 1) + XCTAssertNil(deferredSubscriptionId()) + } + + /// Under Identity Verification the alias id is an app-chosen external_id, so it has to be encoded. + func testTheFetchPathPercentEncodesTheAliasId() { + OneSignalIdentifiers.currentAppId = appId + let externalId = "us er/a?b#c%d" + let request = OSRequestGetInAppMessages.withSubscriptionId( + testPushSubId, + withAlias: OSAliasPair(OS_EXTERNAL_ID, externalId), + withUserHeaders: nil, + withSessionDuration: 0, + withRetryCount: 0, + withRywToken: nil + ) + + XCTAssertEqual( + request.path, + "apps/\(appId)/users/by/\(OS_EXTERNAL_ID)/us%20er%2Fa%3Fb%23c%25d/subscriptions/\(testPushSubId)/iams" + ) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift index 9c73924d6..54155d657 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift @@ -59,6 +59,7 @@ final class OSMessagingControllerUserStateTests: XCTestCase { } override func tearDownWithError() throws { + OSFeatureManager.shared.setEnabledFeatureKeys([]) OSMessagingController.removeInstance() } @@ -71,7 +72,7 @@ final class OSMessagingControllerUserStateTests: XCTestCase { - IAM fetch should be deferred Expected: - - shouldFetchOnUserChangeWithSubscriptionID property is set with the subscription ID + - deferredFetchSubscriptionId property is set with the subscription ID - No IAM fetch actually occurs */ func testStoresSubscriptionIDWhenOneSignalIDUnavailable() throws { @@ -87,15 +88,14 @@ final class OSMessagingControllerUserStateTests: XCTestCase { /* Verify */ // The controller should have stored the subscription ID for retry - let shouldFetchOnUserChangeWithSubscriptionID = OSMessagingController.sharedInstance().value(forKey: "shouldFetchOnUserChangeWithSubscriptionID") - XCTAssertEqual(shouldFetchOnUserChangeWithSubscriptionID as! String, testSubscriptionId) + XCTAssertEqual(OSMessagingController.sharedInstance().deferredFetchSubscriptionId, testSubscriptionId) // Verify no IAM request was actually made (since we don't have OneSignal ID) XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 0)) } /** - Test that when user state changes with a valid OneSignal ID and shouldFetchOnUserChangeWithSubscriptionID is set, it retries the fetch. + Test that when user state changes with a valid OneSignal ID and deferredFetchSubscriptionId is set, it retries the fetch. Scenario: - IAM fetch was previously deferred due to missing OneSignal ID @@ -104,7 +104,7 @@ final class OSMessagingControllerUserStateTests: XCTestCase { Expected: - IAM fetch is retried with the stored subscription ID - - shouldFetchOnUserChangeWithSubscriptionID is cleared + - deferredFetchSubscriptionId is cleared */ func testRetriesFetchWhenUserStateChangesWithValidOneSignalID() throws { /* Setup */ @@ -129,7 +129,7 @@ final class OSMessagingControllerUserStateTests: XCTestCase { OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) // Verify the subscription ID was stored and no IAM fetch occurred - XCTAssertEqual(controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID") as! String, testSubscriptionId) + XCTAssertEqual(controller.deferredFetchSubscriptionId, testSubscriptionId) XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 0)) // Now let the login succeed, receive onesignal ID which fires user state observer @@ -143,22 +143,24 @@ final class OSMessagingControllerUserStateTests: XCTestCase { XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1)) // The stored subscription ID should be cleared after successful retry - XCTAssertNil(controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID")) + XCTAssertNil(controller.deferredFetchSubscriptionId) } /** - Test that when user state changes but shouldFetchOnUserChangeWithSubscriptionID is not set, it does nothing. - + Test that logging in refetches in-app messages for the user signing in. + Scenario: - - Normal user state change occurs - - No deferred fetch was pending - + - Identity Verification code paths are on + - A user's in-app messages have already been fetched + - The app logs in as someone else + Expected: - - No retry logic is triggered - - Normal operation continues + - A second fetch goes out once the new user has a OneSignal ID, so the previous user's messages are + not what gets evaluated */ - func testDoesNothingWhenNoRetryPending() throws { + func testLoginRefetchesInAppMessagesForTheIncomingUser() throws { /* Setup */ + OSFeatureManager.shared.setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) let client = MockOneSignalClient() OneSignalCoreImpl.setSharedClient(client) OneSignalInAppMessages.start() @@ -177,16 +179,41 @@ final class OSMessagingControllerUserStateTests: XCTestCase { /* Verify */ // IAM is fetched and no retry is pending XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1)) - XCTAssertNil(controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID")) + XCTAssertNil(controller.deferredFetchSubscriptionId) /* Execute */ - // Trigger a normal user state change by login MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: testExternalId) OneSignalUserManagerImpl.sharedInstance.login(externalId: testExternalId, token: nil) OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) /* Verify */ - // Does not fetch IAMs again + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 2)) + XCTAssertNil(controller.deferredFetchSubscriptionId) + } + + /// Without the rollout flag or `jwt_required`, a login leaves the current user's messages as they were. + func testLoginDoesNotRefetchInAppMessagesWhenIdentityVerificationCodePathsAreOff() throws { + let client = MockOneSignalClient() + OneSignalCoreImpl.setSharedClient(client) + OneSignalInAppMessages.start() + let controller = OSMessagingController.sharedInstance() + + MockUserRequests.setDefaultCreateAnonUserResponses( + with: client, + onesignalId: testOneSignalId, + subscriptionId: testSubscriptionId + ) + ConsistencyManagerTestHelpers.setDefaultRywToken(id: testOneSignalId) + OneSignalUserManagerImpl.sharedInstance.start() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1)) + + MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: testExternalId) + OneSignalUserManagerImpl.sharedInstance.login(externalId: testExternalId, token: nil) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1)) + XCTAssertNil(controller.deferredFetchSubscriptionId) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h index 813316635..8f671baae 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h @@ -14,6 +14,7 @@ @property (strong, nonatomic, nonnull) NSMutableDictionary *redisplayedInAppMessages; @property (strong, nonatomic, nonnull) NSMutableArray *messages; @property (strong, nonatomic, nonnull) OSTriggerController *triggerController; +@property (strong, nonatomic, nullable) NSString *deferredFetchSubscriptionId; + (void)start; + (void)removeInstance; - (void)presentInAppPreviewMessage:(OSInAppMessageInternal *)message; diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift index 859353d90..5a424932f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift @@ -63,6 +63,7 @@ extension OneSignalUserManagerImpl { } operationRepo.addFlushDeltaQueueToDispatchQueue() userExecutor?.executePendingRequests() + NotificationCenter.default.post(name: Notification.Name(OS_ON_USER_JWT_UPDATED), object: nil) } /** @@ -107,4 +108,28 @@ extension OneSignalUserManagerImpl { storeJwt(externalId: externalId, token: token) } + + /// Rollout flag, or always when the app requires Identity Verification. + @objc public var newCodePathsRun: Bool { + identityVerificationService.newCodePathsRun + } + + /** + How another module should address and sign a user-scoped call for the current user, decided in one + read so the alias and the token cannot come from different users. + + Returns nil when the call cannot be sent yet — the requirement is still unknown, nobody is logged in + under Identity Verification, or the app owes a token, which this asks for. Callers reattempt when + `OS_ON_JWT_CONFIG_HYDRATED` or `OS_ON_USER_JWT_UPDATED` is posted. + */ + @objc + public func authorizationForCurrentUser() -> OSUserRequestAuthorization? { + // `_user` rather than `user`, which would create a guest user for a caller that only reads. + guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil), + let identityModel = _user?.identityModel + else { + return nil + } + return requestAuth.authorization(onesignalId: identityModel.onesignalId, externalId: identityModel.externalId) + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 1cf2695f3..222137988 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -296,6 +296,9 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { app's own opt-in, which would make the silencing permanent. */ identityVerificationService.addOnJwtConfigHydratedHandler(for: .userManager) { [weak self] requirement in + // Tells work that does not travel through the Repo, such as the in-app message fetch, + // that how to address a user-scoped call is now decided. + NotificationCenter.default.post(name: Notification.Name(OS_ON_JWT_CONFIG_HYDRATED), object: nil) guard requirement == .off else { return }