diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 9c98cfc51..67a75e20f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -361,6 +361,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 */; }; + 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 */; }; 911E2CBD1E398AB3003112A4 /* UnitTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 911E2CBC1E398AB3003112A4 /* UnitTests.m */; }; @@ -1595,6 +1596,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 = ""; }; + 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 = ""; }; 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtRepo.swift; sourceTree = ""; }; @@ -2533,6 +2535,7 @@ 047D8F5E1095A20C9C54FD33 /* OSRequestAuthTests.swift */, 3016921C1F6B7B7793F67567 /* RequestPathEncodingTests.swift */, 5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */, + 3567F486AA4335CCA79D659B /* UserJwtAskTests.swift */, ); path = OneSignalUserTests; sourceTree = ""; @@ -4722,6 +4725,7 @@ AAFA2D46E6C5FD3D14D39F27 /* OSRequestAuthTests.swift in Sources */, 23D66BEB40CE76DFF89744A3 /* RequestPathEncodingTests.swift in Sources */, 7EB69F3B404D0AEF46EC1536 /* UserJwtLifecycleTests.swift in Sources */, + 9345FA2E8878EA1B0216A49A /* UserJwtAskTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift index c4ad37f66..c50b75a88 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift @@ -81,15 +81,19 @@ public final class OSUserJwtConfig: NSObject { @objc public func hydrate(requiresUserAuth: Bool) { let hydrated: OSRequiresUserAuth = requiresUserAuth ? .on : .off - // Keep the log and the handler out of the lock; either can re-enter and read the requirement. + // Only the value changes under the lock. The write flushes to disk, and every reader of + // `requirement` (the repo queue, IAM on main, the executors) would wait on it; the log and the + // handler can re-enter and read the requirement. let (previous, handler) = lock.withLock { () -> (OSRequiresUserAuth, ((OSRequiresUserAuth) -> Void)?) in let previous = _requirement _requirement = hydrated - // Written even when the value is unchanged, so a launch whose write was dropped by locked - // storage still ends up with the requirement on disk. - OneSignalUserDefaults.initShared().saveInteger(forKey: OSUD_USE_IDENTITY_VERIFICATION, withValue: hydrated.rawValue) return (previous, onHydrated) } + // Written even when the value is unchanged, so a launch whose write was dropped by locked + // storage still ends up with the requirement on disk. Two hydrates racing with different values + // could leave disk holding the older one while memory holds the newer; `hydrate` has one guarded + // call site per session, and the next session's params fetch heals a stale cache. + OneSignalUserDefaults.initShared().saveInteger(forKey: OSUD_USE_IDENTITY_VERIFICATION, withValue: hydrated.rawValue) if previous != hydrated { OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSUserJwtConfig requirement changed from \(previous) to \(hydrated)") } @@ -100,15 +104,27 @@ public final class OSUserJwtConfig: NSObject { /** Re-reads the cached requirement while it is still unknown. The read in `init` can land during an - app prewarm, before first unlock, when UserDefaults silently returns nothing. + app prewarm, before first unlock, when UserDefaults silently returns nothing. Fires the hydrated + handler when the value moves off `unknown`, the same transition `hydrate` reports, so a caller + registered before this runs is told the same way. */ public func refreshIfUnknown() { - lock.withLock { + let (refreshed, handler) = lock.withLock { () -> (OSRequiresUserAuth?, ((OSRequiresUserAuth) -> Void)?) in guard _requirement == .unknown else { - return + return (nil, nil) } - _requirement = OSUserJwtConfig.cachedRequirement() + let cached = OSUserJwtConfig.cachedRequirement() + guard cached != .unknown else { + return (nil, nil) + } + _requirement = cached + return (cached, onHydrated) + } + guard let refreshed else { + return } + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSUserJwtConfig requirement refreshed from the cache to \(refreshed)") + handler?(refreshed) } /// The Identity Verification service is the sole observer, so a second registration replaces the first. diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSUserJwtConfigTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSUserJwtConfigTests.swift index 1d536c65a..ba1107850 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSUserJwtConfigTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSUserJwtConfigTests.swift @@ -72,6 +72,29 @@ final class OSUserJwtConfigTests: XCTestCase { XCTAssertEqual(jwtConfig.requirement, .on) } + /// The same transition `hydrate` reports, so a handler registered before the refresh hears it too. + func testRefreshFiresTheHydratedHandlerWhenItAdoptsACachedRequirement() { + let jwtConfig = OSUserJwtConfig() + var reported: [OSRequiresUserAuth] = [] + jwtConfig.setOnHydratedHandler { reported.append($0) } + cacheRequirement(.on) + + jwtConfig.refreshIfUnknown() + + XCTAssertEqual(reported, [.on]) + } + + func testRefreshFiresNothingWhileTheCacheIsStillEmpty() { + let jwtConfig = OSUserJwtConfig() + var reported: [OSRequiresUserAuth] = [] + jwtConfig.setOnHydratedHandler { reported.append($0) } + + jwtConfig.refreshIfUnknown() + + XCTAssertEqual(jwtConfig.requirement, .unknown) + XCTAssertTrue(reported.isEmpty) + } + func testRefreshLeavesAKnownRequirementAlone() { let jwtConfig = OSUserJwtConfig() jwtConfig.hydrate(requiresUserAuth: false) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift index 66c6450bb..720d53aae 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift @@ -65,8 +65,9 @@ class OSUserExecutor { `login` that promoted an anonymous user while the requirement was still unknown — becomes the Create User that login would have made, or is dropped if a later `login` has superseded it. - Runs on every send because `refreshIfUnknown` can raise `requirement` with no event; reads the live - model because this executor sends nothing while `requirement` is unknown. + 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. */ private func reshapeInvalidRequests() { guard identityVerificationService.ivBehaviorActive else { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift index 5678d9747..7aba9299c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift @@ -176,8 +176,9 @@ final class OSRequestAuth: OSRequestAuthorizing { /** Nothing else prompts the app when a Request merely parks: the invalidated event fires on a rejected - token, and a token the app never supplied — or supplied in a session that has since ended — leaves the - SDK holding none with nothing to reject. The repo keeps this to one ask per external ID per session. + token, and a token the app never supplied, or supplied in a session that has since ended, leaves the + SDK holding none with nothing to reject. The repo keeps this to one ask per external ID until a token + is stored or a `login` as that user rearms it. */ private func park(_ request: OSUserRequest, ownedBy externalId: String) { // Log only on the ask that reaches the app; later prepareForExecution retries stay quiet. diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtRepo.swift index 8adf8f164..db22f24ac 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtRepo.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtRepo.swift @@ -39,8 +39,9 @@ protocol OSUserJwtProviding: AnyObject { /** Asks the app for a token for `externalId`. - Returns `true` if this call is the one that asked, which happens at most once per external ID - per session so a burst of concurrent callers does not fire the event repeatedly. + Returns `true` if this call is the one that asked. An external ID is asked once, until a usable token + is stored for it or a `login` builds it a new Identity Model, so a burst of concurrent callers does + not fire the event repeatedly. */ @discardableResult func askForToken(externalId: String) -> Bool @@ -104,11 +105,20 @@ final class OSUserJwtRepo: OSUserJwtProviding { return true } - /// External IDs already asked this session; cleared when a usable token is stored. + /// External IDs asked and not yet answered. A stored token or a `login` as that user clears the entry. func pendingTokenAsks() -> [String] { return lock.withLock { Array(askedForToken) } } + /** + Forgets an ask so a later park can ask again. A `login` that builds a new Identity Model for this + external ID is a fresh chance to hear about the token it owes; without this, an ask left unanswered + before a logout silenced every later token-less login as the same user for the rest of the session. + */ + func clearAsk(externalId: String) { + lock.withLock { _ = askedForToken.remove(externalId) } + } + @discardableResult func invalidateJwt(externalId: String, rejectedToken: String) -> Bool { // No model for this user means the token could not have come from here. A Request stamped diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift index 5a424932f..569a3febc 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift @@ -28,6 +28,44 @@ import OneSignalCore import OneSignalOSCore +/** + The app's JWT listeners, and whether one was ever added. An ask nobody hears is replayed when a listener + is added, so before the first registration it is the cold-start order and logs at debug; after one, the + app has let its listener go (they are held weakly) and only the warning tells it. + */ +final class OSUserJwtInvalidatedListeners { + private let observer = OSObservable( + change: #selector(OSUserJwtInvalidatedListener.onUserJwtInvalidated(event:)) + ) + private let lock = NSLock() + private var everRegistered = false + + func add(_ listener: OSUserJwtInvalidatedListener) { + // Observer first, so an ask that lands between the two is heard rather than logged as a lost listener. + observer.addObserver(listener) + lock.withLock { everRegistered = true } + } + + func remove(_ listener: OSUserJwtInvalidatedListener) { + observer.removeObserver(listener) + } + + func notify(externalId: String) { + // Read first: a listener added during delivery is told by the replay, not by this warning. + let hadListener = lock.withLock { everRegistered } + if observer.notifyChange(OSUserJwtInvalidatedEvent(externalId: externalId)) { + return + } + let unheard = "asked for a JWT for externalId \(externalId)" + let replayed = "the ask is replayed when a listener is added" + if hadListener { + OneSignalLog.onesignalLog(.LL_WARN, message: "\(unheard) but the OSUserJwtInvalidatedListener registered earlier is gone; \(replayed)") + } else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "\(unheard) before any OSUserJwtInvalidatedListener was registered; \(replayed)") + } + } +} + /** The Identity Verification surface the app talks to: it hands the SDK a token for a user, and the SDK tells it when that token stopped being accepted. @@ -51,28 +89,40 @@ extension OneSignalUserManagerImpl { notification — the work that travels through neither. Every app-supplied token arrives here, from `login` as well as `updateUserJwt`, so that the pending - ask for this user is cleared and a later rejection can ask again. + ask for this user is cleared and a later rejection can ask again. Returns whether the token was + stored; the repo refuses an unusable one. */ - func storeJwt(externalId: String, token: String) { + @discardableResult + func storeJwt(externalId: String, token: String) -> Bool { guard userJwtRepo.updateJwt(externalId: externalId, token: token) else { - return + return false } OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignalUserManager stored a JWT for externalId: \(externalId)") guard identityVerificationService.newCodePathsRun else { - return + return true } operationRepo.addFlushDeltaQueueToDispatchQueue() userExecutor?.executePendingRequests() NotificationCenter.default.post(name: Notification.Name(OS_ON_USER_JWT_UPDATED), object: nil) + return true + } + + /// `storeJwt` clears the ask itself when it stores; a login with no usable token rearms it instead. + func storeJwtOrRearmAsk(externalId: String, token: String?) { + if let token = token, storeJwt(externalId: externalId, token: token) { + return + } + userJwtRepo.clearAsk(externalId: externalId) } /** Replays any ask that already fired this session, so a listener registered after `start` or `login` - still hears who currently owes a token. + still hears who currently owes a token. The listener is held weakly, so the app has to keep its own + reference to it. */ @objc public func addUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { - self.userJwtInvalidatedObserver.addObserver(listener) + self.userJwtInvalidatedListeners.add(listener) let pending = userJwtRepo.pendingTokenAsks() guard !pending.isEmpty else { return @@ -91,7 +141,7 @@ extension OneSignalUserManagerImpl { @objc public func removeUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { - self.userJwtInvalidatedObserver.removeObserver(listener) + self.userJwtInvalidatedListeners.remove(listener) } @objc diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index dc1a55db5..d0b1a55a9 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -180,17 +180,10 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { return userStateChangesObserver } - // JWT Invalidated Observer - private var _userJwtInvalidatedObserver: OSObservable? - var userJwtInvalidatedObserver: OSObservable { - if let observer = _userJwtInvalidatedObserver { - return observer - } - let userJwtInvalidatedObserver = OSObservable(change: #selector(OSUserJwtInvalidatedListener.onUserJwtInvalidated(event:))) - _userJwtInvalidatedObserver = userJwtInvalidatedObserver - - return userJwtInvalidatedObserver - } + // The app's JWT listeners. Built in `init` rather than lazily: the ask path reaches them from the + // executor queues while the app registers from its own thread, and a lazy getter with no lock could + // build two and discard the one holding the app's listener. + let userJwtInvalidatedListeners: OSUserJwtInvalidatedListeners // Model Stores let identityModelStore = OSModelStore(changeSubscription: OSEventProducer(), storeKey: OS_IDENTITY_MODEL_STORE_KEY).registerAsUserObserver() @@ -219,12 +212,13 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { private override init() { let identityVerificationService = OSIdentityVerificationService(featureManager: featureManager, jwtConfig: jwtConfig) let operationRepo = OSOperationRepo(identityVerificationService: identityVerificationService) - // Goes through `sharedInstance` rather than capturing self: the observer it notifies is created - // lazily and must not be touched during init. + let userJwtInvalidatedListeners = OSUserJwtInvalidatedListeners() + // Captures the listeners, not self, so the closure is safe to build before init completes. let userJwtRepo = OSUserJwtRepo(identityModelRepo: identityModelRepo) { externalId in - OneSignalUserManagerImpl.sharedInstance.userJwtInvalidatedObserver.notifyChange(OSUserJwtInvalidatedEvent(externalId: externalId)) + userJwtInvalidatedListeners.notify(externalId: externalId) } self.identityVerificationService = identityVerificationService + self.userJwtInvalidatedListeners = userJwtInvalidatedListeners self.userJwtRepo = userJwtRepo self.requestAuth = OSRequestAuth(identityVerificationService: identityVerificationService, jwt: userJwtRepo) self.operationRepo = operationRepo @@ -447,8 +441,8 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } let newUser = setNewInternalUser(externalId: externalId, pushSubscriptionModel: pushSubscriptionModel) - if let externalId = externalId, let token = token { - storeJwt(externalId: externalId, token: token) + if let externalId = externalId { + storeJwtOrRearmAsk(externalId: externalId, token: token) } userExecutor!.createUser(newUser) return newUser @@ -475,9 +469,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { let newUser = setNewInternalUser(externalId: externalId, pushSubscriptionModel: pushSubscriptionModel) // The token belongs on the model that carries `external_id`: the Fetch User this leads to is signed // with it, as is the Create User this becomes if the requirement turns out to be on. - if let token = token { - storeJwt(externalId: externalId, token: token) - } + storeJwtOrRearmAsk(externalId: externalId, token: token) // Now proceed to identify the previous user userExecutor!.identifyUser( diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSUserJwtRepoTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSUserJwtRepoTests.swift index dc492a9f4..7ab82f7c4 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSUserJwtRepoTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSUserJwtRepoTests.swift @@ -84,6 +84,18 @@ final class OSUserJwtRepoTests: XCTestCase { XCTAssertEqual(asked, ["user-a"]) } + /// A login that builds a new Identity Model for the user is a fresh chance to be asked. + func testClearingAnAskLetsTheUserBeAskedAgain() { + XCTAssertTrue(repo.askForToken(externalId: "user-a")) + XCTAssertFalse(repo.askForToken(externalId: "user-a")) + + repo.clearAsk(externalId: "user-a") + + XCTAssertTrue(repo.askForToken(externalId: "user-a")) + XCTAssertEqual(asked, ["user-a", "user-a"]) + XCTAssertEqual(repo.pendingTokenAsks(), ["user-a"]) + } + // MARK: - invalidateJwt func testInvalidatingParksTheTokenAndAsksTheApp() { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift new file mode 100644 index 000000000..e0f73016a --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift @@ -0,0 +1,167 @@ +/* + 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 + +/// Listeners hear every line logged in the process, on the thread that logged it, so a test reads back +/// only the level and text it is about. +private final class LogCapture: NSObject, OSLogListener { + private let lock = NSLock() + private var entries: [(level: ONE_S_LOG_LEVEL, entry: String)] = [] + + func onLogEvent(_ event: OneSignalLogEvent) { + lock.withLock { entries.append((event.level, event.entry)) } + } + + func entries(at level: ONE_S_LOG_LEVEL, mentioning text: String) -> [String] { + return lock.withLock { entries.filter { $0.level == level && $0.entry.contains(text) }.map { $0.entry } } + } +} + +/** + How the app is asked for a token: once per user per login, and audibly when a listener it registered is gone. + */ +final class UserJwtAskTests: XCTestCase { + private var client = MockOneSignalClient() + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + + client = MockOneSignalClient() + MockUserRequests.setDefaultCreateUserResponses(with: client, externalId: userA_EUID) + OneSignalCoreImpl.setSharedClient(client) + } + + override func tearDownWithError() throws { + // The mock answers 50ms late, so a Request still in flight would otherwise land mid-next-test + // and hydrate the shared models and JWT repo out from under it. + OneSignalCoreMocks.waitUntil("A Request was still in flight at teardown") { self.clientIsIdle } + OneSignalCoreMocks.clearUserDefaults() + } + + private var clientIsIdle: Bool { + return client.completedRequests.count == client.startedRequests.count + } + + // MARK: - an ask nobody hears + + /// Before the app registers anything, an unheard ask is the cold-start order and the replay on + /// registration covers it, so it stays below the default console level. + func testAnAskBeforeAnyListenerIsRegisteredLogsAtDebug() { + let log = LogCapture() + OneSignalLog.debug().__add(log) + defer { OneSignalLog.debug().__remove(log) } + + OSUserJwtInvalidatedListeners().notify(externalId: userA_EUID) + + let warnings = log.entries(at: .LL_WARN, mentioning: userA_EUID) + XCTAssertTrue(warnings.isEmpty, "\(warnings)") + XCTAssertEqual(log.entries(at: .LL_DEBUG, mentioning: userA_EUID).count, 1) + } + + /// Listeners are held weakly, so one the app let go of leaves the ask unheard for good, and the ask + /// is the only way the SDK gets a token, so that has to leave a trace above DEBUG. + func testAnAskAfterTheListenerIsGoneWarns() { + let log = LogCapture() + OneSignalLog.debug().__add(log) + defer { OneSignalLog.debug().__remove(log) } + let listeners = OSUserJwtInvalidatedListeners() + autoreleasepool { + listeners.add(MockUserJwtInvalidatedListener()) + } + + listeners.notify(externalId: userA_EUID) + + let warnings = log.entries(at: .LL_WARN, mentioning: userA_EUID) + XCTAssertEqual(warnings.count, 1, "\(warnings)") + XCTAssertTrue(warnings.first?.contains("OSUserJwtInvalidatedListener") == true) + } + + /// A heard ask is the normal path and stays quiet. + func testAnAskWithAListenerIsDeliveredWithoutAWarning() { + let log = LogCapture() + OneSignalLog.debug().__add(log) + defer { OneSignalLog.debug().__remove(log) } + let listener = MockUserJwtInvalidatedListener() + let listeners = OSUserJwtInvalidatedListeners() + listeners.add(listener) + + listeners.notify(externalId: userA_EUID) + + OneSignalCoreMocks.waitUntil("The listener was not told") { listener.invalidatedExternalIds == [userA_EUID] } + let warnings = log.entries(at: .LL_WARN, mentioning: userA_EUID) + XCTAssertTrue(warnings.isEmpty, "\(warnings)") + } + + // MARK: - asking again after a logout + + /// An ask left unanswered before a logout must not silence the next login as the same user: every + /// login that builds a new Identity Model is asked afresh. + func testLoggingInAgainAfterALogoutAsksAgain() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + // Held strongly for the test's lifetime: the observer keeps listeners weakly. + let listener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(listener) + defer { OneSignalUserManagerImpl.sharedInstance.removeUserJwtInvalidatedListener(listener) } + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalCoreMocks.waitUntil("The first login did not ask") { listener.invalidatedExternalIds == [userA_EUID] } + + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + + OneSignalCoreMocks.waitUntil("The second login did not ask again") { + listener.invalidatedExternalIds == [userA_EUID, userA_EUID] + } + } + + /// A token the repo refuses answers nothing, so that login asks like one with no token. + func testLoggingInAgainWithAnUnusableTokenAsksAgain() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + let listener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(listener) + defer { OneSignalUserManagerImpl.sharedInstance.removeUserJwtInvalidatedListener(listener) } + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalCoreMocks.waitUntil("The first login did not ask") { listener.invalidatedExternalIds == [userA_EUID] } + + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "") + + OneSignalCoreMocks.waitUntil("The login with an empty token did not ask again") { + listener.invalidatedExternalIds == [userA_EUID, userA_EUID] + } + } +} diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignalFramework.h b/iOS_SDK/OneSignalSDK/Source/OneSignalFramework.h index 3b6972122..a63a14e24 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignalFramework.h +++ b/iOS_SDK/OneSignalSDK/Source/OneSignalFramework.h @@ -72,6 +72,7 @@ typedef void (^OSFailureBlock)(NSError* error); + (void)login:(NSString * _Nonnull)externalId; + (void)login:(NSString * _Nonnull)externalId withToken:(NSString * _Nullable)token NS_SWIFT_NAME(login(externalId:token:)); +/// The listener is held weakly: keep a strong reference to it, or it is released and never called. + (void)addUserJwtInvalidatedListener:(id _Nonnull)listener NS_REFINED_FOR_SWIFT; + (void)removeUserJwtInvalidatedListener:(id _Nonnull)listener NS_REFINED_FOR_SWIFT; + (void)updateUserJwt:(NSString * _Nonnull)externalId withToken:(NSString * _Nonnull)token diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignalSwiftInterface.swift b/iOS_SDK/OneSignalSDK/Source/OneSignalSwiftInterface.swift index 756ec78ac..e302481b3 100644 --- a/iOS_SDK/OneSignalSDK/Source/OneSignalSwiftInterface.swift +++ b/iOS_SDK/OneSignalSDK/Source/OneSignalSwiftInterface.swift @@ -33,6 +33,7 @@ import OneSignalNotifications import OneSignalCore public extension OneSignal { + /// The listener is held weakly: keep a strong reference to it, or it is released and never called. static func addUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { __add(listener) }