Skip to content

Commit

Permalink
store alias update requests that need auth
Browse files Browse the repository at this point in the history
When a requests fails with a 401 due to JWT or fails when preparing for execution we remove the request from the request queue and add it to the pending dictionary.

Once we get the onJWTUpdated callback for that externalId we requeue the pending requests and try again.
  • Loading branch information
emawby committed Sep 11, 2024
1 parent aca978e commit 1ae0deb
Show file tree
Hide file tree
Showing 2 changed files with 171 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

0 comments on commit 1ae0deb

Please sign in to comment.