diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift index bd9a5c86d..78d119495 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift @@ -34,6 +34,7 @@ class OSIdentityOperationExecutor: OSOperationExecutor { // To simplify uncaching, we maintain separate request queues for each type var addRequestQueue: [OSRequestAddAliases] = [] var removeRequestQueue: [OSRequestRemoveAlias] = [] + var pendingAuthRequests: [String: [OSUserRequest]] = [String:[OSUserRequest]]() let newRecordsState: OSNewRecordsState let jwtConfig: OSUserJwtConfig @@ -228,16 +229,38 @@ class OSIdentityOperationExecutor: OSOperationExecutor { } } - func handleUnauthorizedError(externalId: String, error: NSError) { + func handleUnauthorizedError(externalId: String, error: NSError, request: OSUserRequest) { if (jwtConfig.isRequired ?? false) { + self.pendRequestUntilAuthUpdated(request, externalId: externalId) OneSignalUserManagerImpl.sharedInstance.invalidateJwtForExternalId(externalId: externalId, error: error) } } + + func pendRequestUntilAuthUpdated(_ request: OSUserRequest, externalId: String?) { + self.dispatchQueue.async { + self.addRequestQueue.removeAll(where: { $0 == request}) + self.removeRequestQueue.removeAll(where: { $0 == request}) + guard let externalId = externalId else { + return + } + var requests = self.pendingAuthRequests[externalId] ?? [] + let inQueue = requests.contains(where: {$0 == request}) + guard !inQueue else { + return + } + requests.append(request) + self.pendingAuthRequests[externalId] = requests + } + } func executeAddAliasesRequest(_ request: OSRequestAddAliases, inBackground: Bool) { guard !request.sentToClient else { return } + guard request.addJWTHeaderIsValid(identityModel: request.identityModel) else { + pendRequestUntilAuthUpdated(request, externalId:request.identityModel.externalId) + return + } guard request.prepareForExecution(newRecordsState: newRecordsState) else { return } @@ -281,7 +304,7 @@ class OSIdentityOperationExecutor: OSOperationExecutor { OneSignalUserManagerImpl.sharedInstance._logout() } else if responseType == .unauthorized && (self.jwtConfig.isRequired ?? false) { if let externalId = request.identityModel.externalId { - self.handleUnauthorizedError(externalId: externalId, error: nsError) + self.handleUnauthorizedError(externalId: externalId, error: nsError, request: request) } request.sentToClient = false } else if responseType != .retryable { @@ -301,6 +324,10 @@ class OSIdentityOperationExecutor: OSOperationExecutor { guard !request.sentToClient else { return } + guard request.addJWTHeaderIsValid(identityModel: request.identityModel) else { + pendRequestUntilAuthUpdated(request, externalId:request.identityModel.externalId) + return + } guard request.prepareForExecution(newRecordsState: newRecordsState) else { return } @@ -330,7 +357,7 @@ class OSIdentityOperationExecutor: OSOperationExecutor { let responseType = OSNetworkingUtils.getResponseStatusType(nsError.code) if responseType == .unauthorized && (self.jwtConfig.isRequired ?? false) { if let externalId = request.identityModel.externalId { - self.handleUnauthorizedError(externalId: externalId, error: nsError) + self.handleUnauthorizedError(externalId: externalId, error: nsError, request: request) } request.sentToClient = false } @@ -360,6 +387,26 @@ extension OSIdentityOperationExecutor: OSUserJwtConfigListener { func onJwtUpdated(externalId: String, token: String?) { print("❌ OSIdentityOperationExecutor onJwtUpdated for \(externalId) to \(String(describing: token))") + reQueuePendingRequestsForExternalId(externalId: externalId) + } + + private func reQueuePendingRequestsForExternalId(externalId: String) { + self.dispatchQueue.async { + guard let requests = self.pendingAuthRequests[externalId] else { + return + } + for request in requests { + if let addRequest = request as? OSRequestAddAliases { + self.addRequestQueue.append(addRequest) + } else if let removeRequest = request as? OSRequestRemoveAlias { + self.removeRequestQueue.append(removeRequest) + } + } + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: self.removeRequestQueue) + self.pendingAuthRequests[externalId] = nil + self.processRequestQueue(inBackground: false) + } } private func removeInvalidDeltasAndRequests() { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/IdentityExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/IdentityExecutorTests.swift index e50af82b4..545df66c5 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/IdentityExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/IdentityExecutorTests.swift @@ -165,5 +165,126 @@ final class IdentityExecutorTests: XCTestCase { XCTAssertTrue(invalidatedCallbackWasCalled) } + func testAddAliasRequests_Retry_OnTokenUpdate() { + + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(true) + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + + let user = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + user.identityModel.jwtBearerToken = userA_InvalidJwtToken + + // We need to use the user manager's executor because the onJWTUpdated callback won't fire on the mock executor + let executor = OneSignalUserManagerImpl.sharedInstance.identityExecutor! + + let aliases = userA_Aliases + MockUserRequests.setUnauthorizedAddAliasFailureResponse(with: mocks.client, aliases: userA_Aliases) + executor.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: user.identityModel.modelId, model: user.identityModel, property: "aliases", value:aliases)) + + var invalidatedCallbackWasCalled = false + OneSignalUserManagerImpl.sharedInstance.User.onJwtInvalidated { event in + invalidatedCallbackWasCalled = true + MockUserRequests.setAddAliasesResponse(with: mocks.client, aliases: aliases) + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userA_EUID, token: userA_ValidJwtToken) + } + + /* When */ + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestAddAliases.self)) + XCTAssertTrue(invalidatedCallbackWasCalled) + XCTAssertEqual(mocks.client.networkRequestCount, 2) + } + + func testAddAliasRequests_RetryRequests_OnTokenUpdate_ForOnlyUpdatedUser() { + /* Setup */ + let mocks = Mocks() + + mocks.setAuthRequired(true) + + let userA = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + userA.identityModel.jwtBearerToken = userA_InvalidJwtToken + + let userB = mocks.setUserManagerInternalUser(externalId: userB_EUID, onesignalId: userB_OSID) + userB.identityModel.jwtBearerToken = userA_InvalidJwtToken + // We need to use the user manager's executor because the onJWTUpdated callback won't fire on the mock executor + let executor = OneSignalUserManagerImpl.sharedInstance.identityExecutor! + + let aliases = userA_Aliases + MockUserRequests.setUnauthorizedAddAliasFailureResponse(with: mocks.client, aliases: userA_Aliases) + + executor.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: userA.identityModel.modelId, model: userA.identityModel, property: "aliases", value:aliases)) + executor.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: userB.identityModel.modelId, model: userB.identityModel, property: "aliases", value:aliases)) + + var invalidatedCallbackWasCalled = false + OneSignalUserManagerImpl.sharedInstance.User.onJwtInvalidated { event in + invalidatedCallbackWasCalled = true + } + + /* When */ + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + MockUserRequests.setAddAliasesResponse(with: mocks.client, aliases: aliases) + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userB_EUID, token: userB_ValidJwtToken) + + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + // The executor should execute this request since identity verification is required and the token was set + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestAddAliases.self)) + XCTAssertTrue(invalidatedCallbackWasCalled) + let addAliasRequests = mocks.client.executedRequests.filter { request in + request.isKind(of: OSRequestAddAliases.self) + } + // It is 4 because setting user B's OneSignal ID counts as an add alias request + XCTAssertEqual(addAliasRequests.count, 4) + } + func testRemoveAliasRequests_RetryRequests_OnTokenUpdate_ForOnlyUpdatedUser() { + /* Setup */ + let mocks = Mocks() + + mocks.setAuthRequired(true) + + let userA = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + userA.identityModel.jwtBearerToken = userA_InvalidJwtToken + + let userB = mocks.setUserManagerInternalUser(externalId: userB_EUID, onesignalId: userB_OSID) + userB.identityModel.jwtBearerToken = userA_InvalidJwtToken + // We need to use the user manager's executor because the onJWTUpdated callback won't fire on the mock executor + let executor = OneSignalUserManagerImpl.sharedInstance.identityExecutor! + + let aliases = userA_Aliases + MockUserRequests.setUnauthorizedRemoveAliasFailureResponse(with: mocks.client, aliasLabel: userA_AliasLabel) + + executor.enqueueDelta(OSDelta(name: OS_REMOVE_ALIAS_DELTA, identityModelId: userA.identityModel.modelId, model: userA.identityModel, property: "aliases", value:aliases)) + executor.enqueueDelta(OSDelta(name: OS_REMOVE_ALIAS_DELTA, identityModelId: userB.identityModel.modelId, model: userB.identityModel, property: "aliases", value:aliases)) + + var invalidatedCallbackWasCalled = false + OneSignalUserManagerImpl.sharedInstance.User.onJwtInvalidated { event in + invalidatedCallbackWasCalled = true + } + + /* When */ + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userB_EUID, token: userB_ValidJwtToken) + + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + // The executor should execute this request since identity verification is required and the token was set + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestRemoveAlias.self)) + XCTAssertTrue(invalidatedCallbackWasCalled) + let removeAliasRequests = mocks.client.executedRequests.filter { request in + request.isKind(of: OSRequestRemoveAlias.self) + } + + XCTAssertEqual(removeAliasRequests.count, 3) + } }