diff --git a/examples/demo/App/Models/AppModels.swift b/examples/demo/App/Models/AppModels.swift index 70ccd615a..8967a8304 100644 --- a/examples/demo/App/Models/AppModels.swift +++ b/examples/demo/App/Models/AppModels.swift @@ -80,6 +80,7 @@ enum AddItemType { case tag case trigger case externalUserId + case updateJwt var title: String { switch self { @@ -89,13 +90,23 @@ enum AddItemType { case .tag: return "Add Tag" case .trigger: return "Add Trigger" case .externalUserId: return "Login User" + case .updateJwt: return "Update JWT" } } var requiresKeyValue: Bool { switch self { - case .alias, .tag, .trigger: return true - case .email, .sms, .externalUserId: return false + case .alias, .tag, .trigger, .externalUserId, .updateJwt: return true + case .email, .sms: return false + } + } + + /// When true the second field may be left blank (confirm stays enabled). + /// Used by Login, where the JWT token is optional. + var optionalValue: Bool { + switch self { + case .externalUserId: return true + default: return false } } @@ -103,6 +114,7 @@ enum AddItemType { switch self { case .alias: return "Label" case .tag, .trigger: return "Key" + case .externalUserId, .updateJwt: return "External User Id" default: return "Key" } } @@ -113,7 +125,8 @@ enum AddItemType { case .email: return "Email Address" case .sms: return "Phone Number" case .tag, .trigger: return "Value" - case .externalUserId: return "External User Id" + case .externalUserId: return "JWT Token (optional)" + case .updateJwt: return "JWT Token" } } @@ -128,6 +141,7 @@ enum AddItemType { var confirmLabel: String { switch self { case .externalUserId: return "Login" + case .updateJwt: return "Update" default: return "Add" } } @@ -141,6 +155,7 @@ enum AddItemType { case .tag: return "tag" case .trigger: return "trigger" case .externalUserId: return "login_user_id" + case .updateJwt: return "update_jwt" } } @@ -152,6 +167,8 @@ enum AddItemType { case .alias: return "alias_label_input" case .tag: return "tag_key_input" case .trigger: return "trigger_key_input" + case .externalUserId: return "login_user_id_input" + case .updateJwt: return "update_jwt_external_id_input" default: return "\(accessibilityKey)_key_input" } } @@ -165,6 +182,8 @@ enum AddItemType { case .alias: return "alias_id_input" case .tag: return "tag_value_input" case .trigger: return "trigger_value_input" + case .externalUserId: return "login_user_jwt_input" + case .updateJwt: return "update_jwt_token_input" default: return "\(accessibilityKey)_input" } } diff --git a/examples/demo/App/Services/OneSignalService.swift b/examples/demo/App/Services/OneSignalService.swift index 598189ea0..db1f9c360 100644 --- a/examples/demo/App/Services/OneSignalService.swift +++ b/examples/demo/App/Services/OneSignalService.swift @@ -96,9 +96,13 @@ final class OneSignalService { // MARK: - User - func login(externalId: String) { + func login(externalId: String, token: String? = nil) { prefs.setExternalUserId(externalId) - OneSignal.login(externalId) + OneSignal.login(externalId: externalId, token: token) + } + + func updateUserJwt(externalId: String, token: String) { + OneSignal.updateUserJwt(externalId: externalId, token: token) } func logout() { diff --git a/examples/demo/App/ViewModels/OneSignalViewModel.swift b/examples/demo/App/ViewModels/OneSignalViewModel.swift index dfa23553a..01c70e792 100644 --- a/examples/demo/App/ViewModels/OneSignalViewModel.swift +++ b/examples/demo/App/ViewModels/OneSignalViewModel.swift @@ -67,8 +67,6 @@ final class OneSignalViewModel: ObservableObject { // MARK: - UI State - @Published var isLoading: Bool = false - @Published var activeTooltip: TooltipData? // MARK: - Computed @@ -129,7 +127,6 @@ final class OneSignalViewModel: ObservableObject { guard let onesignalId = service.onesignalId else { return } requestSequence &+= 1 let captured = requestSequence - isLoading = true let userData = await UserFetchService.shared.fetchUser(appId: appId, onesignalId: onesignalId) @@ -145,7 +142,6 @@ final class OneSignalViewModel: ObservableObject { externalUserId = extId } } - isLoading = false } // MARK: - Consent @@ -166,15 +162,22 @@ final class OneSignalViewModel: ObservableObject { // MARK: - User - func login(externalId: String) { + func login(externalId: String, token: String? = nil) { let trimmed = externalId.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } - isLoading = true - service.login(externalId: trimmed) + let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) + service.login(externalId: trimmed, token: (trimmedToken?.isEmpty ?? true) ? nil : trimmedToken) externalUserId = trimmed clearUserData() } + func updateUserJwt(externalId: String, token: String) { + let trimmedId = externalId.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedId.isEmpty, !trimmedToken.isEmpty else { return } + service.updateUserJwt(externalId: trimmedId, token: trimmedToken) + } + func logout() { service.logout() externalUserId = nil diff --git a/examples/demo/App/Views/Components/AddItemDialog.swift b/examples/demo/App/Views/Components/AddItemDialog.swift index f6b099578..f8362379c 100644 --- a/examples/demo/App/Views/Components/AddItemDialog.swift +++ b/examples/demo/App/Views/Components/AddItemDialog.swift @@ -80,8 +80,9 @@ struct AddItemDialog: View { private var isValid: Bool { if itemType.requiresKeyValue { - return !keyText.trimmingCharacters(in: .whitespaces).isEmpty && - !valueText.trimmingCharacters(in: .whitespaces).isEmpty + let keyOK = !keyText.trimmingCharacters(in: .whitespaces).isEmpty + if itemType.optionalValue { return keyOK } + return keyOK && !valueText.trimmingCharacters(in: .whitespaces).isEmpty } return !valueText.trimmingCharacters(in: .whitespaces).isEmpty } diff --git a/examples/demo/App/Views/Sections/UserSection.swift b/examples/demo/App/Views/Sections/UserSection.swift index 0e0bb5067..80ab230e3 100644 --- a/examples/demo/App/Views/Sections/UserSection.swift +++ b/examples/demo/App/Views/Sections/UserSection.swift @@ -31,6 +31,7 @@ import SwiftUI struct UserSection: View { @EnvironmentObject var viewModel: OneSignalViewModel @State private var loginOpen = false + @State private var updateJwtOpen = false var body: some View { SectionCard(title: "USER", sectionKey: "user") { @@ -55,6 +56,14 @@ struct UserSection: View { loginOpen = true } + ActionButton( + "UPDATE JWT", + style: .outline, + accessibilityID: "update_jwt_button" + ) { + updateJwtOpen = true + } + if viewModel.isLoggedIn { ActionButton( "LOGOUT USER", @@ -68,12 +77,22 @@ struct UserSection: View { .osCenteredDialog(isPresented: $loginOpen) { AddItemDialog( itemType: .externalUserId, - onAdd: { _, value in - viewModel.login(externalId: value) + onAdd: { externalId, token in + viewModel.login(externalId: externalId, token: token.isEmpty ? nil : token) loginOpen = false }, onCancel: { loginOpen = false } ) } + .osCenteredDialog(isPresented: $updateJwtOpen) { + AddItemDialog( + itemType: .updateJwt, + onAdd: { externalId, token in + viewModel.updateUserJwt(externalId: externalId, token: token) + updateJwtOpen = false + }, + onCancel: { updateJwtOpen = false } + ) + } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 00fc4b56a..1f21e78f9 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -185,6 +185,7 @@ 3CC063E02B6D7F2A002BB07F /* OneSignalUserMocks.h in Headers */ = {isa = PBXBuildFile; fileRef = 3CC063DF2B6D7F2A002BB07F /* OneSignalUserMocks.h */; settings = {ATTRIBUTES = (Public, ); }; }; 3CC063E62B6D7F96002BB07F /* OneSignalUserMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC063E52B6D7F96002BB07F /* OneSignalUserMocks.swift */; }; 3CC063EE2B6D7FE8002BB07F /* OneSignalUserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC063ED2B6D7FE8002BB07F /* OneSignalUserTests.swift */; }; + B91A66287DEA4026A4DC5952 /* OSIdentityModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1399651D1A401EB888DA77 /* OSIdentityModelTests.swift */; }; 3CC063EF2B6D7FE8002BB07F /* OneSignalUser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE69E19B282ED8060090BB3D /* OneSignalUser.framework */; }; 3CC890352C5BF9A7002CB4CC /* UserConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC890342C5BF9A7002CB4CC /* UserConcurrencyTests.swift */; }; 3CC9A6342AFA1FDE008F68FD /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3CC9A6332AFA1FDD008F68FD /* PrivacyInfo.xcprivacy */; }; @@ -1439,6 +1440,7 @@ 3CC063E52B6D7F96002BB07F /* OneSignalUserMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalUserMocks.swift; sourceTree = ""; }; 3CC063EB2B6D7FE8002BB07F /* OneSignalUserTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OneSignalUserTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3CC063ED2B6D7FE8002BB07F /* OneSignalUserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalUserTests.swift; sourceTree = ""; }; + 6C1399651D1A401EB888DA77 /* OSIdentityModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSIdentityModelTests.swift; sourceTree = ""; }; 3CC890342C5BF9A7002CB4CC /* UserConcurrencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserConcurrencyTests.swift; sourceTree = ""; }; 3CC9A6332AFA1FDD008F68FD /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 3CC9A6352AFA26E7008F68FD /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; @@ -2422,6 +2424,7 @@ 3CDE664A2BFC2A55006DA114 /* OneSignalUserTests-Bridging-Header.h */, 3CF11E3E2C6D61AC002856F5 /* Executors */, 3CC063ED2B6D7FE8002BB07F /* OneSignalUserTests.swift */, + 6C1399651D1A401EB888DA77 /* OSIdentityModelTests.swift */, 3CC890342C5BF9A7002CB4CC /* UserConcurrencyTests.swift */, 3CB331672F281679000E1801 /* CustomEventsIntegrationTests.swift */, 3C67F7792BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift */, @@ -4539,6 +4542,7 @@ DE3568F22C8911EA00AF447C /* IdentityExecutorTests.swift in Sources */, 3C67F77A2BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift in Sources */, 3CC063EE2B6D7FE8002BB07F /* OneSignalUserTests.swift in Sources */, + B91A66287DEA4026A4DC5952 /* OSIdentityModelTests.swift in Sources */, 3CC890352C5BF9A7002CB4CC /* UserConcurrencyTests.swift in Sources */, DE3568F02C89067400AF447C /* SubscriptionsExecutorTests.swift in Sources */, 3CB3316A2F281692000E1801 /* OSCustomEventsExecutorTests.swift in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModel.swift index dcbe778d7..2c5d158e0 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModel.swift @@ -38,23 +38,55 @@ class OSIdentityModel: OSModel { return internalGetAlias(OS_EXTERNAL_ID) } - // All access to aliases should go through helper methods with locking + // All access to aliases and jwtBearerToken must go through the lock var aliases: [String: String] = [:] - private let aliasesLock = NSRecursiveLock() + private let lock = NSRecursiveLock() // MARK: - JWT + private var jwtBearerTokenLocked: String? // only read/write under self.lock public var jwtBearerToken: String? { - didSet { - guard jwtBearerToken != oldValue else { - return + get { + lock.withLock { jwtBearerTokenLocked } + } + set { + // Lock only the storage write. The change notifier fires synchronously + // to listeners that may take other locks + let changed = lock.withLock { + guard newValue != jwtBearerTokenLocked else { return false } + jwtBearerTokenLocked = newValue + return true + } + if changed { + self.set(property: OS_JWT_BEARER_TOKEN, newValue: newValue) } - self.set(property: OS_JWT_BEARER_TOKEN, newValue: jwtBearerToken) } } - func isJwtValid() -> Bool { - return jwtBearerToken != nil && jwtBearerToken != "" && jwtBearerToken != OS_JWT_TOKEN_INVALID + /// Returns the bearer token if it is valid, otherwise nil, snapshots once + func getValidJwt() -> String? { + let token = jwtBearerToken + guard let token = token, !token.isEmpty, token != OS_JWT_TOKEN_INVALID else { + return nil + } + return token + } + + /** + Atomically transition the JWT token to `OS_JWT_TOKEN_INVALID`. Returns + `true` if the transition occurred, `false` if the token was already invalid. + */ + @discardableResult + func invalidateJwtBearerToken() -> Bool { + let changed = lock.withLock { + guard jwtBearerTokenLocked != OS_JWT_TOKEN_INVALID else { return false } + jwtBearerTokenLocked = OS_JWT_TOKEN_INVALID + return true + } + if changed { + self.set(property: OS_JWT_BEARER_TOKEN, newValue: OS_JWT_TOKEN_INVALID) + } + return changed } // MARK: - Initialization @@ -66,10 +98,10 @@ class OSIdentityModel: OSModel { } override func encode(with coder: NSCoder) { - aliasesLock.withLock { + lock.withLock { super.encode(with: coder) coder.encode(aliases, forKey: "aliases") - coder.encode(jwtBearerToken, forKey: OS_JWT_BEARER_TOKEN) + coder.encode(jwtBearerTokenLocked, forKey: OS_JWT_BEARER_TOKEN) } } @@ -79,20 +111,20 @@ class OSIdentityModel: OSModel { // log error return nil } - self.jwtBearerToken = coder.decodeObject(forKey: OS_JWT_BEARER_TOKEN) as? String + self.jwtBearerTokenLocked = coder.decodeObject(forKey: OS_JWT_BEARER_TOKEN) as? String self.aliases = aliases } /** Threadsafe getter for an alias */ private func internalGetAlias(_ label: String) -> String? { - aliasesLock.withLock { + lock.withLock { return self.aliases[label] } } /** Threadsafe setter or removal for aliases */ private func internalAddAliases(_ aliases: [String: String]) { - aliasesLock.withLock { + lock.withLock { for (label, id) in aliases { // Remove the alias if the ID field is "" self.aliases[label] = id.isEmpty ? nil : id @@ -105,7 +137,7 @@ class OSIdentityModel: OSModel { Called to clear the model's data in preparation for hydration via a fetch user call. */ func clearData() { - aliasesLock.withLock { + lock.withLock { self.aliases = [:] } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelRepo.swift index ef82e264e..37e7e007e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelRepo.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelRepo.swift @@ -73,17 +73,19 @@ class OSIdentityModelRepo { This can be optimized in the future to re-use an Identity Model if multiple logins are made for the same user. */ func updateJwtToken(externalId: String, token: String) { - var found = false - lock.withLock { - for model in models.values { - if model.externalId == externalId { - model.jwtBearerToken = token - found = true - } - } + // Snapshot matching models under the repo lock, then mutate outside. + // Writing the token fires the model's change notifier synchronously + // (→ onModelUpdated → onJwtTokenChanged); doing that while holding the + // repo lock leaves a trap for future listeners to deadlock on. + let matchingModels: [OSIdentityModel] = lock.withLock { + models.values.filter { $0.externalId == externalId } } - if !found { + guard !matchingModels.isEmpty else { OneSignalLog.onesignalLog(ONE_S_LOG_LEVEL.LL_ERROR, message: "Update User JWT called for external ID \(externalId) that does not exist") + return + } + for model in matchingModels { + model.jwtBearerToken = token } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index e03fbfb14..9de6ccd1c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -435,9 +435,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { // JWT is required - if _user.identityModel.isJwtValid(), - let token = _user.identityModel.jwtBearerToken - { + if let token = _user.identityModel.getValidJwt() { fullHeader["Authorization"] = "Bearer \(token)" return fullHeader } @@ -739,14 +737,9 @@ extension OneSignalUserManagerImpl { return } - // Return, if the token has already been invalidated - guard identityModel.jwtBearerToken != OS_JWT_TOKEN_INVALID else { - return + if identityModel.invalidateJwtBearerToken() { + fireJwtExpired(externalId: externalId) } - - identityModel.jwtBearerToken = OS_JWT_TOKEN_INVALID - - fireJwtExpired(externalId: externalId) } private func fireJwtExpired(externalId: String) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift index 52bebf57e..fa435d421 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift @@ -70,12 +70,10 @@ internal extension OneSignalRequest { | --------------- | -------------- | ------- | ------- | */ func addJWTHeaderIsValid(identityModel: OSIdentityModel) -> Bool { - let tokenIsValid = identityModel.isJwtValid() + let validToken = identityModel.getValidJwt() let required = OneSignalUserManagerImpl.sharedInstance.jwtConfig.isRequired - let canBeSent = (required == false) || (required == true && tokenIsValid) - if canBeSent && tokenIsValid, - let token = identityModel.jwtBearerToken - { + let canBeSent = (required == false) || (required == true && validToken != nil) + if canBeSent, let token = validToken { // Add the JWT token if it is valid, regardless of requirements var additionalHeaders = self.additionalHeaders ?? [String: String]() additionalHeaders["Authorization"] = "Bearer \(token)" diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift new file mode 100644 index 000000000..2b8756ade --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift @@ -0,0 +1,90 @@ +/* + 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 +@testable import OneSignalOSCore +@testable import OneSignalUser + +/// Tests for the two new JWT APIs added to `OSIdentityModel`: +/// - `getValidJwt()` snapshots and returns the bearer token only when it is +/// non-nil, non-empty, and not the `OS_JWT_TOKEN_INVALID` sentinel. +/// - `invalidateJwtBearerToken()` performs an atomic compare-and-set to +/// `OS_JWT_TOKEN_INVALID`, returning `true` only on the transition. +final class OSIdentityModelTests: XCTestCase { + + private func makeModel(token: String? = nil) -> OSIdentityModel { + let model = OSIdentityModel(aliases: [:], changeNotifier: OSEventProducer()) + model.jwtBearerToken = token + return model + } + + // MARK: - getValidJwt() + + func testGetValidJwt_returnsNil_whenTokenIsNil() { + XCTAssertNil(makeModel(token: nil).getValidJwt()) + } + + func testGetValidJwt_returnsNil_whenTokenIsEmptyString() { + XCTAssertNil(makeModel(token: "").getValidJwt()) + } + + func testGetValidJwt_returnsNil_whenTokenIsInvalidSentinel() { + XCTAssertNil(makeModel(token: OS_JWT_TOKEN_INVALID).getValidJwt()) + } + + func testGetValidJwt_returnsToken_whenTokenIsValid() { + let token = "eyJhbGciOiJFUzI1NiJ9.payload.sig" + XCTAssertEqual(makeModel(token: token).getValidJwt(), token) + } + + // MARK: - invalidateJwtBearerToken() + + func testInvalidate_returnsTrueOnFirstTransition_andSetsInvalidSentinel() { + let model = makeModel(token: "valid-token") + + XCTAssertTrue(model.invalidateJwtBearerToken()) + XCTAssertEqual(model.jwtBearerToken, OS_JWT_TOKEN_INVALID) + } + + func testInvalidate_returnsFalseWhenAlreadyInvalid() { + let model = makeModel(token: "valid-token") + _ = model.invalidateJwtBearerToken() + + XCTAssertFalse(model.invalidateJwtBearerToken()) + XCTAssertEqual(model.jwtBearerToken, OS_JWT_TOKEN_INVALID) + } + + func testInvalidate_returnsTrueWhenStartingFromNil() { + // Defensive: nil → INVALID is still a real transition, the model lands + // on the sentinel and the caller can fire fireJwtExpired once. + let model = makeModel(token: nil) + + XCTAssertTrue(model.invalidateJwtBearerToken()) + XCTAssertEqual(model.jwtBearerToken, OS_JWT_TOKEN_INVALID) + } +}