From 5f40aae7953e822c057acf8a98f6a72ac3a86a38 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 11 Aug 2026 16:55:36 -0700 Subject: [PATCH 1/6] feat: [PR6] Identity Verification for the request pipeline Everything that sends a user-scoped call now decides how to address and sign it in one place. OSRequestAuth answers, for a given user, which alias names them in the path and which token signs it, read together so the alias and the token can never come from different users. Under Identity Verification a user is addressed by external_id, which the app chooses, so those path segments are percent-encoded through OSUrlPath. Requests carry the identity model that owns them rather than reading whoever is current at send time. The operation repo holds queued work while the requirement is still unknown, since sending unsigned would be rejected and sending signed too early is not possible. Once the answer arrives the queue flushes. When Identity Verification is on, work belonging to no external ID is dropped rather than sent: an anonymous user is never created on the server, so that work has no user to belong to. Update Subscription is deliberately exempt from all of this. A push subscription belongs to the device, not the signed-in user, so it always goes out whether or not anyone is logged in and whether or not a token is valid; nothing about it is gated on auth. Logging out while Identity Verification is on internally disables the push subscription, since the replacement anonymous user is never created and the subscription would otherwise keep reporting under the logged-out user. Logging back in clears that, as does the requirement hydrating to off. Co-authored-by: Cursor --- .../OneSignal.xcodeproj/project.pbxproj | 36 ++ .../MockOneSignalClient.swift | 4 + .../Source/Jwt/OSAliasPair.swift | 40 ++ .../Source/OSOperationExecutor.swift | 6 + .../Source/OSOperationRepo.swift | 190 ++++++-- .../OneSignalOSCore/Source/OSUrlPath.swift | 47 ++ .../OneSignalOSCoreMocks/OSCoreMocks.swift | 18 +- .../OSOperationRepoFlushTests.swift | 102 ++-- ...erationRepoIdentityVerificationTests.swift | 266 +++++++++++ .../OSOperationRepoTestSupport.swift | 107 +++++ .../Executors/OSCustomEventsExecutor.swift | 48 +- .../OSIdentityOperationExecutor.swift | 53 ++- .../OSPropertyOperationExecutor.swift | 39 +- .../OSSubscriptionOperationExecutor.swift | 99 +++- .../Source/Executors/OSUserExecutor.swift | 153 +++++- .../OneSignalUser/Source/OSRequestAuth.swift | 249 ++++++++++ .../Source/OSSubscriptionModel.swift | 63 ++- .../Source/OneSignalUserManagerImpl.swift | 132 +++-- .../Source/Requests/OSRequestAddAliases.swift | 17 +- .../OSRequestCreateSubscription.swift | 17 +- .../Source/Requests/OSRequestCreateUser.swift | 25 +- .../Requests/OSRequestCustomEvents.swift | 29 +- .../OSRequestDeleteSubscription.swift | 19 +- ...OSRequestFetchIdentityBySubscription.swift | 6 +- .../Source/Requests/OSRequestFetchUser.swift | 12 +- .../Requests/OSRequestIdentifyUser.swift | 22 +- .../Requests/OSRequestRemoveAlias.swift | 18 +- .../OSRequestTransferSubscription.swift | 4 +- .../Requests/OSRequestUpdateProperties.swift | 18 +- .../OSRequestUpdateSubscription.swift | 35 +- .../Source/Requests/OSUserRequest.swift | 49 +- .../OneSignalUserMocks.swift | 5 +- .../CustomEventsIntegrationTests.swift | 12 +- .../DeltaOwnershipTests.swift | 49 ++ .../ExecutorAnonymousPurgeTests.swift | 358 ++++++++++++++ .../OSCustomEventsExecutorTests.swift | 2 +- .../SubscriptionUpdateRaceTests.swift | 10 +- .../Executors/UserExecutorTests.swift | 210 +++++++- .../OSRequestAuthTests.swift | 356 ++++++++++++++ .../OneSignalUserTests.swift | 2 +- .../RequestPathEncodingTests.swift | 125 +++++ .../SwitchUserIntegrationTests.swift | 2 +- .../UserConcurrencyTests.swift | 44 +- .../UserJwtLifecycleTests.swift | 450 ++++++++++++++++++ 44 files changed, 3257 insertions(+), 291 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSAliasPair.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSUrlPath.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/OSRequestAuthTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/RequestPathEncodingTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtLifecycleTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 8bea2dbee..06891b51d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -53,9 +53,13 @@ 03E56DD328405F4A006AA1DA /* OneSignalAppDelegateOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 03E56DD228405F4A006AA1DA /* OneSignalAppDelegateOverrider.m */; }; 0AA11438FBF3A82D13824467 /* OSFeatureManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DA9EEED644F1160CAD9A38 /* OSFeatureManager.swift */; }; 16664C4C25DDB195003B8A14 /* NSTimeZoneOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 16664C4B25DDB195003B8A14 /* NSTimeZoneOverrider.m */; }; + 1B7E5A0AEB23050C398E6111 /* ExecutorAnonymousPurgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE740F9E6B87D215510B5982 /* ExecutorAnonymousPurgeTests.swift */; }; + 23D66BEB40CE76DFF89744A3 /* RequestPathEncodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3016921C1F6B7B7793F67567 /* RequestPathEncodingTests.swift */; }; 257E219608960B8545199057 /* OneSignalUserManagerImpl+Jwt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */; }; 2DB99C76F3532383C3B81D09 /* OSUserJwtRepoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5809B47EB4F2478099697CE /* OSUserJwtRepoTests.swift */; }; 2F32272222E88DF0C2C18B53 /* OSFeatureManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E4A83C6C0D0DF28CDECA90 /* OSFeatureManagerTests.swift */; }; + 322B62F85070DCB5C5599D6D /* OSOperationRepoIdentityVerificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFBB451F692A15639A1A08AE /* OSOperationRepoIdentityVerificationTests.swift */; }; + 32601EF1960CD92605D1ABF9 /* OSRequestAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BF72AAEB5284C97B864A1A8 /* OSRequestAuth.swift */; }; 37E6B2BB19D9CAF300D0C601 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37E6B2BA19D9CAF300D0C601 /* UIKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 3C0151922C2E298F0079E076 /* OneSignalInAppMessages.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DEBAAE282A4211D900BF2C1C /* OneSignalInAppMessages.framework */; }; 3C01519C2C2E29F90079E076 /* IAMRequestTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3C01519B2C2E29F90079E076 /* IAMRequestTests.m */; }; @@ -336,6 +340,8 @@ 7AFE856B2368DDB80091D6A5 /* OSFocusCallParams.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFE856A2368DDB80091D6A5 /* OSFocusCallParams.m */; }; 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 */; }; + 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 */; }; 911E2CC51E398B53003112A4 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3E08E2701D49A5C8002176DE /* SystemConfiguration.framework */; }; @@ -378,6 +384,8 @@ 9FF50E2A40C88E4533033A38 /* OSIdentityVerificationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4855B81F170253FB0C1749D /* OSIdentityVerificationServiceTests.swift */; }; A662399326850DDE00D52FD8 /* LanguageTest.m in Sources */ = {isa = PBXBuildFile; fileRef = A662399026850DDE00D52FD8 /* LanguageTest.m */; }; A66239952686612F00D52FD8 /* OneSignalFramework.h in Headers */ = {isa = PBXBuildFile; fileRef = 912411F01E73342200E41FD7 /* OneSignalFramework.h */; settings = {ATTRIBUTES = (Public, ); }; }; + AAFA2D46E6C5FD3D14D39F27 /* OSRequestAuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047D8F5E1095A20C9C54FD33 /* OSRequestAuthTests.swift */; }; + ABAFB38CC150EE24ED595EF7 /* OSAliasPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = F83E7BF2B518EA8B0B51B276 /* OSAliasPair.swift */; }; B5FBED8247288744EB484CB5 /* OSIdentityModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5953656ACCC21358BC0CF2F0 /* OSIdentityModelTests.swift */; }; BE737361D82E74544B7A1996 /* OSUserJwtConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */; }; CA08FC871FE99BB4004C445F /* OneSignalClientOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = CA08FC831FE99BB4004C445F /* OneSignalClientOverrider.m */; }; @@ -407,6 +415,7 @@ CACBAAAA218A65AE000ACAA5 /* InAppMessagingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CACBAAA9218A65AE000ACAA5 /* InAppMessagingTests.m */; }; CACBAAAC218A662B000ACAA5 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CACBAAAB218A662B000ACAA5 /* WebKit.framework */; }; CACBAAB4218A7113000ACAA5 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CACBAAAB218A662B000ACAA5 /* WebKit.framework */; }; + D14FEB74CBF964F5BF0FD615 /* OSUrlPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8596A3E728FB90691E43BCA0 /* OSUrlPath.swift */; }; D465D9B81F58B242ADF14874 /* OSIdentityModelRepoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89DE5BB0EDD3964C20C5169F /* OSIdentityModelRepoTests.swift */; }; DAF9C81134248FCDB0C12E5B /* OSUserJwtInvalidatedEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */; }; DD2A89A8052E2D1912B0038B /* OSIamFetchReadyConditionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */; }; @@ -1344,6 +1353,7 @@ 03CCCC842835F291004BF794 /* UIApplicationDelegateSwizzlingTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UIApplicationDelegateSwizzlingTests.m; sourceTree = ""; }; 03E56DD128405F4A006AA1DA /* OneSignalAppDelegateOverrider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalAppDelegateOverrider.h; sourceTree = ""; }; 03E56DD228405F4A006AA1DA /* OneSignalAppDelegateOverrider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalAppDelegateOverrider.m; sourceTree = ""; }; + 047D8F5E1095A20C9C54FD33 /* OSRequestAuthTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSRequestAuthTests.swift; sourceTree = ""; }; 16664C4B25DDB195003B8A14 /* NSTimeZoneOverrider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NSTimeZoneOverrider.m; sourceTree = ""; }; 16664C5425DDB2CB003B8A14 /* NSTimeZoneOverrider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NSTimeZoneOverrider.h; sourceTree = ""; }; 1AF75EAC1E8567FD0097B315 /* NSString+OneSignal.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSString+OneSignal.h"; sourceTree = ""; }; @@ -1351,6 +1361,7 @@ 1C4EAEA1BC62D8FC57927511 /* OSIdentityVerificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityVerificationService.swift; sourceTree = ""; }; 1F214EE6C5FE133672D6622F /* MockUserJwtInvalidatedListener.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MockUserJwtInvalidatedListener.swift; sourceTree = ""; }; 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtInvalidatedEvent.swift; sourceTree = ""; }; + 3016921C1F6B7B7793F67567 /* RequestPathEncodingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RequestPathEncodingTests.swift; sourceTree = ""; }; 37747F9319147D6500558FAD /* libOneSignal.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libOneSignal.a; sourceTree = BUILT_PRODUCTS_DIR; }; 37E6B2BA19D9CAF300D0C601 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; 3881024646E7F0DE05158442 /* DeltaOwnershipTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeltaOwnershipTests.swift; sourceTree = ""; }; @@ -1545,6 +1556,7 @@ 475F47202B8E398E00EC05B3 /* OneSignalLiveActivities.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalLiveActivities.h; sourceTree = ""; }; 475F47482B8E3A4400EC05B3 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "OneSignalUserManagerImpl+Jwt.swift"; sourceTree = ""; }; + 5221EEDBA5A74BD565490D52 /* OSOperationRepoTestSupport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSOperationRepoTestSupport.swift; sourceTree = ""; }; 557653D2007BFF86EA8342E4 /* OSDeltaTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSDeltaTests.swift; sourceTree = ""; }; 5953656ACCC21358BC0CF2F0 /* OSIdentityModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityModelTests.swift; sourceTree = ""; }; 5B053FB82CAE07EB002F30C4 /* OneSignalOSCoreTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OneSignalOSCoreTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1557,6 +1569,7 @@ 5BC1DE612C90B85A00CA8807 /* OSIamFetchOffsetKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSIamFetchOffsetKey.swift; sourceTree = ""; }; 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 = ""; }; 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtConfig.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 = ""; }; @@ -1633,6 +1646,7 @@ 7AFE856A2368DDB80091D6A5 /* OSFocusCallParams.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OSFocusCallParams.m; sourceTree = ""; }; 7AFE856E2368DDC50091D6A5 /* OSFocusCallParams.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSFocusCallParams.h; sourceTree = ""; }; 80DC5517E6EB5B26CF980CC5 /* UserJwtApiTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserJwtApiTests.swift; sourceTree = ""; }; + 8596A3E728FB90691E43BCA0 /* OSUrlPath.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUrlPath.swift; sourceTree = ""; }; 89DE5BB0EDD3964C20C5169F /* OSIdentityModelRepoTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityModelRepoTests.swift; sourceTree = ""; }; 911E2CBA1E398AB3003112A4 /* UnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 911E2CBC1E398AB3003112A4 /* UnitTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UnitTests.m; sourceTree = ""; }; @@ -1657,6 +1671,7 @@ 91C7725D1E7CCE1000D612D0 /* OneSignalInternal.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OneSignalInternal.h; sourceTree = ""; }; 91F60F7B1E80E49A00706E60 /* UncaughtExceptionHandler.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = UncaughtExceptionHandler.h; sourceTree = ""; }; 91F60F7C1E80E4E400706E60 /* UncaughtExceptionHandler.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = UncaughtExceptionHandler.m; sourceTree = ""; }; + 9BF72AAEB5284C97B864A1A8 /* OSRequestAuth.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSRequestAuth.swift; sourceTree = ""; }; 9D1BD95D237663BF00A064F7 /* OSInfluenceDataDefines.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSInfluenceDataDefines.h; sourceTree = ""; }; 9D1BD95E2379E7A900A064F7 /* OSOutcomeEvent.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSOutcomeEvent.h; sourceTree = ""; }; 9D1BD95F2379E7C300A064F7 /* OSOutcomeEvent.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OSOutcomeEvent.m; sourceTree = ""; }; @@ -1869,7 +1884,10 @@ DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSStubLiveActivities.swift; sourceTree = ""; }; E9376A4957E9090C748BCB18 /* OSUserJwtConfigTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtConfigTests.swift; sourceTree = ""; }; F4855B81F170253FB0C1749D /* OSIdentityVerificationServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityVerificationServiceTests.swift; sourceTree = ""; }; + F83E7BF2B518EA8B0B51B276 /* OSAliasPair.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSAliasPair.swift; sourceTree = ""; }; + FE740F9E6B87D215510B5982 /* ExecutorAnonymousPurgeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExecutorAnonymousPurgeTests.swift; sourceTree = ""; }; FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIamFetchReadyConditionTests.swift; sourceTree = ""; }; + FFBB451F692A15639A1A08AE /* OSOperationRepoIdentityVerificationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSOperationRepoIdentityVerificationTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -2311,6 +2329,7 @@ DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */, A843B922174496E99F2D00A8 /* Jwt */, C7DA9EEED644F1160CAD9A38 /* OSFeatureManager.swift */, + 8596A3E728FB90691E43BCA0 /* OSUrlPath.swift */, ); path = Source; sourceTree = ""; @@ -2439,6 +2458,9 @@ B5809B47EB4F2478099697CE /* OSUserJwtRepoTests.swift */, 80DC5517E6EB5B26CF980CC5 /* UserJwtApiTests.swift */, 3881024646E7F0DE05158442 /* DeltaOwnershipTests.swift */, + 047D8F5E1095A20C9C54FD33 /* OSRequestAuthTests.swift */, + 3016921C1F6B7B7793F67567 /* RequestPathEncodingTests.swift */, + 5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */, ); path = OneSignalUserTests; sourceTree = ""; @@ -2457,6 +2479,7 @@ 3CF11E3C2C6D6155002856F5 /* UserExecutorTests.swift */, 3CA93BC3300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift */, 3CB331692F281692000E1801 /* OSCustomEventsExecutorTests.swift */, + FE740F9E6B87D215510B5982 /* ExecutorAnonymousPurgeTests.swift */, ); path = Executors; sourceTree = ""; @@ -2618,6 +2641,8 @@ 4795885CE6CFFB1998AC7D09 /* Feature */, 5B47CE0CE255AC7128442FFF /* Jwt */, 557653D2007BFF86EA8342E4 /* OSDeltaTests.swift */, + FFBB451F692A15639A1A08AE /* OSOperationRepoIdentityVerificationTests.swift */, + 5221EEDBA5A74BD565490D52 /* OSOperationRepoTestSupport.swift */, ); path = OneSignalOSCoreTests; sourceTree = ""; @@ -2788,6 +2813,7 @@ children = ( 1C4EAEA1BC62D8FC57927511 /* OSIdentityVerificationService.swift */, 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */, + F83E7BF2B518EA8B0B51B276 /* OSAliasPair.swift */, ); name = Jwt; path = Jwt; @@ -2833,6 +2859,7 @@ 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */, 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */, 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */, + 9BF72AAEB5284C97B864A1A8 /* OSRequestAuth.swift */, ); path = Source; sourceTree = ""; @@ -4530,6 +4557,8 @@ ECD9DF65FB98056806A43541 /* OSIdentityVerificationService.swift in Sources */, BE737361D82E74544B7A1996 /* OSUserJwtConfig.swift in Sources */, 0AA11438FBF3A82D13824467 /* OSFeatureManager.swift in Sources */, + D14FEB74CBF964F5BF0FD615 /* OSUrlPath.swift in Sources */, + ABAFB38CC150EE24ED595EF7 /* OSAliasPair.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4602,6 +4631,10 @@ 2DB99C76F3532383C3B81D09 /* OSUserJwtRepoTests.swift in Sources */, 9224347AAE3E092B5743380D /* UserJwtApiTests.swift in Sources */, 99B1615D0132AFAA981A9AD3 /* DeltaOwnershipTests.swift in Sources */, + 1B7E5A0AEB23050C398E6111 /* ExecutorAnonymousPurgeTests.swift in Sources */, + AAFA2D46E6C5FD3D14D39F27 /* OSRequestAuthTests.swift in Sources */, + 23D66BEB40CE76DFF89744A3 /* RequestPathEncodingTests.swift in Sources */, + 7EB69F3B404D0AEF46EC1536 /* UserJwtLifecycleTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4681,6 +4714,8 @@ 9FF50E2A40C88E4533033A38 /* OSIdentityVerificationServiceTests.swift in Sources */, 8E949FF4C7A7A2C7182E53EA /* OSUserJwtConfigTests.swift in Sources */, 4E8E880086C66B4120CC0CD4 /* OSDeltaTests.swift in Sources */, + 322B62F85070DCB5C5599D6D /* OSOperationRepoIdentityVerificationTests.swift in Sources */, + 8D2F4893453206700BB60F85 /* OSOperationRepoTestSupport.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4805,6 +4840,7 @@ DAF9C81134248FCDB0C12E5B /* OSUserJwtInvalidatedEvent.swift in Sources */, FD1F1FCA05D555623DD53B54 /* OSUserJwtRepo.swift in Sources */, 257E219608960B8545199057 /* OneSignalUserManagerImpl+Jwt.swift in Sources */, + 32601EF1960CD92605D1ABF9 /* OSRequestAuth.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift b/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift index a388fc6f9..d85ffbaa3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift @@ -197,12 +197,16 @@ public class MockOneSignalClient: NSObject, IOneSignalClient { executionQueue.sync {} } + // A request has one outcome: whichever of these was called for it last. Otherwise a test could not + // override a default its setUp registered, nor let a retry succeed after the first attempt failed. public func setMockResponseForRequest(request: String, response: [String: Any]) { mockResponses[request] = response + mockFailureResponses.removeValue(forKey: request) } public func setMockFailureResponseForRequest(request: String, error: OneSignalClientError) { mockFailureResponses[request] = error + mockResponses.removeValue(forKey: request) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSAliasPair.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSAliasPair.swift new file mode 100644 index 000000000..3db01bfc6 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSAliasPair.swift @@ -0,0 +1,40 @@ +/* + 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. + */ + +/** + Requests address a user by one alias or the other depending on Identity Verification: `external_id` + when it is active, `onesignal_id` otherwise. + */ +@objc public class OSAliasPair: NSObject { + @objc public let label: String + @objc public let id: String + + public init(_ label: String, _ id: String) { + self.label = label + self.id = id + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationExecutor.swift index 4afcf0ec7..0c0a753c9 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationExecutor.swift @@ -36,4 +36,10 @@ public protocol OSOperationExecutor { func enqueueDelta(_ delta: OSDelta) func cacheDeltaQueue() func processDeltaQueue(inBackground: Bool) + + /** + Drop queued Deltas and Requests that belong to an anonymous user. Driven by `OSOperationRepo` + so the policy stays there; only the storage is per-executor. + */ + func removeOperationsWithoutExternalId() } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift index 223080697..a38b449ac 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift @@ -29,17 +29,26 @@ import Foundation import OneSignalCore /** - The OSOperationRepo is a static singleton. - OSDeltas are enqueued when model store observers observe changes to their models, and sorted to their appropriate executors. + Enqueues OSDeltas from model-store observers and routes them to executors. + + Also owns Identity Verification decisions for queued work: hold flushes until `requirement` is known, + and drop anonymous Deltas while IV is active — except push subscription updates, which stay unsigned + and have to keep flowing with or without an identified user. */ public class OSOperationRepo: NSObject { - public static let sharedInstance = OSOperationRepo() - private var hasCalledStart = false + private let identityVerificationService: OSIdentityVerificationService + + /** + Serial, and the only place `deltaQueue`, the executor registry, and the two start flags may be + touched once this instance is handed out — `init` runs before anything else can reach it. Every + private method here assumes it is already running on this queue. + Non-private so test helpers can synchronize with it. + */ + let dispatchQueue = DispatchQueue(label: "OneSignal.OSOperationRepo", target: .global()) - // The Operation Repo dispatch queue, serial. This synchronizes access to `deltaQueue` and flushing behavior. - private let dispatchQueue = DispatchQueue(label: "OneSignal.OSOperationRepo", target: .global()) + private var hasCalledStart = false + private var hasBegunObserving = false - // Maps delta names to the interfaces for the operation executors var deltasToExecutorMap: [String: OSOperationExecutor] = [:] var executors: [OSOperationExecutor] = [] var deltaQueue: [OSDelta] = [] // non-private for unit test access @@ -48,35 +57,96 @@ public class OSOperationRepo: NSObject { var pollIntervalMilliseconds = Int(POLL_INTERVAL_MS) public var paused = false + // Uncache in init so an enqueue before start cannot persist over a previous session's queue. + public init(identityVerificationService: OSIdentityVerificationService) { + self.identityVerificationService = identityVerificationService + super.init() + uncacheDeltaQueue() + } + /** - Initilize this Operation Repo. Read from the cache. Executors may not be available by this time. - If everything starts up on initialize(), order can matter, ideally not but it can. - Likely call init on this from oneSignal but exeuctors can come from diff modules. + Re-reads the cache while the in-memory queue is still empty. `init` can run during prewarm before + first unlock, when UserDefaults silently returns nothing — same gap as `OSModelStore.refresh`. */ + public func refreshIfEmpty() { + dispatchQueue.async { + guard self.deltaQueue.isEmpty else { + return + } + self.uncacheDeltaQueue() + } + } + + private func uncacheDeltaQueue() { + guard let cached = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSOperationRepo is unable to uncache the OSDelta queue.") + return + } + deltaQueue = cached + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSOperationRepo uncached deltaQueue: \(cached)") + } + public func start() { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { return } + dispatchQueue.async { + self.startPolling() + } + } + + /** + While `requirement` is unknown, returns without setting `hasCalledStart` so hydration can call + `start()` again once remote params answer. + */ + private func startPolling() { guard !hasCalledStart else { return } + + beginObserving() + + guard identityVerificationService.requirement != .unknown else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSOperationRepo.start() deferred until the Identity Verification requirement is known") + return + } hasCalledStart = true OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSOperationRepo calling start()") - // register as user observer + pollFlushQueue() + } + + // Subscribe ahead of the requirement gate so a never-hydrated session still hears late hydration. + private func beginObserving() { + guard !hasBegunObserving else { + return + } + hasBegunObserving = true + NotificationCenter.default.addObserver(self, selector: #selector(self.addFlushDeltaQueueToDispatchQueue), name: Notification.Name(OS_ON_USER_WILL_CHANGE), object: nil) - // Read the Deltas from cache, if any... - if let deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] { - self.deltaQueue = deltaQueue - OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSOperationRepo.start() with deltaQueue: \(deltaQueue)") - } else { - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSOperationRepo.start() is unable to uncache the OSDelta queue.") + + // Callback rather than a repo dependency, which would cycle. + identityVerificationService.addOnJwtConfigHydratedHandler(for: .operationRepo) { [weak self] _ in + self?.onJwtConfigHydrated() } + } - pollFlushQueue() + /** + Runs on every hydration, including an unchanged value — deferred work is waiting on it. + + Hop onto `dispatchQueue`: `hydrate` calls this from whichever thread received remote params, and a + handler registered when the requirement is already cached fires synchronously from inside + `startPolling()`, where the hop defers this until that call finishes. + */ + private func onJwtConfigHydrated() { + dispatchQueue.async { + // Flush now rather than wait out a poll interval for work held since launch. + self.startPolling() + self.flushDeltaQueue() + } } private func pollFlushQueue() { @@ -87,23 +157,26 @@ public class OSOperationRepo: NSObject { } /** - Add and start an executor. + Registers before starting rather than after: once remote params are cached, `startPolling()` fires + the hydration handler synchronously and that flush reads the registry being written here. */ public func addExecutor(_ executor: OSOperationExecutor) { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { return } - start() - executors.append(executor) - for delta in executor.supportedDeltas { - deltasToExecutorMap[delta] = executor + dispatchQueue.async { + self.executors.append(executor) + for delta in executor.supportedDeltas { + self.deltasToExecutorMap[delta] = executor + } + self.startPolling() } } /** Enqueueing is driven by model changes and called manually by the User Manager to add session time, session count and purchase data. - + // TODO: We can make this method internal once there is no manual adding of a Delta except through stores. This can happen when session data and purchase data use the model / store / listener infrastructure. */ @@ -111,12 +184,19 @@ public class OSOperationRepo: NSObject { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { return } - start() + self.dispatchQueue.async { + self.startPolling() + + // Drop here too so it is never persisted; flush still covers deltas restored from cache. + guard !self.shouldDropAnonymousDelta(delta, ivActive: self.shouldDropAnonymousDeltas) else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSOperationRepo dropping anonymous Delta, Identity Verification is required: \(delta)") + return + } + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSOperationRepo enqueueDelta: \(delta)") self.deltaQueue.append(delta) - // Persist the deltas (including new delta) to storage OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, withValue: self.deltaQueue) if flush { @@ -131,6 +211,26 @@ public class OSOperationRepo: NSObject { } } + /// An anonymous Delta can never be signed, so drop it while Identity Verification is active. + private var shouldDropAnonymousDeltas: Bool { + return identityVerificationService.ivBehaviorActive + } + + /** + `OS_UPDATE_SUBSCRIPTION_DELTA` is exempt. In practice it is only ever the device's own push + subscription — nothing updates an email or SMS subscription model — and that channel exists before + any login and outlives every logout, so its token and device state have to keep flowing whether or + not a user is identified. Its endpoint is addressed by subscription ID and takes no user JWT. + + That leaves the exemption resting on the invariant that email and SMS subscriptions are only ever + added and removed, never updated. Should an update path for them appear, this has to narrow to the + push type, which the repo cannot see from here: `OSSubscriptionModel` lives in OneSignalUser, so + the Delta would have to carry the distinction the way it carries `externalId`. + */ + private func shouldDropAnonymousDelta(_ delta: OSDelta, ivActive: Bool) -> Bool { + return ivActive && delta.externalId == nil && delta.name != OS_UPDATE_SUBSCRIPTION_DELTA + } + private func flushDeltaQueue(inBackground: Bool = false) { guard !paused else { OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSOperationRepo not flushing queue due to being paused") @@ -141,29 +241,55 @@ public class OSOperationRepo: NSObject { return } + // Before the requirement gate so a first flush still registers the hydration handler. + self.startPolling() + + /* + Hold until `requirement` is known. `newCodePathsRun` / `ivBehaviorActive` both read false while + it is unknown. + */ + guard identityVerificationService.requirement != .unknown else { + let heldCount = self.deltaQueue.count + if heldCount > 0 { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSOperationRepo holding \(heldCount) Deltas until the requirement is known") + } + return + } + if inBackground { OSBackgroundTaskManager.beginBackgroundTask(OPERATION_REPO_BACKGROUND_TASK) } - self.start() - if !self.deltaQueue.isEmpty { OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSOperationRepo flushDeltaQueue in background: \(inBackground) with queue: \(self.deltaQueue)") } + // Snapshot once so every Delta in this pass sees the same gate values. + let dropAnonymous = shouldDropAnonymousDeltas + var unmatched: [OSDelta] = [] for delta in self.deltaQueue { - if let executor = self.deltasToExecutorMap[delta.name] { + if shouldDropAnonymousDelta(delta, ivActive: dropAnonymous) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSOperationRepo dropping anonymous Delta, Identity Verification is required: \(delta)") + } else if let executor = self.deltasToExecutorMap[delta.name] { executor.enqueueDelta(delta) } else { // Keep if no executor matches yet (module may not have started). unmatched.append(delta) } } - self.deltaQueue = unmatched + // Persist only when the queue changed: a no-op write before `refreshIfEmpty` can clobber a + // cache that prewarm failed to read. + if unmatched.count != self.deltaQueue.count { + self.deltaQueue = unmatched + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + } - // Persist the deltas (including removed deltas) to storage after they are divvy'd up to executors. - OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + if dropAnonymous { + for executor in self.executors { + executor.removeOperationsWithoutExternalId() + } + } for executor in self.executors { executor.cacheDeltaQueue() diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSUrlPath.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSUrlPath.swift new file mode 100644 index 000000000..e104d7d8d --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSUrlPath.swift @@ -0,0 +1,47 @@ +/* + 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 Foundation + +/// Shared path-segment encoding for Swift and ObjC request builders. +@objc(OSUrlPath) +public final class OSUrlPath: NSObject { + /** + Returns `value` percent-encoded for use as one path segment, or nil if it cannot be encoded. + + `urlUserAllowed` rather than `urlPathAllowed`, which leaves `/` alone: the values the SDK + interpolates into a path — `external_id`, alias labels, Live Activity types — come from the app, + and one containing a slash, `?`, `#` or `%` would otherwise reach a different endpoint than intended. + + Encode once, where the path is built. A value that has already been through this comes back with its + `%` escaped again. + */ + @objc(segment:) + public static func segment(_ value: String) -> String? { + return value.addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift index 1f8cc7a27..4dae89435 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift @@ -31,10 +31,6 @@ import OneSignalCore @objc public class OSCoreMocks: NSObject { - public static func resetOperationRepo() { - OSOperationRepo.sharedInstance.reset() - } - /// Puts the shared JWT config back to unhydrated. public static func resetSharedJwtConfig() { OSUserJwtConfig.shared.resetRequirementToUnknownForTests() @@ -48,13 +44,15 @@ public class OSCoreMocks: NSObject { extension OSOperationRepo { /** - The Operation Repo needs to reset between tests until we dependency inject the Operation Repo, - to prevent state from carrying over between tests. + Clears queue state between tests that reach the repo through the User Manager singleton. + Leaves `hasCalledStart` alone so the next `start()` does not schedule a second poller. */ - func reset() { - deltaQueue.removeAll() - executors.removeAll() - deltasToExecutorMap.removeAll() + public func reset() { + dispatchQueue.sync { + deltaQueue.removeAll() + executors.removeAll() + deltasToExecutorMap.removeAll() + } paused = false } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift index de6859a1d..0257b9260 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift @@ -1,7 +1,7 @@ /* Modified MIT License - Copyright 2026 OneSignal + Copyright 2025 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 @@ -37,17 +37,26 @@ final class OSOperationRepoFlushTests: XCTestCase { private let knownDelta = "test_known_delta" private let unknownDelta = "test_unknown_delta" + private var jwtConfig = OSUserJwtConfig() + private var repo: OSOperationRepo! + override func setUp() { super.setUp() OneSignalIdentifiers.currentAppId = "test-app-id" - resetOperationRepo() - // Pause so the poller (started by addExecutor/start) cannot flush mid-setup. - OSOperationRepo.sharedInstance.paused = true - OSOperationRepo.sharedInstance.pollIntervalMilliseconds = 60_000 + OSOperationRepoTestEnvironment.clearCache() + + jwtConfig = OSUserJwtConfig() + // Hydrate `off` before building the repo so routing tests are not held by unknown-requirement. + jwtConfig.hydrate(requiresUserAuth: false) + repo = OSOperationRepoTestEnvironment.makeRepo(jwtConfig: jwtConfig) + + // Pause so addExecutor/start cannot flush mid-setup. + repo.paused = true + repo.pollIntervalMilliseconds = 60_000 } override func tearDown() { - resetOperationRepo() + OSOperationRepoTestEnvironment.clearCache() super.tearDown() } @@ -56,21 +65,18 @@ final class OSOperationRepoFlushTests: XCTestCase { let processExpectation = expectation(description: "processDeltaQueue") executor.onProcessDeltaQueue = { processExpectation.fulfill() } - let repo = OSOperationRepo.sharedInstance repo.addExecutor(executor) - let deltaA = makeDelta(name: knownDelta, property: "a") - let deltaB = makeDelta(name: knownDelta, property: "b") - repo.enqueueDelta(deltaA) - repo.enqueueDelta(deltaB) - waitUntil("both deltas enqueued") { repo.deltaQueue.count == 2 } + repo.enqueueDelta(makeDelta(name: knownDelta, property: "a")) + repo.enqueueDelta(makeDelta(name: knownDelta, property: "b")) + waitUntil("both deltas enqueued") { self.repo.snapshotDeltaQueue().count == 2 } repo.paused = false repo.addFlushDeltaQueueToDispatchQueue() wait(for: [processExpectation], timeout: 2.0) XCTAssertEqual(executor.enqueued.map(\.property), ["a", "b"]) - XCTAssertTrue(repo.deltaQueue.isEmpty) + XCTAssertTrue(repo.snapshotDeltaQueue().isEmpty) } func testFlush_keepsUnmatchedDeltasInRepoQueue() { @@ -78,21 +84,18 @@ final class OSOperationRepoFlushTests: XCTestCase { let processExpectation = expectation(description: "processDeltaQueue") executor.onProcessDeltaQueue = { processExpectation.fulfill() } - let repo = OSOperationRepo.sharedInstance repo.addExecutor(executor) - let deltaA = makeDelta(name: unknownDelta, property: "a") - let deltaB = makeDelta(name: unknownDelta, property: "b") - repo.enqueueDelta(deltaA) - repo.enqueueDelta(deltaB) - waitUntil("both deltas enqueued") { repo.deltaQueue.count == 2 } + repo.enqueueDelta(makeDelta(name: unknownDelta, property: "a")) + repo.enqueueDelta(makeDelta(name: unknownDelta, property: "b")) + waitUntil("both deltas enqueued") { self.repo.snapshotDeltaQueue().count == 2 } repo.paused = false repo.addFlushDeltaQueueToDispatchQueue() wait(for: [processExpectation], timeout: 2.0) XCTAssertTrue(executor.enqueued.isEmpty) - XCTAssertEqual(repo.deltaQueue.map(\.property), ["a", "b"]) + XCTAssertEqual(repo.snapshotDeltaQueue().map(\.property), ["a", "b"]) } func testFlush_routesMatchedAndPreservesUnmatchedOrder() { @@ -100,7 +103,6 @@ final class OSOperationRepoFlushTests: XCTestCase { let processExpectation = expectation(description: "processDeltaQueue") executor.onProcessDeltaQueue = { processExpectation.fulfill() } - let repo = OSOperationRepo.sharedInstance repo.addExecutor(executor) // Interleaved matched/unmatched: assert dispatch order and retained queue order. @@ -109,54 +111,44 @@ final class OSOperationRepoFlushTests: XCTestCase { repo.enqueueDelta(makeDelta(name: knownDelta, property: "known-2")) repo.enqueueDelta(makeDelta(name: unknownDelta, property: "unknown-2")) repo.enqueueDelta(makeDelta(name: knownDelta, property: "known-3")) - waitUntil("all deltas enqueued") { repo.deltaQueue.count == 5 } + waitUntil("all deltas enqueued") { self.repo.snapshotDeltaQueue().count == 5 } repo.paused = false repo.addFlushDeltaQueueToDispatchQueue() wait(for: [processExpectation], timeout: 2.0) XCTAssertEqual(executor.enqueued.map(\.property), ["known-1", "known-2", "known-3"]) - XCTAssertEqual(repo.deltaQueue.map(\.property), ["unknown-1", "unknown-2"]) + XCTAssertEqual(repo.snapshotDeltaQueue().map(\.property), ["unknown-1", "unknown-2"]) } - // MARK: - Helpers + /** + Registration used to write the executor list and the name map on the caller's thread, where a + flush or another registration could tear them. Every executor added concurrently has to end up + routable. + */ + func testEveryExecutorRegisteredConcurrentlyIsRoutable() { + let names = (0..<50).map { "concurrent_delta_\($0)" } + let executors = names.map { MockOperationExecutor(supportedDeltas: [$0]) } - private func resetOperationRepo() { - let repo = OSOperationRepo.sharedInstance - repo.deltaQueue.removeAll() - repo.executors.removeAll() - repo.deltasToExecutorMap.removeAll() - repo.paused = false - } + DispatchQueue.concurrentPerform(iterations: executors.count) { index in + self.repo.addExecutor(executors[index]) + } - private func makeDelta(name: String, property: String) -> OSDelta { - OSDelta( - name: name, - identityModelId: UUID().uuidString, - externalId: nil, - model: OSModel(changeNotifier: OSEventProducer()), - property: property, - value: property - ) - } -} - -private final class MockOperationExecutor: OSOperationExecutor { - let supportedDeltas: [String] - private(set) var enqueued: [OSDelta] = [] - var onProcessDeltaQueue: (() -> Void)? + for name in names { + repo.enqueueDelta(makeDelta(name: name, property: name)) + } + waitUntil("all deltas enqueued") { self.repo.snapshotDeltaQueue().count == names.count } - init(supportedDeltas: [String]) { - self.supportedDeltas = supportedDeltas - } + repo.paused = false + repo.addFlushDeltaQueueToDispatchQueue() - func enqueueDelta(_ delta: OSDelta) { - enqueued.append(delta) + waitUntil("all deltas routed") { self.repo.snapshotDeltaQueue().isEmpty } + XCTAssertEqual(executors.map { $0.enqueued.map(\.property) }, names.map { [$0] }) } - func cacheDeltaQueue() {} + // MARK: - Helpers - func processDeltaQueue(inBackground: Bool) { - onProcessDeltaQueue?() + private func makeDelta(name: String, property: String) -> OSDelta { + OSOperationRepoTestEnvironment.makeDelta(name: name, externalId: nil, property: property) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift new file mode 100644 index 000000000..8e748b0ef --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift @@ -0,0 +1,266 @@ +/* + Modified MIT License + + Copyright 2025 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 Foundation +import XCTest +import OneSignalCore +@testable import OneSignalOSCore + +/// Covers Operation Repo Identity Verification: hold until `requirement` is known, drop unsigned work. +final class OSOperationRepoIdentityVerificationTests: XCTestCase { + + private let deltaName = "test_delta" + + private var jwtConfig = OSUserJwtConfig() + private var featureManager = OSFeatureManager(enabledKeys: []) + + override func setUp() { + super.setUp() + OneSignalIdentifiers.currentAppId = "test-app-id" + OSOperationRepoTestEnvironment.clearCache() + jwtConfig = OSUserJwtConfig() + featureManager = OSFeatureManager(enabledKeys: []) + } + + override func tearDown() { + OSOperationRepoTestEnvironment.clearCache() + super.tearDown() + } + + // MARK: - Deferral while the requirement is unknown + + func testNothingFlushesWhileTheRequirementIsUnknown() { + let repo = makeRepo() + let executor = MockOperationExecutor(supportedDeltas: [deltaName]) + let notProcessed = expectation(description: "processDeltaQueue is not called") + notProcessed.isInverted = true + executor.onProcessDeltaQueue = { notProcessed.fulfill() } + repo.addExecutor(executor) + + repo.enqueueDelta(makeDelta(externalId: "user-1", property: "a")) + repo.enqueueDelta(makeDelta(externalId: "user-1", property: "b")) + waitUntil("both deltas enqueued") { repo.snapshotDeltaQueue().count == 2 } + + repo.addFlushDeltaQueueToDispatchQueue() + + wait(for: [notProcessed], timeout: 0.5) + XCTAssertTrue(executor.enqueued.isEmpty) + XCTAssertEqual(repo.snapshotDeltaQueue().map(\.property), ["a", "b"]) + } + + /// Enqueue still persists while unknown; only flush waits. + func testDeltasEnqueuedWhileTheRequirementIsUnknownArePersisted() { + let repo = makeRepo() + repo.enqueueDelta(makeDelta(externalId: "user-1", property: "a")) + waitUntil("delta enqueued") { repo.snapshotDeltaQueue().count == 1 } + + let cached = OSOperationRepoTestEnvironment.cachedDeltaQueue() + XCTAssertEqual(cached?.map(\.property), ["a"]) + } + + /// Hydration must flush immediately rather than wait out a poll interval. + func testHydratingTheRequirementReleasesHeldDeltasImmediately() { + let repo = makeRepo() + let executor = MockOperationExecutor(supportedDeltas: [deltaName]) + let processed = flushExpectation(on: executor) + repo.addExecutor(executor) + + repo.enqueueDelta(makeDelta(externalId: "user-1", property: "a")) + waitUntil("delta enqueued") { repo.snapshotDeltaQueue().count == 1 } + + jwtConfig.hydrate(requiresUserAuth: false) + + wait(for: [processed], timeout: 2.0) + XCTAssertEqual(executor.enqueued.map(\.property), ["a"]) + XCTAssertTrue(repo.snapshotDeltaQueue().isEmpty) + } + + // MARK: - Anonymous suppression + + /// No `externalId` means nothing to sign with, so drop at enqueue while IV is required. + func testAnonymousDeltasAreDroppedAtEnqueueWhileIdentityVerificationIsRequired() { + // Hydrate first and pause so this asserts the enqueue drop, not a flush. + jwtConfig.hydrate(requiresUserAuth: true) + let repo = makeRepo() + repo.paused = true + + repo.enqueueDelta(makeDelta(externalId: nil, property: "anonymous")) + repo.enqueueDelta(makeDelta(externalId: "user-1", property: "identified")) + + // Identified Delta is the sync point; the queue is serial. + waitUntil("identified delta enqueued") { repo.snapshotDeltaQueue().count == 1 } + XCTAssertEqual(repo.snapshotDeltaQueue().map(\.property), ["identified"]) + } + + /// Flush must drop restored anonymous Deltas; they never pass through enqueue. + func testAnonymousDeltasRestoredFromTheCacheAreDroppedAtFlush() { + OSOperationRepoTestEnvironment.seedCachedDeltaQueue([ + makeDelta(externalId: nil, property: "anonymous"), + makeDelta(externalId: "user-1", property: "identified") + ]) + + let repo = makeRepo() + XCTAssertEqual(repo.snapshotDeltaQueue().count, 2, "the repo should restore both Deltas before judging them") + + let executor = MockOperationExecutor(supportedDeltas: [deltaName]) + let processed = flushExpectation(on: executor) + repo.addExecutor(executor) + + jwtConfig.hydrate(requiresUserAuth: true) + + wait(for: [processed], timeout: 2.0) + XCTAssertEqual(executor.enqueued.map(\.property), ["identified"]) + XCTAssertTrue(repo.snapshotDeltaQueue().isEmpty) + } + + /** + The push subscription has no owner to sign for before login or after logout, and its updates still + have to go out, so `OS_UPDATE_SUBSCRIPTION_DELTA` survives the enqueue drop. + */ + func testAnonymousSubscriptionUpdatesAreExemptFromTheEnqueueDrop() { + jwtConfig.hydrate(requiresUserAuth: true) + let repo = makeRepo() + repo.paused = true + + repo.enqueueDelta(OSOperationRepoTestEnvironment.makeDelta(name: OS_UPDATE_SUBSCRIPTION_DELTA, externalId: nil, property: "token")) + repo.enqueueDelta(makeDelta(externalId: nil, property: "anonymous")) + repo.enqueueDelta(makeDelta(externalId: "user-1", property: "identified")) + + // The identified Delta is the sync point; the queue is serial. + waitUntil("identified delta enqueued") { repo.snapshotDeltaQueue().count == 2 } + XCTAssertEqual(repo.snapshotDeltaQueue().map(\.property), ["token", "identified"]) + } + + /// Same exemption for a Delta restored from a previous session, which never passes through enqueue. + func testAnonymousSubscriptionUpdatesAreExemptFromTheFlushDrop() { + OSOperationRepoTestEnvironment.seedCachedDeltaQueue([ + OSOperationRepoTestEnvironment.makeDelta(name: OS_UPDATE_SUBSCRIPTION_DELTA, externalId: nil, property: "token"), + makeDelta(externalId: nil, property: "anonymous") + ]) + + let repo = makeRepo() + let executor = MockOperationExecutor(supportedDeltas: [OS_UPDATE_SUBSCRIPTION_DELTA, deltaName]) + let processed = flushExpectation(on: executor) + repo.addExecutor(executor) + + jwtConfig.hydrate(requiresUserAuth: true) + + wait(for: [processed], timeout: 2.0) + XCTAssertEqual(executor.enqueued.map(\.property), ["token"]) + } + + /// The rollout flag alone must not suppress; only `jwt_required` turns it on. + func testAnonymousDeltasSurviveWhenTheFlagIsOnButTheAppDoesNotRequireAuth() { + featureManager = OSFeatureManager(enabledKeys: [OSFeatureFlag.identityVerification.rawValue]) + + let repo = makeRepo() + let executor = MockOperationExecutor(supportedDeltas: [deltaName]) + let processed = flushExpectation(on: executor) + repo.addExecutor(executor) + + repo.enqueueDelta(makeDelta(externalId: nil, property: "anonymous")) + waitUntil("delta enqueued") { repo.snapshotDeltaQueue().count == 1 } + + jwtConfig.hydrate(requiresUserAuth: false) + + wait(for: [processed], timeout: 2.0) + XCTAssertEqual(executor.enqueued.map(\.property), ["anonymous"]) + } + + // MARK: - Purge when the requirement arrives as required + + /// Unsupported delta name on purpose so survival is by `externalId`, not routing. + func testLearningThatAuthIsRequiredDropsAnonymousDeltasAndKeepsIdentifiedOnes() { + OSOperationRepoTestEnvironment.seedCachedDeltaQueue([ + makeDelta(externalId: nil, property: "anonymous"), + makeDelta(externalId: "user-1", property: "identified") + ]) + + let repo = makeRepo() + let executor = MockOperationExecutor(supportedDeltas: ["some_other_delta"]) + let processed = flushExpectation(on: executor) + repo.addExecutor(executor) + + jwtConfig.hydrate(requiresUserAuth: true) + + wait(for: [processed], timeout: 2.0) + XCTAssertEqual(repo.snapshotDeltaQueue().map(\.property), ["identified"]) + + let cached = OSOperationRepoTestEnvironment.cachedDeltaQueue() + XCTAssertEqual(cached?.map(\.property), ["identified"], "the drop has to survive a restart") + } + + /// Executor caches hold last session's deltas, so the purge must reach them too. + func testFlushingWhileAuthIsRequiredDrivesThePurgeIntoExecutors() { + let repo = makeRepo() + let executor = MockOperationExecutor(supportedDeltas: [deltaName]) + repo.addExecutor(executor) + + jwtConfig.hydrate(requiresUserAuth: true) + + waitUntil("executor asked to purge") { executor.removeOperationsWithoutExternalIdCallCount >= 1 } + } + + func testFlushingWhileAuthIsNotRequiredLeavesAnonymousDeltasAlone() { + OSOperationRepoTestEnvironment.seedCachedDeltaQueue([ + makeDelta(externalId: nil, property: "anonymous") + ]) + + let repo = makeRepo() + let executor = MockOperationExecutor(supportedDeltas: ["some_other_delta"]) + let processed = flushExpectation(on: executor) + repo.addExecutor(executor) + + jwtConfig.hydrate(requiresUserAuth: false) + + wait(for: [processed], timeout: 2.0) + XCTAssertEqual(repo.snapshotDeltaQueue().map(\.property), ["anonymous"]) + XCTAssertEqual(executor.removeOperationsWithoutExternalIdCallCount, 0) + } + + // MARK: - Helpers + + /** + Fulfills once the executor is asked to process. Repeats are allowed: a handler that registers while + `hydrate` is running is delivered both by the fire and by `addOnJwtConfigHydratedHandler`'s catch-up, + so the same hydration can flush twice. These tests assert on what the flush did, not on how many ran. + */ + private func flushExpectation(on executor: MockOperationExecutor) -> XCTestExpectation { + let processed = expectation(description: "processDeltaQueue") + processed.assertForOverFulfill = false + executor.onProcessDeltaQueue = { processed.fulfill() } + return processed + } + + private func makeRepo() -> OSOperationRepo { + return OSOperationRepoTestEnvironment.makeRepo(jwtConfig: jwtConfig, featureManager: featureManager) + } + + private func makeDelta(externalId: String?, property: String) -> OSDelta { + return OSOperationRepoTestEnvironment.makeDelta(name: deltaName, externalId: externalId, property: property) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift new file mode 100644 index 000000000..0c6e4beac --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift @@ -0,0 +1,107 @@ +/* + Modified MIT License + + Copyright 2025 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 Foundation +import XCTest +import OneSignalCore +@testable import OneSignalOSCore + +/** + Builds an Operation Repo per test. Clear the cache before constructing one (`init` uncachees); + seed the cache first when the test starts from a restored queue. + */ +enum OSOperationRepoTestEnvironment { + static func clearCache() { + OneSignalUserDefaults.initShared().removeValue(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY) + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_USE_IDENTITY_VERIFICATION) + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_FEATURE_FLAGS) + } + + static func seedCachedDeltaQueue(_ deltas: [OSDelta]) { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, withValue: deltas) + } + + static func cachedDeltaQueue() -> [OSDelta]? { + let key = OS_OPERATION_REPO_DELTA_QUEUE_KEY + return OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: key, defaultValue: []) as? [OSDelta] + } + + // Pin the poller out of reach: DEBUG uses 100ms and would flush underneath expectations. + static func makeRepo(jwtConfig: OSUserJwtConfig, featureManager: OSFeatureManager = OSFeatureManager(enabledKeys: [])) -> OSOperationRepo { + let service = OSIdentityVerificationService(featureManager: featureManager, jwtConfig: jwtConfig) + let repo = OSOperationRepo(identityVerificationService: service) + repo.pollIntervalMilliseconds = 60_000 + return repo + } + + static func makeDelta(name: String, externalId: String?, property: String) -> OSDelta { + return OSDelta( + name: name, + identityModelId: UUID().uuidString, + externalId: externalId, + model: OSModel(changeNotifier: OSEventProducer()), + property: property, + value: property + ) + } +} + +extension OSOperationRepo { + /** + The queue as of right now. Tests poll it while the repo appends on its own queue, so reading + `deltaQueue` directly is a data race even when only the count is wanted. + */ + func snapshotDeltaQueue() -> [OSDelta] { + return dispatchQueue.sync { deltaQueue } + } +} + +/// Records what the Operation Repo hands it, so tests can assert on routing rather than on requests. +final class MockOperationExecutor: OSOperationExecutor { + let supportedDeltas: [String] + private(set) var enqueued: [OSDelta] = [] + private(set) var removeOperationsWithoutExternalIdCallCount = 0 + var onProcessDeltaQueue: (() -> Void)? + + init(supportedDeltas: [String]) { + self.supportedDeltas = supportedDeltas + } + + func enqueueDelta(_ delta: OSDelta) { + enqueued.append(delta) + } + + func cacheDeltaQueue() {} + + func processDeltaQueue(inBackground: Bool) { + onProcessDeltaQueue?() + } + + func removeOperationsWithoutExternalId() { + removeOperationsWithoutExternalIdCallCount += 1 + } +} \ No newline at end of file diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift index 7feb01a05..e6c372e61 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift @@ -49,12 +49,14 @@ class OSCustomEventsExecutor: OSOperationExecutor { private var deltaQueue: [OSDelta] = [] private var requestQueue: [OSRequestCustomEvents] = [] private let newRecordsState: OSNewRecordsState + private let auth: OSRequestAuthorizing // The executor dispatch queue, serial. This synchronizes access to `deltaQueue` and `requestQueue`. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSCustomEventsExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState) { + init(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) { self.newRecordsState = newRecordsState + self.auth = auth // Read unfinished deltas and requests from cache, if any... uncacheDeltas() uncacheRequests() @@ -85,8 +87,8 @@ class OSCustomEventsExecutor: OSOperationExecutor { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { // 1. The identity model exist in the repo, set it to be the Request's model request.identityModel = identityModel - } else if request.prepareForExecution(newRecordsState: newRecordsState) { - // 2. The request can be sent, add the model to the repo + } else if request.ownerExternalId != nil || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // 2. The Request is owned, so a token can still arrive for it, or it can be sent as is; add the model to the repo OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) } else { // 3. The identitymodel do not exist AND this request cannot be sent, drop this Request @@ -116,6 +118,24 @@ class OSCustomEventsExecutor: OSOperationExecutor { } } + func removeOperationsWithoutExternalId() { + self.dispatchQueue.async { + let remainingDeltas = self.deltaQueue.filter { $0.externalId != nil } + if remainingDeltas.count != self.deltaQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSCustomEventsExecutor dropped \(self.deltaQueue.count - remainingDeltas.count) anonymous Deltas, Identity Verification is required") + self.deltaQueue = remainingDeltas + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + } + + let remainingRequests = self.requestQueue.filter { $0.ownerExternalId != nil } + if remainingRequests.count != self.requestQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSCustomEventsExecutor dropped \(self.requestQueue.count - remainingRequests.count) anonymous Requests, Identity Verification is required") + self.requestQueue = remainingRequests + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, withValue: self.requestQueue) + } + } + } + /// The `deltaQueue` can contain events for multiple users. They will remain as Deltas if there is no onesignal ID yet for its user. /// This method will be used in an upcoming release that combine multiple events. func processDeltaQueueWithBatching(inBackground: Bool) { @@ -127,8 +147,8 @@ class OSCustomEventsExecutor: OSOperationExecutor { } OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSCustomEventsExecutor processDeltaQueue with queue: \(self.deltaQueue)") - // Holds mapping of identity model ID to the events for it - var combinedEvents: [String: [[String: Any]]] = [:] + // Holds mapping of identity model ID to the events for it, with the owner the Deltas stamped + var combinedEvents: [String: (events: [[String: Any]], ownerExternalId: String?)] = [:] // 1. Combine the events for every distinct user for (index, delta) in self.deltaQueue.enumerated().reversed() { @@ -154,20 +174,21 @@ class OSCustomEventsExecutor: OSOperationExecutor { EventConstants.payload: self.addSdkMetadata(properties: properties) ] - combinedEvents[identityModel.modelId, default: []].append(event) + combinedEvents[identityModel.modelId, default: ([], delta.externalId)].events.append(event) self.deltaQueue.remove(at: index) } // 2. Turn each user's events into a Request - for (modelId, events) in combinedEvents { + for (modelId, combined) in combinedEvents { guard let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(modelId) else { // This should never happen as we already checked this during Deltas processing above continue } let request = OSRequestCustomEvents( - events: events, - identityModel: identityModel + events: combined.events, + identityModel: identityModel, + ownerExternalId: combined.ownerExternalId ) self.requestQueue.append(request) } @@ -216,7 +237,8 @@ class OSCustomEventsExecutor: OSOperationExecutor { let request = OSRequestCustomEvents( events: [event], - identityModel: identityModel + identityModel: identityModel, + ownerExternalId: delta.externalId ) self.requestQueue.append(request) } @@ -262,7 +284,7 @@ class OSCustomEventsExecutor: OSOperationExecutor { guard !request.sentToClient else { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { return } request.sentToClient = true @@ -284,7 +306,9 @@ class OSCustomEventsExecutor: OSOperationExecutor { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSCustomEventsExecutor request failed with error: \(error.debugDescription)") self.dispatchQueue.async { let responseType = OSNetworkingUtils.getResponseStatusType(error.code) - if responseType != .retryable { + if responseType == .unauthorized, self.auth.handleUnauthorized(request) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSCustomEventsExecutor holding \(request) for a new token") + } else if responseType != .retryable { // Fail, no retry, remove from cache and queue self.requestQueue.removeAll(where: { $0 == request}) OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, withValue: self.requestQueue) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift index 516ca0c02..46f357854 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift @@ -35,12 +35,14 @@ class OSIdentityOperationExecutor: OSOperationExecutor { private var addRequestQueue: [OSRequestAddAliases] = [] private var removeRequestQueue: [OSRequestRemoveAlias] = [] private let newRecordsState: OSNewRecordsState + private let auth: OSRequestAuthorizing // The Identity executor dispatch queue, serial. This synchronizes access to the delta and request queues. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSIdentityOperationExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState) { + init(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) { self.newRecordsState = newRecordsState + self.auth = auth // Read unfinished deltas and requests from cache, if any... uncacheDeltas() uncacheAddAliasRequests() @@ -74,9 +76,9 @@ class OSIdentityOperationExecutor: OSOperationExecutor { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { // 1. The model exists in the repo, so set it to be the Request's models request.identityModel = identityModel - } else if request.prepareForExecution(newRecordsState: newRecordsState) { - // 2. The request can be sent, add the model to the repo - OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) + } else if request.ownerExternalId != nil || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // 2. The Request is owned, so a token can still arrive for it, or it can be sent as is; add the model to the repo + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) } else { // 3. The model do not exist AND this request cannot be sent, drop this Request OneSignalLog.onesignalLog(.LL_ERROR, message: "OSIdentityOperationExecutor.init dropped \(request)") @@ -97,8 +99,8 @@ class OSIdentityOperationExecutor: OSOperationExecutor { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { // 1. The model exists in the repo, so set it to be the Request's model request.identityModel = identityModel - } else if request.prepareForExecution(newRecordsState: newRecordsState) { - // 2. The request can be sent, add the model to the repo + } else if request.ownerExternalId != nil || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // 2. The Request is owned, so a token can still arrive for it, or it can be sent as is; add the model to the repo OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) } else { // 3. The model does not exist AND this request cannot be sent, drop this Request @@ -126,6 +128,31 @@ class OSIdentityOperationExecutor: OSOperationExecutor { } } + func removeOperationsWithoutExternalId() { + self.dispatchQueue.async { + let remainingDeltas = self.deltaQueue.filter { $0.externalId != nil } + if remainingDeltas.count != self.deltaQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSIdentityOperationExecutor dropped \(self.deltaQueue.count - remainingDeltas.count) anonymous Deltas, Identity Verification is required") + self.deltaQueue = remainingDeltas + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + } + + let remainingAdd = self.addRequestQueue.filter { $0.ownerExternalId != nil } + if remainingAdd.count != self.addRequestQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSIdentityOperationExecutor dropped \(self.addRequestQueue.count - remainingAdd.count) anonymous add Requests, Identity Verification is required") + self.addRequestQueue = remainingAdd + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) + } + + let remainingRemove = self.removeRequestQueue.filter { $0.ownerExternalId != nil } + if remainingRemove.count != self.removeRequestQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSIdentityOperationExecutor dropped \(self.removeRequestQueue.count - remainingRemove.count) anonymous remove Requests, Identity Verification is required") + self.removeRequestQueue = remainingRemove + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: self.removeRequestQueue) + } + } + } + func processDeltaQueue(inBackground: Bool) { self.dispatchQueue.async { if !self.deltaQueue.isEmpty { @@ -141,12 +168,12 @@ class OSIdentityOperationExecutor: OSOperationExecutor { switch delta.name { case OS_ADD_ALIAS_DELTA: - let request = OSRequestAddAliases(aliases: aliases, identityModel: model) + let request = OSRequestAddAliases(aliases: aliases, identityModel: model, ownerExternalId: delta.externalId) self.addRequestQueue.append(request) case OS_REMOVE_ALIAS_DELTA: for (label, _) in aliases { - let request = OSRequestRemoveAlias(labelToRemove: label, identityModel: model) + let request = OSRequestRemoveAlias(labelToRemove: label, identityModel: model, ownerExternalId: delta.externalId) self.removeRequestQueue.append(request) } @@ -193,7 +220,7 @@ class OSIdentityOperationExecutor: OSOperationExecutor { guard !request.sentToClient else { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { return } request.sentToClient = true @@ -234,6 +261,8 @@ class OSIdentityOperationExecutor: OSOperationExecutor { // The subscription has been deleted along with the user, so remove the subscription_id but keep the same push subscription model OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId = nil OneSignalUserManagerImpl.sharedInstance._logout() + } else if responseType == .unauthorized, self.auth.handleUnauthorized(request) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSIdentityOperationExecutor holding \(request) for a new token") } else if responseType != .retryable { // Fail, no retry, remove from cache and queue self.addRequestQueue.removeAll(where: { $0 == request}) @@ -250,7 +279,7 @@ class OSIdentityOperationExecutor: OSOperationExecutor { guard !request.sentToClient else { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { return } request.sentToClient = true @@ -276,7 +305,9 @@ class OSIdentityOperationExecutor: OSOperationExecutor { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSIdentityOperationExecutor remove alias request failed with error: \(error.debugDescription)") self.dispatchQueue.async { let responseType = OSNetworkingUtils.getResponseStatusType(error.code) - if responseType != .retryable { + if responseType == .unauthorized, self.auth.handleUnauthorized(request) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSIdentityOperationExecutor holding \(request) for a new token") + } else if responseType != .retryable { // Fail, no retry, remove from cache and queue // A response of .missing could mean the alias doesn't exist on this user OR this user has been deleted self.removeRequestQueue.removeAll(where: { $0 == request}) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift index 0df165df2..0ba3b65a4 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift @@ -35,6 +35,11 @@ private struct OSCombinedProperties { var location: OSLocationPoint? var refreshDeviceMetadata = false + /// Carried from the Deltas so the Request inherits their stamped owner. The Deltas combined here + /// share one Identity Model; the last one wins if that model gained an `external_id` partway, which + /// keeps the combined work rather than dropping it. + var ownerExternalId: String? + // Items of Properties Deltas var sessionTime: Int = 0 var sessionCount: Int = 0 @@ -65,12 +70,14 @@ class OSPropertyOperationExecutor: OSOperationExecutor { private var deltaQueue: [OSDelta] = [] private var updateRequestQueue: [OSRequestUpdateProperties] = [] private let newRecordsState: OSNewRecordsState + private let auth: OSRequestAuthorizing // The property executor dispatch queue, serial. This synchronizes access to `deltaQueue` and `updateRequestQueue`. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSPropertyOperationExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState) { + init(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) { self.newRecordsState = newRecordsState + self.auth = auth // Read unfinished deltas and requests from cache, if any... // Note that we should only have deltas for the current user as old ones are flushed.. uncacheDeltas() @@ -100,8 +107,8 @@ class OSPropertyOperationExecutor: OSOperationExecutor { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { // 1. The identity model exist in the repo, set it to be the Request's model request.identityModel = identityModel - } else if request.prepareForExecution(newRecordsState: newRecordsState) { - // 2. The request can be sent, add the model to the repo + } else if request.ownerExternalId != nil || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // 2. The Request is owned, so a token can still arrive for it, or it can be sent as is; add the model to the repo OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) } else { // 3. The identitymodel do not exist AND this request cannot be sent, drop this Request @@ -129,6 +136,24 @@ class OSPropertyOperationExecutor: OSOperationExecutor { } } + func removeOperationsWithoutExternalId() { + self.dispatchQueue.async { + let remainingDeltas = self.deltaQueue.filter { $0.externalId != nil } + if remainingDeltas.count != self.deltaQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSPropertyOperationExecutor dropped \(self.deltaQueue.count - remainingDeltas.count) anonymous Deltas, Identity Verification is required") + self.deltaQueue = remainingDeltas + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_PROPERTIES_EXECUTOR_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + } + + let remainingRequests = self.updateRequestQueue.filter { $0.ownerExternalId != nil } + if remainingRequests.count != self.updateRequestQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSPropertyOperationExecutor dropped \(self.updateRequestQueue.count - remainingRequests.count) anonymous Requests, Identity Verification is required") + self.updateRequestQueue = remainingRequests + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: self.updateRequestQueue) + } + } + } + /// The `deltaQueue` should only contain updates for one user. /// Even when login -> addTag -> login -> addTag are called in immediate succession. func processDeltaQueue(inBackground: Bool) { @@ -168,7 +193,8 @@ class OSPropertyOperationExecutor: OSOperationExecutor { } let request = OSRequestUpdateProperties( params: properties.jsonRepresentation(), - identityModel: identityModel + identityModel: identityModel, + ownerExternalId: properties.ownerExternalId ) self.updateRequestQueue.append(request) } @@ -186,6 +212,7 @@ class OSPropertyOperationExecutor: OSOperationExecutor { /// Helper method to combine the information in an `OSDelta` to the existing `OSCombinedProperties` so far. private func combineProperties(existing: OSCombinedProperties?, delta: OSDelta) -> OSCombinedProperties { var combinedProperties = existing ?? OSCombinedProperties() + combinedProperties.ownerExternalId = delta.externalId guard let property = OSPropertiesSupportedProperty(rawValue: delta.property) else { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSPropertyOperationExecutor.combineProperties dropped unsupported property: \(delta.property)") @@ -235,7 +262,7 @@ class OSPropertyOperationExecutor: OSOperationExecutor { guard !request.sentToClient else { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { return } request.sentToClient = true @@ -298,6 +325,8 @@ class OSPropertyOperationExecutor: OSOperationExecutor { // The subscription has been deleted along with the user, so remove the subscription_id but keep the same push subscription model OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId = nil OneSignalUserManagerImpl.sharedInstance._logout() + } else if responseType == .unauthorized, self.auth.handleUnauthorized(request) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSPropertyOperationExecutor holding \(request) for a new token") } else if responseType != .retryable { // Fail, no retry, remove from cache and queue self.updateRequestQueue.removeAll(where: { $0 == request}) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift index f8c985cce..70b0d6d75 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift @@ -37,12 +37,14 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { private var updateRequestQueue: [OSRequestUpdateSubscription] = [] private var subscriptionModels: [String: OSSubscriptionModel] = [:] private let newRecordsState: OSNewRecordsState + private let auth: OSRequestAuthorizing // The Subscription executor dispatch queue, serial. This synchronizes access to the delta and request queues. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSSubscriptionOperationExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState) { + init(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) { self.newRecordsState = newRecordsState + self.auth = auth // Read unfinished deltas and requests from cache, if any... uncacheDeltas() uncacheCreateSubscriptionRequests() @@ -91,8 +93,8 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { // a. The model exist in the repo request.identityModel = identityModel - } else if request.prepareForExecution(newRecordsState: newRecordsState) { - // b. The request can be sent, add the model to the repo + } else if request.ownerExternalId != nil || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // b. The Request is owned, so a token can still arrive for it, or it can be sent as is; add the model to the repo OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) } else { // c. The model do not exist AND this request cannot be sent, drop this Request @@ -118,10 +120,12 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } else if let subscriptionModel = subscriptionModels[request.subscriptionModel.modelId] { // 2. The model exists in the dict of seen subscription models request.subscriptionModel = subscriptionModel - } else if !request.prepareForExecution(newRecordsState: newRecordsState) { - // 3. The model does not exist AND this request cannot be sent, drop this Request + } else if request.ownerExternalId == nil, + !request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // 3. The model does not exist AND no token can arrive to make this sendable, drop it OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionOperationExecutor.init dropped \(request)") removeRequestQueue.remove(at: index) + continue } } self.removeRequestQueue = removeRequestQueue @@ -141,11 +145,13 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } else if let subscriptionModel = subscriptionModels[request.subscriptionModel.modelId] { // 2. The model exists in the dict of seen subscription models request.subscriptionModel = subscriptionModel - } else if !request.prepareForExecution(newRecordsState: newRecordsState) { + } else if !request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { // 3. The models do not exist AND this request cannot be sent, drop this Request OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionOperationExecutor.init dropped \(request)") updateRequestQueue.remove(at: index) + continue } + request.identityModel = liveIdentityModel(request.identityModel) } self.updateRequestQueue = updateRequestQueue OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: self.updateRequestQueue) @@ -154,6 +160,21 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } } + /** + Returns the repo's instance for this Identity Model, registering the decoded one if missing, + so every request for a user shares one instance. + */ + private func liveIdentityModel(_ identityModel: OSIdentityModel?) -> OSIdentityModel? { + guard let identityModel = identityModel else { + return nil + } + if let modelInRepo = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(identityModel.modelId) { + return modelInRepo + } + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(identityModel) + return identityModel + } + /** Since there are 2 subscription stores, we need to check both stores for the model with a particular `modelId`. */ @@ -180,6 +201,39 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } } + /** + Drops anonymous add/remove Deltas and Requests. Updates are kept — in practice only the device's + own push subscription is ever updated, and that has to keep reporting with or without an identified + user. See `OSOperationRepo.shouldDropAnonymousDelta` for what the exemption rests on. `logout()`'s + unsubscribe travels in `updateRequestQueue`, which is also left alone. + */ + func removeOperationsWithoutExternalId() { + self.dispatchQueue.async { + let remainingDeltas = self.deltaQueue.filter { + $0.externalId != nil || $0.name == OS_UPDATE_SUBSCRIPTION_DELTA + } + if remainingDeltas.count != self.deltaQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSubscriptionOperationExecutor dropped \(self.deltaQueue.count - remainingDeltas.count) anonymous Deltas, Identity Verification is required") + self.deltaQueue = remainingDeltas + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + } + + let remainingAdd = self.addRequestQueue.filter { $0.ownerExternalId != nil } + if remainingAdd.count != self.addRequestQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSubscriptionOperationExecutor dropped \(self.addRequestQueue.count - remainingAdd.count) anonymous add Requests, Identity Verification is required") + self.addRequestQueue = remainingAdd + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) + } + + let remainingRemove = self.removeRequestQueue.filter { $0.ownerExternalId != nil } + if remainingRemove.count != self.removeRequestQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSubscriptionOperationExecutor dropped \(self.removeRequestQueue.count - remainingRemove.count) anonymous remove Requests, Identity Verification is required") + self.removeRequestQueue = remainingRemove + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: self.removeRequestQueue) + } + } + } + func processDeltaQueue(inBackground: Bool) { self.dispatchQueue.async { if !self.deltaQueue.isEmpty { @@ -192,13 +246,16 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { continue } + let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) + switch delta.name { case OS_ADD_SUBSCRIPTION_DELTA: // Only create the request if the identity model exists - if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) { + if let identityModel = identityModel { let request = OSRequestCreateSubscription( subscriptionModel: subModel, - identityModel: identityModel + identityModel: identityModel, + ownerExternalId: delta.externalId ) self.addRequestQueue.append(request) } else { @@ -206,7 +263,8 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } case OS_REMOVE_SUBSCRIPTION_DELTA: let request = OSRequestDeleteSubscription( - subscriptionModel: subModel + subscriptionModel: subModel, + ownerExternalId: delta.externalId ) self.removeRequestQueue.append(request) @@ -216,7 +274,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { self.updateRequestQueue.removeAll { request in !request.sentToClient && request.subscriptionModel.modelId == modelId } - let request = OSRequestUpdateSubscription(subscriptionModel: subModel) + let request = OSRequestUpdateSubscription(subscriptionModel: subModel, identityModel: identityModel) self.updateRequestQueue.append(request) default: @@ -240,7 +298,12 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { // Bypasses the operation repo to create a push subscription request func createPushSubscription(subscriptionModel: OSSubscriptionModel, identityModel: OSIdentityModel) { - let request = OSRequestCreateSubscription(subscriptionModel: subscriptionModel, identityModel: identityModel) + // No Delta to inherit ownership from, so read the owner directly. + let request = OSRequestCreateSubscription( + subscriptionModel: subscriptionModel, + identityModel: identityModel, + ownerExternalId: identityModel.externalId + ) self.dispatchQueue.async { self.addRequestQueue.append(request) OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) @@ -275,7 +338,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { guard !request.sentToClient else { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { return } request.sentToClient = true @@ -338,6 +401,8 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { // The subscription has been deleted along with the user, so remove the subscription_id but keep the same push subscription model OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId = nil OneSignalUserManagerImpl.sharedInstance._logout() + } else if responseType == .unauthorized, self.auth.handleUnauthorized(request) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSubscriptionOperationExecutor holding \(request) for a new token") } else if responseType != .retryable { // Fail, no retry, remove from cache and queue self.addRequestQueue.removeAll(where: { $0 == request}) @@ -354,7 +419,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { guard !request.sentToClient else { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { return } request.sentToClient = true @@ -380,7 +445,9 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionOperationExecutor delete subscription request failed with error: \(error.debugDescription)") self.dispatchQueue.async { let responseType = OSNetworkingUtils.getResponseStatusType(error.code) - if responseType != .retryable { + if responseType == .unauthorized, self.auth.handleUnauthorized(request) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSubscriptionOperationExecutor holding \(request) for a new token") + } else if responseType != .retryable { // Fail, no retry, remove from cache and queue // If this request returns a missing status, that is ok as this is a delete request self.removeRequestQueue.removeAll(where: { $0 == request}) @@ -403,7 +470,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { guard !updateRequestQueue.contains(where: { $0 !== request && $0.sentToClient && $0.subscriptionModel.modelId == modelId }) else { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { return } request.sentToClient = true @@ -420,7 +487,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { self.updateRequestQueue.removeAll(where: { $0 == request}) OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: self.updateRequestQueue) - if let onesignalId = OneSignalUserManagerImpl.sharedInstance.onesignalId { + if let onesignalId = request.identityModel?.onesignalId { if let rywToken = response?["ryw_token"] as? String { let rywDelay = response?["ryw_delay"] as? NSNumber diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift index 477f11540..18b4a1384 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift @@ -36,19 +36,90 @@ import OneSignalOSCore class OSUserExecutor { var userRequestQueue: [OSUserRequest] = [] private let newRecordsState: OSNewRecordsState + private let identityVerificationService: OSIdentityVerificationService + private let auth: OSRequestAuthorizing /// Delay by the "cool down" period plus a buffer of a set amount of milliseconds private let flushDelayMilliseconds = Int(OP_REPO_POST_CREATE_DELAY_SECONDS * 1_000 + 200) // TODO: This could come from a config, plist, method, remote params /// The User executor dispatch queue, serial. This synchronizes access to the request queues. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSUserExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState) { + init(newRecordsState: OSNewRecordsState, identityVerificationService: OSIdentityVerificationService, auth: OSRequestAuthorizing) { self.newRecordsState = newRecordsState + self.identityVerificationService = identityVerificationService + self.auth = auth uncacheUserRequests() migrateTransferSubscriptionRequests() + + identityVerificationService.addOnJwtConfigHydratedHandler(for: .userExecutor) { [weak self] _ in + // Including an unchanged value: Requests held while `requirement` was unknown wait on this. + self?.executePendingRequests() + } + executePendingRequests() } + /** + Reshapes the queue once `requirement` is known, so nothing that cannot be signed is sent: a Create User + with no `external_id` and every Fetch Identity By Subscription are dropped, and an Identify User — a + `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. + */ + private func reshapeInvalidRequests() { + guard identityVerificationService.ivBehaviorActive else { + return + } + + var reshaped: [OSUserRequest] = [] + var changed = false + + for request in userRequestQueue { + if let identifyUser = request as? OSRequestIdentifyUser { + changed = true + if let createUser = promotionAsCreateUser(identifyUser) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSUserExecutor replaced \(identifyUser) with \(createUser), Identity Verification is required") + reshaped.append(createUser) + } else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSUserExecutor dropped \(identifyUser), Identity Verification is required") + } + } else if isInvalidUnderIdentityVerification(request) { + changed = true + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSUserExecutor dropped \(request), Identity Verification is required") + } else { + reshaped.append(request) + } + } + + guard changed else { + return + } + userRequestQueue = reshaped + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, withValue: userRequestQueue) + } + + /// The Create User the promoting `login` would have made, or nil if that user is no longer the current one. + private func promotionAsCreateUser(_ request: OSRequestIdentifyUser) -> OSRequestCreateUser? { + guard let user = OneSignalUserManagerImpl.sharedInstance.currentUser(matching: request.identityModelToUpdate.modelId) else { + return nil + } + return OSRequestCreateUser( + identityModel: user.identityModel, + propertiesModel: user.propertiesModel, + pushSubscriptionModel: user.pushSubscriptionModel, + originalPushToken: user.pushSubscriptionModel.address + ) + } + + private func isInvalidUnderIdentityVerification(_ request: OSUserRequest) -> Bool { + if let createUser = request as? OSRequestCreateUser { + return createUser.identityModel.externalId == nil + } + return request is OSRequestFetchIdentityBySubscription + } + /// Read in requests from the cache, do not read in FetchUser requests as this is not needed. private func uncacheUserRequests() { var userRequestQueue: [OSUserRequest] = [] @@ -94,7 +165,7 @@ class OSUserExecutor { // 3. Both models don't exist yet // Drop the request if the identityModelToIdentify does not already exist AND the request is missing OSID // Otherwise, this request will forever fail `prepareForExecution` and block pending requests such as recovery calls to `logout` or `login` - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserExecutor.start() dropped: \(request)") continue } @@ -164,33 +235,56 @@ class OSUserExecutor { } private func _executePendingRequests() { + // Hold until known: a Create User sent now would go out unsigned if `requirement` later becomes on. + guard identityVerificationService.requirement != .unknown else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSUserExecutor holding \(self.userRequestQueue.count) Requests until the Identity Verification requirement is known") + return + } + reshapeInvalidRequests() + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSUserExecutor.executePendingRequests called with queue \(self.userRequestQueue)") + var awaitingToken = false + var executed = false + for request in self.userRequestQueue { // Return as soon as we reach an un-executable request - guard request.prepareForExecution(newRecordsState: self.newRecordsState) + guard request.prepareForExecution(newRecordsState: self.newRecordsState, auth: self.auth) else { + // Only the app can end this wait (`updateUserJwt` → `storeJwt`); do not poll for it. + // A login for another user behind this one must not be stranded, so step over it. + if self.auth.awaitsToken(request) { + awaitingToken = true + continue + } OneSignalLog.onesignalLog(.LL_WARN, message: "OSUserExecutor.executePendingRequests() is blocked by unexecutable request \(request)") executePendingRequests(withDelay: true) return } + // One Request per pass; its response re-enters here for the next. + executed = true if request.isKind(of: OSRequestFetchIdentityBySubscription.self), let fetchIdentityRequest = request as? OSRequestFetchIdentityBySubscription { self.executeFetchIdentityBySubscriptionRequest(fetchIdentityRequest) - return + break } else if request.isKind(of: OSRequestCreateUser.self), let createUserRequest = request as? OSRequestCreateUser { self.executeCreateUserRequest(createUserRequest) - return + break } else if request.isKind(of: OSRequestIdentifyUser.self), let identifyUserRequest = request as? OSRequestIdentifyUser { self.executeIdentifyUserRequest(identifyUserRequest) - return + break } else if request.isKind(of: OSRequestFetchUser.self), let fetchUserRequest = request as? OSRequestFetchUser { self.executeFetchUserRequest(fetchUserRequest) - return + break } else { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserExecutor met incompatible Request type that cannot be executed.") } } + + // Wait-only pass: `storeJwt` / hydrate / a later enqueue wakes us. Do not reschedule. + if awaitingToken, !executed { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSUserExecutor has Requests waiting for a token") + } } } @@ -220,14 +314,23 @@ extension OSUserExecutor { return } - // Hook up push subscription model if exists, it may be updated with a subscription_id, etc. - if let modelId = request.pushSubscriptionModel?.modelId, - let pushSubscriptionModel = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModelStore.getModel(modelId: modelId) { - request.pushSubscriptionModel = pushSubscriptionModel - request.updatePushSubscriptionModel(pushSubscriptionModel) + if OneSignalUserManagerImpl.sharedInstance.currentUser(matching: request.identityModel.modelId) != nil { + // Refresh so a subscription_id / token that landed after enqueue is included. + if let modelId = request.pushSubscriptionModel?.modelId, + let pushSubscriptionModel = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModelStore.getModel(modelId: modelId) { + request.pushSubscriptionModel = pushSubscriptionModel + request.updatePushSubscriptionModel(pushSubscriptionModel) + } + } else if request.identityModel.externalId != nil { + // Identified but not current: omit push so a parked Create User can't transfer the device + // subscription after another login took it. Keep push for anonymous creates — the server + // requires a subscription, and with IV off those requests don't sit behind a later user. + request.parameters?.removeValue(forKey: "subscriptions") + request.pushSubscriptionModel = nil + request.originalPushToken = nil } - guard request.prepareForExecution(newRecordsState: newRecordsState) + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { executePendingRequests(withDelay: true) return @@ -240,13 +343,12 @@ extension OSUserExecutor { // Create User's response won't send us the user's complete info if this user already exists if let response = response { - let shouldAddNewRecords = request.pushSubscriptionModel != nil // Parse the response for any data we need to update self.parseFetchUserResponse( response: response, identityModel: request.identityModel, originalPushToken: request.originalPushToken, - addNewRecords: shouldAddNewRecords + addNewRecords: request.addsNewRecords ) // If this user already exists and we logged into an external_id, fetch the user data @@ -275,15 +377,22 @@ extension OSUserExecutor { } } } - OSOperationRepo.sharedInstance.paused = false + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = false } onFailure: { error in OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserExecutor create user request failed with error: \(error.debugDescription)") let responseType = OSNetworkingUtils.getResponseStatusType(error.code) - if responseType != .retryable { + if responseType == .unauthorized, self.auth.handleUnauthorized(request) { + // Held rather than paused: `updateUserJwt` resumes work by flushing, which a paused Repo drops. + // Ordering does not need the pause — every Request for this user waits on an `onesignal_id`. + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSUserExecutor holding \(request) for a new token") + // A replacement token supplied while this was in flight has already released what it could; + // re-enter so that token is used now rather than waiting on another wake. + self.executePendingRequests() + } else if responseType != .retryable { // A failed create user request would leave the SDK in a bad state // Don't remove the request from cache and pause the operation repo // We will retry this request on a new session - OSOperationRepo.sharedInstance.paused = true + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true request.sentToClient = false } } @@ -305,7 +414,7 @@ extension OSUserExecutor { } // newRecordsState is unused for this request - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { executePendingRequests(withDelay: true) return } @@ -358,7 +467,7 @@ extension OSUserExecutor { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { executePendingRequests(withDelay: true) return } @@ -437,7 +546,7 @@ extension OSUserExecutor { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { executePendingRequests(withDelay: true) return } @@ -489,6 +598,8 @@ extension OSUserExecutor { // The subscription has been deleted along with the user, so remove the subscription_id but keep the same push subscription model OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId = nil OneSignalUserManagerImpl.sharedInstance._logout() + } else if responseType == .unauthorized, self.auth.handleUnauthorized(request) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSUserExecutor holding \(request) for a new token") } else if responseType != .retryable { // If the error is not retryable, remove from cache and queue self.removeFromQueue(request) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift new file mode 100644 index 000000000..42e780212 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift @@ -0,0 +1,249 @@ +/* + 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 Foundation +import OneSignalCore +import OneSignalOSCore + +/** + The one place a Request's addressing alias and `Authorization` header are decided. + + While Identity Verification is not in effect every method here resolves what the SDK sent before it + existed: the `onesignal_id` alias and no header. + */ +protocol OSRequestAuthorizing: AnyObject { + /// Whether Identity Verification behavior applies, for the few Requests whose body changes with it. + var ivBehaviorActive: Bool { get } + + /** + Resolves the alias a user-scoped path should address, attaching a Bearer header when Identity + Verification is in effect. `legacyAlias` is what the Request addresses without it, which is + `onesignal_id` for everything except a Fetch User that was built to read some other alias. + + Returns nil when Identity Verification is in effect and the Request cannot be signed, leaving it in + the queue it already sits in. An owner with no usable token is parked and the app is asked for one; + `updateUserJwt` (via `storeJwt`) wakes the executor, or the requirement turning off resolves it and + sends. A Request with no owner at all is refused, since only the purge can resolve it. + */ + func authorizeUserScoped(_ request: OSUserRequest, legacyAlias: OSAliasPair) -> OSAliasPair? + + /// The same decision for endpoints that take a token but no alias, because their path names a + /// subscription or the app. Returns `false` under the same conditions as `authorizeUserScoped`, + /// except that a `sendsUnsigned` Request with no owner is allowed through. + func authorize(_ request: OSUserRequest) -> Bool + + /** + Returns `true` if the Request's owner has no token to sign with, which is why the two methods above + parked it. Reads only: it does not ask the app for a token, so call it after one of them has. + + Lets a caller that stops at its first unsendable Request tell "nothing can send until the app hands + over a token for this user" from "not addressable yet", which resolves on its own. + */ + func awaitsToken(_ request: OSUserRequest) -> Bool + + /** + Parks the token an unauthorized response rejected and clears `sentToClient` so the Request is + re-signed on a later flush. + + Returns `true` when the caller must leave the Request queued, `false` to fall through to its + existing non-retryable handling. + */ + func handleUnauthorized(_ request: OSUserRequest) -> Bool + + /** + The same alias and token decision for a user-scoped call that does not travel through the Request + queues, currently the in-app message fetch. Pass the ids of the user the call is for. + + Returns nil when it cannot be sent yet, having asked the app for a token if that is what is missing. + */ + func authorization(onesignalId: String?, externalId: String?) -> OSUserRequestAuthorization? +} + +/** + How another module should address and sign one user-scoped call. + + `alias` nil means address it the way it was addressed before Identity Verification: no user in the + path and nothing to sign with. + */ +@objc(OSUserRequestAuthorization) +public final class OSUserRequestAuthorization: NSObject { + @objc public let alias: OSAliasPair? + /// Merge into the request's headers. Empty unless the call is signed. + @objc public let headers: [String: String] + /// The token `headers` signs with, nil when the call is unsigned. + @objc public let token: String? + + fileprivate init(alias: OSAliasPair?, headers: [String: String] = [:], token: String? = nil) { + self.alias = alias + self.headers = headers + self.token = token + } +} + +final class OSRequestAuth: OSRequestAuthorizing { + private static let authorizationHeader = "Authorization" + private static let bearerPrefix = "Bearer " + private static let legacyAddressing = OSUserRequestAuthorization(alias: nil) + + private let identityVerificationService: OSIdentityVerificationService + private let jwt: OSUserJwtProviding + + var ivBehaviorActive: Bool { + return identityVerificationService.ivBehaviorActive + } + + init(identityVerificationService: OSIdentityVerificationService, jwt: OSUserJwtProviding) { + self.identityVerificationService = identityVerificationService + self.jwt = jwt + } + + func authorizeUserScoped(_ request: OSUserRequest, legacyAlias: OSAliasPair) -> OSAliasPair? { + guard ivBehaviorActive else { + return legacyAlias + } + guard let externalId = request.ownerExternalId else { + // A user-scoped path with no owner has nobody to sign for, and addressing it by + // `onesignal_id` would send it unsigned. The Operation Repo suppresses anonymous work long + // before it becomes a Request, and the purge clears leftovers on hydration, so this holds. + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSRequestAuth: refusing \(request), it has no owner under Identity Verification") + return nil + } + guard let token = jwt.validJwt(externalId: externalId) else { + park(request, ownedBy: externalId) + return nil + } + setBearer(token, on: request) + return OSAliasPair(OS_EXTERNAL_ID, externalId) + } + + func authorize(_ request: OSUserRequest) -> Bool { + guard ivBehaviorActive else { + return true + } + guard let externalId = request.ownerExternalId else { + // Anything not exempt is a leftover the purge has yet to clear, and unsendable until it does. + guard request.sendsUnsigned else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSRequestAuth: refusing \(request), it has no owner under Identity Verification") + return false + } + return true + } + guard let token = jwt.validJwt(externalId: externalId) else { + park(request, ownedBy: externalId) + return false + } + setBearer(token, on: request) + return true + } + + func awaitsToken(_ request: OSUserRequest) -> Bool { + guard ivBehaviorActive, let externalId = request.ownerExternalId else { + return false + } + return jwt.validJwt(externalId: externalId) == nil + } + + /** + 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. + */ + private func park(_ request: OSUserRequest, ownedBy externalId: String) { + // Log only on the ask that reaches the app; later prepareForExecution retries stay quiet. + if jwt.askForToken(externalId: externalId) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSRequestAuth: holding \(request) until \(externalId) has a token") + } + } + + func handleUnauthorized(_ request: OSUserRequest) -> Bool { + guard ivBehaviorActive, + let externalId = request.ownerExternalId, + let rejectedToken = signedToken(of: request) + else { + return false + } + jwt.invalidateJwt(externalId: externalId, rejectedToken: rejectedToken) + removeBearer(from: request) + request.sentToClient = false + return true + } + + func authorization(onesignalId: String?, externalId: String?) -> OSUserRequestAuthorization? { + // Not decided yet: legacy vs onesignal_id vs external_id+JWT. Callers reattempt on hydration. + guard identityVerificationService.requirement != .unknown else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSRequestAuth: holding a user-scoped call until the requirement is known") + return nil + } + // Requirement is known. Flag-only rollout still uses the pre-IV subscription path. + guard identityVerificationService.newCodePathsRun else { + return Self.legacyAddressing + } + guard ivBehaviorActive else { + // Nothing to address the call to until the server assigns an `onesignal_id`. + guard let onesignalId = onesignalId else { + return nil + } + return OSUserRequestAuthorization(alias: OSAliasPair(OS_ONESIGNAL_ID, onesignalId)) + } + // Under Identity Verification there is nothing the server will serve for a device with no + // identified user, so this waits for a login rather than falling back to `onesignal_id`. + guard let externalId = externalId else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSRequestAuth: holding a user-scoped call until a user is identified") + return nil + } + guard let token = jwt.validJwt(externalId: externalId) else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSRequestAuth: holding a user-scoped call until \(externalId) has a token") + jwt.askForToken(externalId: externalId) + return nil + } + return OSUserRequestAuthorization(alias: OSAliasPair(OS_EXTERNAL_ID, externalId), + headers: [Self.authorizationHeader: Self.bearerPrefix + token], + token: token) + } + + private func setBearer(_ token: String, on request: OneSignalRequest) { + var headers = request.additionalHeaders ?? [String: String]() + headers[Self.authorizationHeader] = Self.bearerPrefix + token + request.additionalHeaders = headers + } + + private func removeBearer(from request: OneSignalRequest) { + var headers = request.additionalHeaders + headers?.removeValue(forKey: Self.authorizationHeader) + request.additionalHeaders = headers + } + + /// The token the Request carries, which is the one the server just rejected. + private func signedToken(of request: OneSignalRequest) -> String? { + guard let header = request.additionalHeaders?[Self.authorizationHeader], + header.hasPrefix(Self.bearerPrefix) + else { + return nil + } + return String(header.dropFirst(Self.bearerPrefix.count)) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift index cc9dd2211..a12407f7b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift @@ -107,6 +107,7 @@ class OSSubscriptionModel: OSModel { var subscriptionId: String? var reachable: Bool var isDisabled: Bool + var isDisabledInternally: Bool var notificationTypes: Int var testType: Int? var deviceOs: String @@ -261,6 +262,54 @@ class OSSubscriptionModel: OSModel { } } + /** + Set by the SDK on `logout` under Identity Verification, where no anonymous user is created to send to. + Only `reportedEnablement` reads it, so `_isDisabled` and `notificationTypes` keep the app's own opt-in + state and clearing this restores what the app asked for. + + The app's push subscription observer does not fire — its opt-in preference has not changed, only what + the SDK reports while there is no user to report it for — but setting this does queue an Update + Subscription, which is how the server learns the device stopped listening. + */ + var _isDisabledInternally: Bool { + get { stateLock.withLock { state.isDisabledInternally } } + set { setDisabledInternally(newValue, sendUpdate: true) } + } + + /// Restores reporting without an Update Subscription, for `login`: its Create User already carries + /// the re-enabled subscription. + func clearDisabledInternallyForLogin() { + setDisabledInternally(false, sendUpdate: false) + } + + private func setDisabledInternally(_ disabled: Bool, sendUpdate: Bool) { + let oldValue = swapValue(\.isDisabledInternally, to: disabled) + guard disabled != oldValue else { + return + } + self.set(property: "isDisabledInternally", newValue: disabled, preventServerUpdate: !sendUpdate) + } + + /** + The `enabled` and `notification_types` to report, which the server reads as a pair. An internal + disable overrides both. `notificationTypes` is nil when there is no value to send. + + Taken from one snapshot so the two cannot disagree, and shared with Update Subscription so a + subscription reports the same thing however the Request was built. + */ + func reportedEnablement() -> (enabled: Bool, notificationTypes: Int?) { + return reportedEnablement(from: snapshot()) + } + + private func reportedEnablement(from state: State) -> (enabled: Bool, notificationTypes: Int?) { + guard !state.isDisabledInternally else { + return (false, -2) + } + let enabled = calculateIsEnabled(address: state.address, reachable: state.reachable, isDisabled: state.isDisabled) + // notificationTypes defaults to -1 instead of nil, don't send if it's -1 + return (enabled, state.notificationTypes == -1 ? nil : state.notificationTypes) + } + // Properties for push subscription var testType: Int? { get { stateLock.withLock { state.testType } } @@ -365,6 +414,7 @@ class OSSubscriptionModel: OSModel { subscriptionId: subscriptionId, reachable: reachable, isDisabled: isDisabled, + isDisabledInternally: false, notificationTypes: notificationTypes, testType: testType, deviceOs: UIDevice.current.systemVersion, @@ -386,6 +436,7 @@ class OSSubscriptionModel: OSModel { coder.encode(state.subscriptionId, forKey: "subscriptionId") coder.encode(state.reachable, forKey: "_reachable") coder.encode(state.isDisabled, forKey: "_isDisabled") + coder.encode(state.isDisabledInternally, forKey: "isDisabledInternally") coder.encode(state.notificationTypes, forKey: "notificationTypes") coder.encode(state.testType, forKey: "testType") coder.encode(state.deviceOs, forKey: "deviceOs") @@ -409,6 +460,9 @@ class OSSubscriptionModel: OSModel { subscriptionId: coder.decodeObject(forKey: "subscriptionId") as? String, reachable: coder.decodeBool(forKey: "_reachable"), isDisabled: coder.decodeBool(forKey: "_isDisabled"), + // A model archived while logged out under Identity Verification stays internally disabled + // until the next login clears it. + isDisabledInternally: coder.decodeBool(forKey: "isDisabledInternally"), notificationTypes: coder.decodeInteger(forKey: "notificationTypes"), testType: coder.decodeObject(forKey: "testType") as? Int, deviceOs: coder.decodeObject(forKey: "deviceOs") as? String ?? UIDevice.current.systemVersion, @@ -457,16 +511,17 @@ class OSSubscriptionModel: OSModel { json["id"] = state.subscriptionId json["type"] = state.type.rawValue json["token"] = state.address - json["enabled"] = calculateIsEnabled(address: state.address, reachable: state.reachable, isDisabled: state.isDisabled) json["test_type"] = state.testType json["device_os"] = state.deviceOs json["sdk"] = state.sdk json["device_model"] = state.deviceModel json["app_version"] = state.appVersion json["net_type"] = state.netType - // notificationTypes defaults to -1 instead of nil, don't send if it's -1 - if state.notificationTypes != -1 { - json["notification_types"] = state.notificationTypes + + let enablement = reportedEnablement(from: state) + json["enabled"] = enablement.enabled + if let notificationTypes = enablement.notificationTypes { + json["notification_types"] = notificationTypes } return json } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index f58e983b3..308bc1d14 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -128,10 +128,6 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { let newRecordsState = OSNewRecordsState() - // Injected into the model store listeners so a Delta is enqueued against a known repo rather - // than reaching for the singleton. A later PR replaces this with an owned instance. - let operationRepo = OSOperationRepo.sharedInstance - // Shared instances: remote params hydrate them before this class is started, and a // fresh instance here would read none of it. let featureManager = OSFeatureManager.shared @@ -206,6 +202,8 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { // These must be initialized in init() let userJwtRepo: OSUserJwtRepo + let requestAuth: OSRequestAuthorizing + let operationRepo: OSOperationRepo let identityModelStoreListener: OSIdentityModelStoreListener let propertiesModelStoreListener: OSPropertiesModelStoreListener let subscriptionModelStoreListener: OSSubscriptionModelStoreListener @@ -220,6 +218,7 @@ 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 userJwtRepo = OSUserJwtRepo(identityModelRepo: identityModelRepo) { externalId in @@ -227,6 +226,8 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } self.identityVerificationService = identityVerificationService self.userJwtRepo = userJwtRepo + self.requestAuth = OSRequestAuth(identityVerificationService: identityVerificationService, jwt: userJwtRepo) + self.operationRepo = operationRepo self.identityModelStoreListener = OSIdentityModelStoreListener(store: identityModelStore, operationRepo: operationRepo) self.propertiesModelStoreListener = OSPropertiesModelStoreListener(store: propertiesModelStore, operationRepo: operationRepo) self.subscriptionModelStoreListener = OSSubscriptionModelStoreListener(store: subscriptionModelStore, operationRepo: operationRepo) @@ -260,6 +261,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { // Same prewarm gap as the stores: init may have read UserDefaults while it was locked. jwtConfig.refreshIfUnknown() featureManager.refreshIfEmpty() + operationRepo.refreshIfEmpty() OSNotificationsManager.delegate = self @@ -283,24 +285,44 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { // TODO: Update the push sub model with any new state from NotificationsManager + /* + Clears an internal disable that Identity Verification no longer needs: either the app turned + the requirement off while logged out, or `logout` guessed on while it was still unknown. + `login` is otherwise the only clear, and would leave the subscription silenced until the next + one. Sends an update, unlike the login path: nothing else will tell the server. + + Registered before the User executor's handler so anything it sends on this hydration already + carries the restored subscription — a Create User response hydrates `enabled` back onto the + app's own opt-in, which would make the silencing permanent. + */ + identityVerificationService.addOnJwtConfigHydratedHandler(for: .userManager) { [weak self] requirement in + guard requirement == .off else { + return + } + self?.pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?._isDisabledInternally = false + } + // Setup the executors // The OSUserExecutor has to run first, before other executors - self.userExecutor = OSUserExecutor(newRecordsState: newRecordsState) - OSOperationRepo.sharedInstance.start() + self.userExecutor = OSUserExecutor(newRecordsState: newRecordsState, identityVerificationService: identityVerificationService, auth: requestAuth) // Cannot initialize these executors in `init` as they reference the sharedInstance - let propertyExecutor = OSPropertyOperationExecutor(newRecordsState: newRecordsState) - let identityExecutor = OSIdentityOperationExecutor(newRecordsState: newRecordsState) - let subscriptionExecutor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState) - let customEventsExecutor = OSCustomEventsExecutor(newRecordsState: newRecordsState) + let propertyExecutor = OSPropertyOperationExecutor(newRecordsState: newRecordsState, auth: requestAuth) + let identityExecutor = OSIdentityOperationExecutor(newRecordsState: newRecordsState, auth: requestAuth) + let subscriptionExecutor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, auth: requestAuth) + let customEventsExecutor = OSCustomEventsExecutor(newRecordsState: newRecordsState, auth: requestAuth) self.propertyExecutor = propertyExecutor self.identityExecutor = identityExecutor self.subscriptionExecutor = subscriptionExecutor self.customEventsExecutor = customEventsExecutor - OSOperationRepo.sharedInstance.addExecutor(identityExecutor) - OSOperationRepo.sharedInstance.addExecutor(propertyExecutor) - OSOperationRepo.sharedInstance.addExecutor(subscriptionExecutor) - OSOperationRepo.sharedInstance.addExecutor(customEventsExecutor) + operationRepo.addExecutor(identityExecutor) + operationRepo.addExecutor(propertyExecutor) + operationRepo.addExecutor(subscriptionExecutor) + operationRepo.addExecutor(customEventsExecutor) + + // After the executors: a cached requirement makes `start()` flush right away, and the + // Deltas restored at launch can only route once the map above is populated. + operationRepo.start() // Path 2. There is a legacy player to migrate if let legacyPlayerId = OneSignalUserDefaults.initShared().getSavedString(forKey: OSUD_LEGACY_PLAYER_ID, defaultValue: nil) { @@ -357,10 +379,13 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignal.User login called with externalId: \(externalId)") + // Ungated: a subscription internally disabled by a previous logout has to come back even if + // Identity Verification has since been turned off. + pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?.clearDisabledInternallyForLogin() + // Logging into an identified user from an anonymous user - if let user = _user, user.isAnonymous { - user.identityModel.jwtBearerToken = token - identifyUser(externalId: externalId, currentUser: user) + if let user = _user, user.isAnonymous, canPromoteAnonymousUser { + identifyUser(externalId: externalId, currentUser: user, token: token) } else { // Logging into identified -> anon, identified -> identified, or nil -> identified _ = createNewUser(externalId: externalId, token: token) @@ -368,6 +393,18 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } + /** + Whether `login` may promote the current anonymous user with Identify User instead of creating a new one. + + Identify User adds an `external_id` to a user that has none, and under Identity Verification no such + user is ever sent to the server, so every login has to create its user instead. While the requirement is + unknown this still promotes: the queue is held until it is known, and `OSUserExecutor` then turns the + promotion into the Create User it should have been if the app turns out to require auth. + */ + private var canPromoteAnonymousUser: Bool { + return !identityVerificationService.ivBehaviorActive + } + /** Converting a 3.x player to a 5.x user. There is a cached legacy player, so we will create the user based on the legacy player ID. */ @@ -403,6 +440,11 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { if let user = _user { guard user.identityModel.externalId != externalId || externalId == nil else { OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignalUserManager.createNewUser: not creating new user due to logging into the same user.)") + // Re-logging in is how an app hands over a replacement token, so take it and let anything + // held for want of one go out. + if let externalId = externalId, let token = token { + storeJwt(externalId: externalId, token: token) + } return user } } @@ -415,7 +457,9 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } let newUser = setNewInternalUser(externalId: externalId, pushSubscriptionModel: pushSubscriptionModel) - newUser.identityModel.jwtBearerToken = token + if let externalId = externalId, let token = token { + storeJwt(externalId: externalId, token: token) + } userExecutor!.createUser(newUser) return newUser } @@ -427,7 +471,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { 1. This externalId already exists on another user. We create a new SDK user and fetch that user's information. 2. This externalId doesn't exist on any users. We successfully identify the user, but we still create a new SDK user and fetch to update it. */ - private func identifyUser(externalId: String, currentUser: OSUserInternal) { + private func identifyUser(externalId: String, currentUser: OSUserInternal, token: String?) { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { return } @@ -439,6 +483,11 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { let pushSubscriptionModel = pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY) prepareForNewUser() 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) + } // Now proceed to identify the previous user userExecutor!.identifyUser( @@ -486,7 +535,30 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { OneSignalLog.onesignalLog(.LL_DEBUG, message: "OneSignal.User logout called, but the user is currently anonymous, so not logging out.") return } + /* + The replacement anonymous user is never created on the server under Identity Verification, so two + things it would otherwise have done have to happen here: stop reporting the push subscription, which + still carries the logged-out user's subscription ID, and tell observers that nobody is signed in. + + Only the app's own `logout()`. `_logout()` also runs as 404 recovery, where the SDK is replacing a + user the server no longer has and the subscription should keep reporting. + + While the requirement is still unknown, guess on: the wrong guess over-silences until hydrate-to-off + restores reporting, and the other guess would keep delivering the logged-out user's pushes. Read + once so hydration cannot flip the two sides mid-logout. + */ + let shouldSilenceForLogout = identityVerificationService.requirement != .off + + if shouldSilenceForLogout { + // Before the switch, so the unsubscribe is stamped with the outgoing user. + user.pushSubscriptionModel._isDisabledInternally = true + } + _logout() + + if shouldSilenceForLogout { + OSUserStateSnapshot.fireUserStateChanged(newOnesignalId: nil, newExternalId: nil) + } } public func _logout() { @@ -496,16 +568,23 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } /** - Stores a token for `externalId`, so that the pending ask for this user is cleared and a later - rejection can ask again. + Stores a token for `externalId` and releases everything held for want of one, so it goes out now: + the Repo's Deltas, the User executor's own queue (which is not Repo-driven), and — over the + notification — the work that travels through neither. - Every app-supplied token arrives here, from `login` as well as `updateUserJwt`. + 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. */ func storeJwt(externalId: String, token: String) { guard userJwtRepo.updateJwt(externalId: externalId, token: token) else { return } OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignalUserManager stored a JWT for externalId: \(externalId)") + guard identityVerificationService.newCodePathsRun else { + return + } + operationRepo.addFlushDeltaQueueToDispatchQueue() + userExecutor?.executePendingRequests() } @objc @@ -618,7 +697,6 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } updatePropertiesDeltas(property: .purchases, value: purchases) } - } // MARK: - Sessions @@ -633,7 +711,7 @@ extension OneSignalUserManagerImpl { start() userExecutor!.executePendingRequests() - OSOperationRepo.sharedInstance.paused = false + operationRepo.paused = false updatePropertiesDeltas(property: .session_count, value: 1, flush: true) // Fetch the user's data if there is a onesignal_id @@ -672,7 +750,7 @@ extension OneSignalUserManagerImpl { property: property.rawValue, value: value ) - OSOperationRepo.sharedInstance.enqueueDelta(delta, flush: flush) + operationRepo.enqueueDelta(delta, flush: flush) } /// Time processors forward the session time to this method. @@ -690,7 +768,7 @@ extension OneSignalUserManagerImpl { */ @objc public func runBackgroundTasks() { - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue(inBackground: true) + operationRepo.addFlushDeltaQueueToDispatchQueue(inBackground: true) } } @@ -892,7 +970,7 @@ extension OneSignalUserManagerImpl: OSUser { property: name, value: processedProperties ) - OSOperationRepo.sharedInstance.enqueueDelta(delta) + operationRepo.enqueueDelta(delta) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestAddAliases.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestAddAliases.swift index a4ad372a0..9e3549e7c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestAddAliases.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestAddAliases.swift @@ -38,23 +38,28 @@ class OSRequestAddAliases: OneSignalRequest, OSUserRequest { var identityModel: OSIdentityModel let aliases: [String: String] + /// See the ownership convention in `OSUserRequest.swift`. + let ownerExternalId: String? + /// requires a `onesignal_id` to send this request - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { if let onesignalId = identityModel.onesignalId, newRecordsState.canAccess(onesignalId), - let appId = OneSignalIdentifiers.currentAppId + let appId = OneSignalIdentifiers.currentAppId, + let alias = auth.authorizeUserScoped(self, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, onesignalId)), + let aliasId = OSUrlPath.segment(alias.id) { - self.addJWTHeader(identityModel: identityModel) - self.path = "apps/\(appId)/users/by/\(OS_ONESIGNAL_ID)/\(onesignalId)/identity" + self.path = "apps/\(appId)/users/by/\(alias.label)/\(aliasId)/identity" return true } else { return false } } - init(aliases: [String: String], identityModel: OSIdentityModel) { + init(aliases: [String: String], identityModel: OSIdentityModel, ownerExternalId: String?) { self.identityModel = identityModel self.aliases = aliases + self.ownerExternalId = ownerExternalId self.stringDescription = "" super.init() self.parameters = ["identity": aliases] @@ -63,6 +68,7 @@ class OSRequestAddAliases: OneSignalRequest, OSUserRequest { func encode(with coder: NSCoder) { coder.encode(identityModel, forKey: "identityModel") + coder.encode(ownerExternalId, forKey: "ownerExternalId") coder.encode(aliases, forKey: "aliases") coder.encode(parameters, forKey: "parameters") coder.encode(method.rawValue, forKey: "method") // Encodes as String @@ -82,6 +88,7 @@ class OSRequestAddAliases: OneSignalRequest, OSUserRequest { } self.identityModel = identityModel self.aliases = aliases + self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String self.stringDescription = "" super.init() self.parameters = parameters diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateSubscription.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateSubscription.swift index e95281e5e..90467757d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateSubscription.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateSubscription.swift @@ -43,23 +43,28 @@ class OSRequestCreateSubscription: OneSignalRequest, OSUserRequest { var subscriptionModel: OSSubscriptionModel var identityModel: OSIdentityModel + /// See the ownership convention in `OSUserRequest.swift`. + let ownerExternalId: String? + // Need the onesignal_id of the user - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { if let onesignalId = identityModel.onesignalId, newRecordsState.canAccess(onesignalId), - let appId = OneSignalIdentifiers.currentAppId + let appId = OneSignalIdentifiers.currentAppId, + let alias = auth.authorizeUserScoped(self, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, onesignalId)), + let aliasId = OSUrlPath.segment(alias.id) { - self.addJWTHeader(identityModel: identityModel) - self.path = "apps/\(appId)/users/by/\(OS_ONESIGNAL_ID)/\(onesignalId)/subscriptions" + self.path = "apps/\(appId)/users/by/\(alias.label)/\(aliasId)/subscriptions" return true } else { return false } } - init(subscriptionModel: OSSubscriptionModel, identityModel: OSIdentityModel) { + init(subscriptionModel: OSSubscriptionModel, identityModel: OSIdentityModel, ownerExternalId: String?) { self.subscriptionModel = subscriptionModel self.identityModel = identityModel + self.ownerExternalId = ownerExternalId self.stringDescription = "" super.init() self.parameters = ["subscription": subscriptionModel.jsonRepresentation()] @@ -69,6 +74,7 @@ class OSRequestCreateSubscription: OneSignalRequest, OSUserRequest { func encode(with coder: NSCoder) { coder.encode(subscriptionModel, forKey: "subscriptionModel") coder.encode(identityModel, forKey: "identityModel") + coder.encode(ownerExternalId, forKey: "ownerExternalId") coder.encode(parameters, forKey: "parameters") coder.encode(method.rawValue, forKey: "method") // Encodes as String coder.encode(timestamp, forKey: "timestamp") @@ -87,6 +93,7 @@ class OSRequestCreateSubscription: OneSignalRequest, OSUserRequest { } self.subscriptionModel = subscriptionModel self.identityModel = identityModel + self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String self.stringDescription = "" super.init() self.parameters = parameters diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateUser.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateUser.swift index a74514f11..c992e6651 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateUser.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateUser.swift @@ -45,8 +45,20 @@ class OSRequestCreateUser: OneSignalRequest, OSUserRequest { var pushSubscriptionModel: OSSubscriptionModel? var originalPushToken: String? + /** + Whether the response's IDs should enter `newRecordsState`. + + `true` for a real create; `false` for the Identify-409 recovery Create, which only hydrates the + `onesignal_id` of a user that already exists. Stamped at init so stripping push later cannot + flip a real create into the recovery path. + */ + let addsNewRecords: Bool + + /// See the ownership convention in `OSUserRequest.swift`. + var ownerExternalId: String? { return identityModel.externalId } + /// Checks if the subscription ID can be accessed, if a subscription is being included in the request - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { guard let appId = OneSignalIdentifiers.currentAppId else { OneSignalLog.onesignalLog(.LL_ERROR, message: "Cannot generate the create user request due to null app ID.") return false @@ -59,8 +71,13 @@ class OSRequestCreateUser: OneSignalRequest, OSUserRequest { return false } + // The path is app-scoped and the identity object already names the user, so there is no + // alias to swap — only the token. + guard auth.authorize(self) else { + return false + } + _ = self.addPushSubscriptionIdToAdditionalHeaders() - self.addJWTHeader(identityModel: identityModel) self.path = "apps/\(appId)/users" return true } @@ -79,6 +96,7 @@ class OSRequestCreateUser: OneSignalRequest, OSUserRequest { self.identityModel = identityModel self.pushSubscriptionModel = pushSubscriptionModel self.originalPushToken = originalPushToken + self.addsNewRecords = true self.stringDescription = "" super.init() @@ -104,6 +122,7 @@ class OSRequestCreateUser: OneSignalRequest, OSUserRequest { init(aliasLabel: String, aliasId: String, identityModel: OSIdentityModel) { self.identityModel = identityModel + self.addsNewRecords = false self.stringDescription = "" super.init() self.parameters = [ @@ -117,6 +136,7 @@ class OSRequestCreateUser: OneSignalRequest, OSUserRequest { coder.encode(identityModel, forKey: "identityModel") coder.encode(pushSubscriptionModel, forKey: "pushSubscriptionModel") coder.encode(originalPushToken, forKey: "originalPushToken") + coder.encode(addsNewRecords, forKey: "addsNewRecords") coder.encode(parameters, forKey: "parameters") coder.encode(method.rawValue, forKey: "method") // Encodes as String coder.encode(timestamp, forKey: "timestamp") @@ -135,6 +155,7 @@ class OSRequestCreateUser: OneSignalRequest, OSUserRequest { self.identityModel = identityModel self.pushSubscriptionModel = coder.decodeObject(forKey: "pushSubscriptionModel") as? OSSubscriptionModel self.originalPushToken = coder.decodeObject(forKey: "originalPushToken") as? String + self.addsNewRecords = coder.decodeBool(forKey: "addsNewRecords") self.stringDescription = "" super.init() self.parameters = parameters diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCustomEvents.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCustomEvents.swift index 83ec1e565..4104ff711 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCustomEvents.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCustomEvents.swift @@ -37,12 +37,19 @@ class OSRequestCustomEvents: OneSignalRequest, OSUserRequest { var identityModel: OSIdentityModel - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + /// See the ownership convention in `OSUserRequest.swift`. + let ownerExternalId: String? + + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { if let onesignalId = identityModel.onesignalId, newRecordsState.canAccess(onesignalId), - let appId = OneSignalIdentifiers.currentAppId + let appId = OneSignalIdentifiers.currentAppId, + auth.authorize(self) { _ = self.addPushSubscriptionIdToAdditionalHeaders() + if auth.ivBehaviorActive, let externalId = ownerExternalId { + addExternalIdToEvents(externalId) + } self.path = "apps/\(appId)/custom_events" return true } else { @@ -50,8 +57,22 @@ class OSRequestCustomEvents: OneSignalRequest, OSUserRequest { } } - init(events: [[String: Any]], identityModel: OSIdentityModel) { + /// The path is app-scoped, so the owner rides in each event's body rather than in the path. + /// Written at send time rather than at init so a cached payload cannot outlive the gate. + private func addExternalIdToEvents(_ externalId: String) { + guard let events = self.parameters?["events"] as? [[String: Any]] else { + return + } + self.parameters?["events"] = events.map { event in + var event = event + event[OS_EXTERNAL_ID] = externalId + return event + } + } + + init(events: [[String: Any]], identityModel: OSIdentityModel, ownerExternalId: String?) { self.identityModel = identityModel + self.ownerExternalId = ownerExternalId self.stringDescription = "" super.init() self.parameters = [ @@ -62,6 +83,7 @@ class OSRequestCustomEvents: OneSignalRequest, OSUserRequest { func encode(with coder: NSCoder) { coder.encode(identityModel, forKey: "identityModel") + coder.encode(ownerExternalId, forKey: "ownerExternalId") coder.encode(parameters, forKey: "parameters") coder.encode(method.rawValue, forKey: "method") // Encodes as String coder.encode(timestamp, forKey: "timestamp") @@ -78,6 +100,7 @@ class OSRequestCustomEvents: OneSignalRequest, OSUserRequest { return nil } self.identityModel = identityModel + self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String self.stringDescription = "" super.init() self.parameters = parameters diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestDeleteSubscription.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestDeleteSubscription.swift index 43cd37e3b..cf162e809 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestDeleteSubscription.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestDeleteSubscription.swift @@ -42,11 +42,19 @@ class OSRequestDeleteSubscription: OneSignalRequest, OSUserRequest { var subscriptionModel: OSSubscriptionModel + /** + See the ownership convention in `OSUserRequest.swift`. Removing an email or SMS subscription is a + deliberate action on one user, so an anonymous one is dropped under Identity Verification even + though the path addresses a subscription rather than a user. + */ + let ownerExternalId: String? + // Need the subscription_id - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { if let subscriptionId = subscriptionModel.subscriptionId, newRecordsState.canAccess(subscriptionId), - let appId = OneSignalIdentifiers.currentAppId + let appId = OneSignalIdentifiers.currentAppId, + auth.authorize(self) { self.path = "apps/\(appId)/subscriptions/\(subscriptionId)" return true @@ -55,8 +63,9 @@ class OSRequestDeleteSubscription: OneSignalRequest, OSUserRequest { } } - init(subscriptionModel: OSSubscriptionModel) { + init(subscriptionModel: OSSubscriptionModel, ownerExternalId: String?) { self.subscriptionModel = subscriptionModel + self.ownerExternalId = ownerExternalId self.stringDescription = "" super.init() self.method = DELETE @@ -64,6 +73,7 @@ class OSRequestDeleteSubscription: OneSignalRequest, OSUserRequest { func encode(with coder: NSCoder) { coder.encode(subscriptionModel, forKey: "subscriptionModel") + coder.encode(ownerExternalId, forKey: "ownerExternalId") coder.encode(method.rawValue, forKey: "method") // Encodes as String coder.encode(timestamp, forKey: "timestamp") } @@ -77,7 +87,8 @@ class OSRequestDeleteSubscription: OneSignalRequest, OSUserRequest { // Log error return nil } - self.subscriptionModel = subscriptionModel + self.subscriptionModel = subscriptionModel + self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String self.stringDescription = "" super.init() self.method = HTTPMethod(rawValue: rawMethod) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchIdentityBySubscription.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchIdentityBySubscription.swift index 207209f3e..e7ea9757a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchIdentityBySubscription.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchIdentityBySubscription.swift @@ -39,7 +39,11 @@ class OSRequestFetchIdentityBySubscription: OneSignalRequest, OSUserRequest { var identityModel: OSIdentityModel var pushSubscriptionModel: OSSubscriptionModel - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + /// Always nil, so this Request is never signed. It discovers which user owns a subscription during + /// the v4 upgrade, before there is an `external_id` to sign with. + var ownerExternalId: String? { return nil } + + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { // newRecordsState is unused for this request guard let appId = OneSignalIdentifiers.currentAppId else { OneSignalLog.onesignalLog(.LL_DEBUG, message: "Cannot generate the FetchIdentityBySubscription request due to null app ID.") diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchUser.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchUser.swift index 8bf9b2973..5c49b96be 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchUser.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchUser.swift @@ -44,15 +44,19 @@ class OSRequestFetchUser: OneSignalRequest, OSUserRequest { let aliasId: String let onNewSession: Bool - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + /// See the ownership convention in `OSUserRequest.swift`. + var ownerExternalId: String? { return identityModel.externalId } + + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { guard let appId = OneSignalIdentifiers.currentAppId, - newRecordsState.canAccess(aliasId) + newRecordsState.canAccess(aliasId), + let alias = auth.authorizeUserScoped(self, legacyAlias: OSAliasPair(aliasLabel, aliasId)), + let encodedAliasId = OSUrlPath.segment(alias.id) else { OneSignalLog.onesignalLog(.LL_DEBUG, message: "Cannot generate the fetch user request for \(aliasLabel): \(aliasId) yet.") return false } - self.addJWTHeader(identityModel: identityModel) - self.path = "apps/\(appId)/users/by/\(aliasLabel)/\(aliasId)" + self.path = "apps/\(appId)/users/by/\(alias.label)/\(encodedAliasId)" return true } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestIdentifyUser.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestIdentifyUser.swift index 880b2e405..3fd6f4fbd 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestIdentifyUser.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestIdentifyUser.swift @@ -47,14 +47,24 @@ class OSRequestIdentifyUser: OneSignalRequest, OSUserRequest { let aliasLabel: String let aliasId: String - /// requires a `onesignal_id` to send this request - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { - if let onesignalId = identityModelToIdentify.onesignalId, + /** + Always nil, so this Request is never signed. It adds an `external_id` to an anonymous user, and + Identity Verification does not allow anonymous users — `login` goes straight to Create User + instead, and the purge drops any that are already queued. + */ + var ownerExternalId: String? { return nil } + + /// Requires a `onesignal_id`, and refuses outright once Identity Verification is active: there is no + /// signed way to promote an anonymous user, so this can only sit until the purge reshapes it. + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { + if !auth.ivBehaviorActive, + let onesignalId = identityModelToIdentify.onesignalId, newRecordsState.canAccess(onesignalId), - let appId = OneSignalIdentifiers.currentAppId + let appId = OneSignalIdentifiers.currentAppId, + let alias = auth.authorizeUserScoped(self, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, onesignalId)), + let aliasId = OSUrlPath.segment(alias.id) { - self.addJWTHeader(identityModel: identityModelToIdentify) - self.path = "apps/\(appId)/users/by/\(OS_ONESIGNAL_ID)/\(onesignalId)/identity" + self.path = "apps/\(appId)/users/by/\(alias.label)/\(aliasId)/identity" return true } else { // self.path is non-nil, so set to empty string diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestRemoveAlias.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestRemoveAlias.swift index 207cebd5a..ff5a986d6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestRemoveAlias.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestRemoveAlias.swift @@ -38,22 +38,28 @@ class OSRequestRemoveAlias: OneSignalRequest, OSUserRequest { let labelToRemove: String var identityModel: OSIdentityModel - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + /// See the ownership convention in `OSUserRequest.swift`. + let ownerExternalId: String? + + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { if let onesignalId = identityModel.onesignalId, newRecordsState.canAccess(onesignalId), - let appId = OneSignalIdentifiers.currentAppId + let appId = OneSignalIdentifiers.currentAppId, + let alias = auth.authorizeUserScoped(self, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, onesignalId)), + let aliasId = OSUrlPath.segment(alias.id), + let encodedLabelToRemove = OSUrlPath.segment(labelToRemove) { - self.addJWTHeader(identityModel: identityModel) - self.path = "apps/\(appId)/users/by/\(OS_ONESIGNAL_ID)/\(onesignalId)/identity/\(labelToRemove)" + self.path = "apps/\(appId)/users/by/\(alias.label)/\(aliasId)/identity/\(encodedLabelToRemove)" return true } else { return false } } - init(labelToRemove: String, identityModel: OSIdentityModel) { + init(labelToRemove: String, identityModel: OSIdentityModel, ownerExternalId: String?) { self.labelToRemove = labelToRemove self.identityModel = identityModel + self.ownerExternalId = ownerExternalId self.stringDescription = "OSRequestRemoveAlias with aliasLabel: \(labelToRemove)" super.init() self.method = DELETE @@ -62,6 +68,7 @@ class OSRequestRemoveAlias: OneSignalRequest, OSUserRequest { func encode(with coder: NSCoder) { coder.encode(labelToRemove, forKey: "labelToRemove") coder.encode(identityModel, forKey: "identityModel") + coder.encode(ownerExternalId, forKey: "ownerExternalId") coder.encode(method.rawValue, forKey: "method") // Encodes as String coder.encode(timestamp, forKey: "timestamp") } @@ -78,6 +85,7 @@ class OSRequestRemoveAlias: OneSignalRequest, OSUserRequest { } self.labelToRemove = labelToRemove self.identityModel = identityModel + self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String self.stringDescription = "OSRequestRemoveAlias with aliasLabel: \(labelToRemove)" super.init() self.method = HTTPMethod(rawValue: rawMethod) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestTransferSubscription.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestTransferSubscription.swift index df0e589a8..1e074cc69 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestTransferSubscription.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestTransferSubscription.swift @@ -43,7 +43,9 @@ class OSRequestTransferSubscription: OneSignalRequest, OSUserRequest { let aliasLabel: String let aliasId: String - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + var ownerExternalId: String? { return nil } + + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { return false } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateProperties.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateProperties.swift index aed93ee04..96dd3d93c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateProperties.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateProperties.swift @@ -37,24 +37,29 @@ class OSRequestUpdateProperties: OneSignalRequest, OSUserRequest { var identityModel: OSIdentityModel + /// See the ownership convention in `OSUserRequest.swift`. + let ownerExternalId: String? + // TODO: Decide if addPushSubscriptionIdToAdditionalHeadersIfNeeded should block. // Note Android adds it to requests, if the push sub ID exists - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { if let onesignalId = identityModel.onesignalId, newRecordsState.canAccess(onesignalId), - let appId = OneSignalIdentifiers.currentAppId + let appId = OneSignalIdentifiers.currentAppId, + let alias = auth.authorizeUserScoped(self, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, onesignalId)), + let aliasId = OSUrlPath.segment(alias.id) { _ = self.addPushSubscriptionIdToAdditionalHeaders() - self.addJWTHeader(identityModel: identityModel) - self.path = "apps/\(appId)/users/by/\(OS_ONESIGNAL_ID)/\(onesignalId)" + self.path = "apps/\(appId)/users/by/\(alias.label)/\(aliasId)" return true } else { return false } } - init(params: [String: Any], identityModel: OSIdentityModel) { + init(params: [String: Any], identityModel: OSIdentityModel, ownerExternalId: String?) { self.identityModel = identityModel + self.ownerExternalId = ownerExternalId self.stringDescription = "" super.init() self.parameters = params @@ -63,6 +68,7 @@ class OSRequestUpdateProperties: OneSignalRequest, OSUserRequest { func encode(with coder: NSCoder) { coder.encode(identityModel, forKey: "identityModel") + coder.encode(ownerExternalId, forKey: "ownerExternalId") coder.encode(parameters, forKey: "parameters") coder.encode(method.rawValue, forKey: "method") // Encodes as String coder.encode(timestamp, forKey: "timestamp") @@ -79,6 +85,8 @@ class OSRequestUpdateProperties: OneSignalRequest, OSUserRequest { return nil } self.identityModel = identityModel + // Absent in caches written before ownership was stamped; nil reads as anonymous. + self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String self.stringDescription = "" super.init() self.parameters = parameters diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateSubscription.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateSubscription.swift index 3f211fad5..55fe178d0 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateSubscription.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateSubscription.swift @@ -39,12 +39,29 @@ class OSRequestUpdateSubscription: OneSignalRequest, OSUserRequest { } var subscriptionModel: OSSubscriptionModel + /// The user this update was made for; used to file the response's RYW token under their + /// `onesignal_id`. `nil` drops the token. Held as the model because that ID may not exist + /// yet when the request is built. + var identityModel: OSIdentityModel? + + /** + Always nil, so this Request is never signed. Its path names a subscription rather than a user and the + endpoint ignores the token, while owning it would stall the device's push token and notification types + behind an identified user whose token went invalid. + + The Delta it comes from is owned, which is what decides whether the update survives the anonymous purge. + */ + var ownerExternalId: String? { return nil } + + /// The one Request Identity Verification lets through unowned, for the reason `ownerExternalId` gives. + var sendsUnsigned: Bool { return true } // Need the subscription_id - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { if let subscriptionId = subscriptionModel.subscriptionId, newRecordsState.canAccess(subscriptionId), - let appId = OneSignalIdentifiers.currentAppId + let appId = OneSignalIdentifiers.currentAppId, + auth.authorize(self) { self.path = "apps/\(appId)/subscriptions/\(subscriptionId)" // Refresh so a stale snapshot queued earlier can't overwrite newer local state. @@ -62,16 +79,18 @@ class OSRequestUpdateSubscription: OneSignalRequest, OSUserRequest { subscriptionParams["device_os"] = subscriptionModel.deviceOs subscriptionParams["sdk"] = subscriptionModel.sdk subscriptionParams["app_version"] = subscriptionModel.appVersion - // notificationTypes defaults to -1 instead of nil, don't send if it's -1 - if subscriptionModel.notificationTypes != -1 { - subscriptionParams["notification_types"] = subscriptionModel.notificationTypes + + let enablement = subscriptionModel.reportedEnablement() + subscriptionParams["enabled"] = enablement.enabled + if let notificationTypes = enablement.notificationTypes { + subscriptionParams["notification_types"] = notificationTypes } - subscriptionParams["enabled"] = subscriptionModel.enabled self.parameters = ["subscription": subscriptionParams] } - init(subscriptionModel: OSSubscriptionModel) { + init(subscriptionModel: OSSubscriptionModel, identityModel: OSIdentityModel?) { self.subscriptionModel = subscriptionModel + self.identityModel = identityModel self.stringDescription = "OSRequestUpdateSubscription with model: \(subscriptionModel.modelId)" super.init() refreshParametersFromLiveModel() @@ -80,6 +99,7 @@ class OSRequestUpdateSubscription: OneSignalRequest, OSUserRequest { func encode(with coder: NSCoder) { coder.encode(subscriptionModel, forKey: "subscriptionModel") + coder.encode(identityModel, forKey: "identityModel") coder.encode(parameters, forKey: "parameters") coder.encode(method.rawValue, forKey: "method") // Encodes as String coder.encode(timestamp, forKey: "timestamp") @@ -96,6 +116,7 @@ class OSRequestUpdateSubscription: OneSignalRequest, OSUserRequest { return nil } self.subscriptionModel = subscriptionModel + self.identityModel = coder.decodeObject(forKey: "identityModel") as? OSIdentityModel self.stringDescription = "OSRequestUpdateSubscription with parameters: \(parameters)" super.init() self.parameters = parameters diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift index 133473ba8..7842b4708 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift @@ -30,19 +30,48 @@ import OneSignalOSCore protocol OSUserRequest: OneSignalRequest, NSCoding { var sentToClient: Bool { get set } - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool + + /// The user this Request belongs to; also selects its token. See the ownership convention below. + var ownerExternalId: String? { get } + + /// Whether this Request may still be sent with no `Authorization` header once Identity Verification + /// is in effect. Only Update Subscription may: its path names a subscription rather than a user, so + /// there is no user for the server to authorize. Everything else with no owner is refused. + var sendsUnsigned: Bool { get } + + /// Builds the path and resolves authorization. `false` leaves the Request queued, whether it is + /// waiting on a record it cannot address yet or on a token it cannot sign with yet. A caller deciding + /// whether to *discard* a cached Request must not read `false` as permanent: an owned Request becomes + /// sendable once `updateUserJwt` supplies its token. + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool } -internal extension OneSignalRequest { - func addJWTHeader(identityModel: OSIdentityModel) { -// guard let token = identityModel.jwtBearerToken else { -// return -// } -// var additionalHeaders = self.additionalHeaders ?? [String:String]() -// additionalHeaders["Authorization"] = "Bearer \(token)" -// self.additionalHeaders = additionalHeaders - } +extension OSUserRequest { + var sendsUnsigned: Bool { return false } +} + +/* + Ownership convention: a Request that Identity Verification can purge stores `ownerExternalId`, the + owner's `external_id` as of when the Request was built, and both the purge and the token lookup + judge it by that rather than by its `identityModel`. + + The live model cannot answer the question. `clearUserData` empties an Identity Model's aliases before + a fetch response hydrates them, so for that window an identified user reads as anonymous and a purge + running alongside it would delete signed work. The stamp also matches how `OSDelta` carries + `externalId`, which keeps a Delta and the Request built from it judged the same way. + + nil means anonymous, including for caches written before ownership was stamped. + + Create User and Fetch User are not built from a Delta and the purge does not consider them, so they + read the owner off their Identity Model. Nothing is in flight when a purge runs, and the User + executor sends nothing while the requirement is unknown, so the live read is sound there. + Three Requests are nil by construction and so are never signed: Identify User and Fetch Identity By + Subscription both address a user that has no `external_id` yet, and Update Subscription is the + device's own push subscription. Each says why at its declaration. + */ + +internal extension OneSignalRequest { /** Returns if the `OneSignal-Subscription-Id` header was added successfully. */ func addPushSubscriptionIdToAdditionalHeaders() -> Bool { if let pushSubscriptionId = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift index e626f423a..430d25b0f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift @@ -37,7 +37,10 @@ public class OneSignalUserMocks: NSObject { // TODO: create mocked server responses to user requests @objc public static func reset() { - OSCoreMocks.resetOperationRepo() + // Drop the previous test's handlers before the hydrate below, or leftover Requests hydrate shared models. + OneSignalUserManagerImpl.sharedInstance.identityVerificationService.removeOnJwtConfigHydratedHandler(for: .userExecutor) + OneSignalUserManagerImpl.sharedInstance.identityVerificationService.removeOnJwtConfigHydratedHandler(for: .userManager) + OneSignalUserManagerImpl.sharedInstance.operationRepo.reset() OSCoreMocks.resetSharedJwtConfig() // Hydrate `off` so the Operation Repo's unknown-requirement deferral does not stall non-IV tests. OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/CustomEventsIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/CustomEventsIntegrationTests.swift index e64b057dc..67eebcc75 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/CustomEventsIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/CustomEventsIntegrationTests.swift @@ -62,7 +62,7 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "test_event", properties: properties) - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) /* Then */ @@ -80,7 +80,7 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "test_event", properties: nil) - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) /* Then */ @@ -98,7 +98,7 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "test_event", properties: [:]) - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) /* Then */ @@ -119,7 +119,7 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "test_event", properties: invalidProperties) - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) /* Then - No request should be made */ @@ -154,7 +154,7 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "complex_event", properties: complexProperties) - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) /* Then */ @@ -220,7 +220,7 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "array_event", properties: properties) - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) /* Then */ diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift index b6fd2de5f..24f8fc772 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift @@ -202,6 +202,36 @@ final class DeltaOwnershipTests: XCTestCase { XCTAssertEqual(delta.identityModelId, first.identityModel.modelId) } + // MARK: - Requests built from a Delta + + func testUpdateSubscriptionRequestIsBoundToTheDeltasOwner() throws { + let client = executingClient() + let user = newUser(externalId: userA) + let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) + + executor.enqueueDelta(subscriptionUpdateDelta(owner: user.identityModel)) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + let request = try XCTUnwrap(client.executedRequests.compactMap { $0 as? OSRequestUpdateSubscription }.first) + XCTAssertTrue(request.identityModel === user.identityModel) + } + + /// Unknown owner still sends; only the RYW token is dropped, not misfiled under the current user. + func testASubscriptionRequestStillSendsWhenTheOwningIdentityIsUnknown() throws { + let client = executingClient() + newUser(externalId: userB) + let unknownOwner = OSIdentityModel(aliases: [OS_EXTERNAL_ID: userA], changeNotifier: OSEventProducer()) + let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) + + executor.enqueueDelta(subscriptionUpdateDelta(owner: unknownOwner)) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + let request = try XCTUnwrap(client.executedRequests.compactMap { $0 as? OSRequestUpdateSubscription }.first) + XCTAssertNil(request.identityModel) + } + // MARK: - Helpers @discardableResult @@ -219,6 +249,13 @@ final class DeltaOwnershipTests: XCTestCase { ) } + private func executingClient() -> MockOneSignalClient { + let client = MockOneSignalClient() + client.fireSuccessForAllRequests = true + OneSignalCoreImpl.setSharedClient(client) + return client + } + private func emailSubscriptionModel() -> OSSubscriptionModel { return OSSubscriptionModel( type: .email, @@ -230,6 +267,18 @@ final class DeltaOwnershipTests: XCTestCase { ) } + private func subscriptionUpdateDelta(owner: OSIdentityModel) -> OSDelta { + let model = emailSubscriptionModel() + return OSDelta( + name: OS_UPDATE_SUBSCRIPTION_DELTA, + identityModelId: owner.modelId, + externalId: owner.externalId, + model: model, + property: model.type.rawValue, + value: model.address ?? "" + ) + } + private func queuedDelta(named name: String, property: String) -> OSDelta? { return OneSignalUserManagerImpl.sharedInstance.operationRepo.deltaQueue.first { $0.name == name && $0.property == property } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift new file mode 100644 index 000000000..b23a70d4e --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift @@ -0,0 +1,358 @@ +/* + 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 +@testable import OneSignalUser + +/** + What each executor drops from its queues when Identity Verification turns out to be required. + + Only ownership is judged: a Delta or Request carries the `external_id` of the user it was built for, and + one carrying none can never be signed. The auth layer refuses to send those, so the purge is what keeps + them from sitting in the queues unsendable for the rest of the session. + */ +final class ExecutorAnonymousPurgeTests: XCTestCase { + private let anonymousOSID = "test-anonymous-onesignal-id" + private let ownedToken = "token-a" + private let anonymousSubscriptionId = "test-anonymous-subscription-id" + private let ownedSubscriptionId = "test-owned-subscription-id" + + private var client = MockOneSignalClient() + private var newRecordsState = MockNewRecordsState() + private var anonymous = OSIdentityModel(aliases: nil, changeNotifier: OSEventProducer()) + private var owned = OSIdentityModel(aliases: nil, changeNotifier: OSEventProducer()) + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + + client = MockOneSignalClient() + client.fireSuccessForAllRequests = true + OneSignalCoreImpl.setSharedClient(client) + newRecordsState = MockNewRecordsState() + + // The purge only ever runs because the requirement came back requiring auth. + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + anonymous = addUserToRepo(externalId: nil, onesignalId: anonymousOSID) + owned = addUserToRepo(externalId: userA_EUID, onesignalId: userA_OSID) + } + + override func tearDownWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + } + + // MARK: - Setup helpers + + /// A user the executors can resolve Deltas and Requests against. The identified one can sign. + private func addUserToRepo(externalId: String?, onesignalId: String) -> OSIdentityModel { + var aliases = [OS_ONESIGNAL_ID: onesignalId] + if let externalId = externalId { + aliases[OS_EXTERNAL_ID] = externalId + } + let model = OSIdentityModel(aliases: aliases, changeNotifier: OSEventProducer()) + if externalId != nil { + model.jwtBearerToken = ownedToken + } + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(model) + return model + } + + /// Holds both users' ids, and any others passed, so nothing can be sent while they propagate. The + /// Requests the Deltas became then wait in the executor's queues, which is where the purge is visible. + private func holdIds(_ ids: String...) { + for id in [anonymousOSID, userA_OSID] + ids { + newRecordsState.add(id) + } + } + + private var auth: OSRequestAuthorizing { + return OneSignalUserManagerImpl.sharedInstance.requestAuth + } + + private func delta(_ name: String, for identityModel: OSIdentityModel, model: OSModel, property: String, value: Any) -> OSDelta { + return OSDelta( + name: name, + identityModelId: identityModel.modelId, + externalId: identityModel.externalId, + model: model, + property: property, + value: value + ) + } + + private func subscription(id: String) -> OSSubscriptionModel { + return OSSubscriptionModel( + type: .email, + address: "\(id)@example.com", + subscriptionId: id, + reachable: true, + isDisabled: false, + changeNotifier: OSEventProducer() + ) + } + + // MARK: - Assertion helpers + + private func cachedRequestOwners(_ key: String, of type: T.Type) -> [String?] { + let requests = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: key, defaultValue: []) as? [T] ?? [] + return requests.map { $0.ownerExternalId } + } + + private func executedPaths() -> [String] { + return client.executedRequests.map { $0.path } + } + + private func userPath(_ suffix: String = "") -> String { + return "apps/test-app-id/users/by/\(OS_EXTERNAL_ID)/\(userA_EUID)" + suffix + } + + // MARK: - Properties + + func testThePropertyExecutorSendsOnlyTheIdentifiedUsersUpdate() { + let executor = OSPropertyOperationExecutor(newRecordsState: newRecordsState, auth: auth) + executor.enqueueDelta(propertiesDelta(for: anonymous)) + executor.enqueueDelta(propertiesDelta(for: owned)) + + executor.removeOperationsWithoutExternalId() + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestUpdateProperties.self, expectedCount: 1)) + XCTAssertEqual(executedPaths(), [userPath()]) + } + + func testThePropertyExecutorDropsTheAnonymousUpdateRequest() { + holdIds() + let executor = OSPropertyOperationExecutor(newRecordsState: newRecordsState, auth: auth) + executor.enqueueDelta(propertiesDelta(for: anonymous)) + executor.enqueueDelta(propertiesDelta(for: owned)) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + XCTAssertEqual(client.executedRequests.count, 0) + + executor.removeOperationsWithoutExternalId() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(cachedRequestOwners(OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, of: OSRequestUpdateProperties.self), [userA_EUID]) + } + + /// The purge writes through to the cache, so a relaunch reads back the identified user's Request alone. + func testAnAnonymousUpdateRequestIsNotRestoredAfterThePurge() { + holdIds() + let executor = OSPropertyOperationExecutor(newRecordsState: newRecordsState, auth: auth) + executor.enqueueDelta(propertiesDelta(for: anonymous)) + executor.enqueueDelta(propertiesDelta(for: owned)) + executor.processDeltaQueue(inBackground: false) + executor.removeOperationsWithoutExternalId() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + // A records state with nothing held stands in for ids that have since propagated. + newRecordsState = MockNewRecordsState() + let relaunched = OSPropertyOperationExecutor(newRecordsState: newRecordsState, auth: auth) + relaunched.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestUpdateProperties.self, expectedCount: 1)) + XCTAssertEqual(executedPaths(), [userPath()]) + } + + private func propertiesDelta(for identityModel: OSIdentityModel) -> OSDelta { + return delta( + OS_UPDATE_PROPERTIES_DELTA, + for: identityModel, + model: OSModel(changeNotifier: OSEventProducer()), + property: "language", + value: "en" + ) + } + + // MARK: - Custom events + + func testTheCustomEventsExecutorSendsOnlyTheIdentifiedUsersEvents() { + let executor = OSCustomEventsExecutor(newRecordsState: newRecordsState, auth: auth) + executor.enqueueDelta(customEventDelta(for: anonymous)) + executor.enqueueDelta(customEventDelta(for: owned)) + + executor.removeOperationsWithoutExternalId() + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + // The path names the app rather than the user, so the events themselves say who survived. + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCustomEvents.self, expectedCount: 1)) + let events = client.executedRequests.first?.parameters?["events"] as? [[String: Any]] + XCTAssertEqual(events?.count, 1) + XCTAssertEqual(events?.first?["onesignal_id"] as? String, userA_OSID) + } + + func testTheCustomEventsExecutorDropsTheAnonymousEventsRequest() { + holdIds() + let executor = OSCustomEventsExecutor(newRecordsState: newRecordsState, auth: auth) + executor.enqueueDelta(customEventDelta(for: anonymous)) + executor.enqueueDelta(customEventDelta(for: owned)) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + XCTAssertEqual(client.executedRequests.count, 0) + + executor.removeOperationsWithoutExternalId() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(cachedRequestOwners(OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, of: OSRequestCustomEvents.self), [userA_EUID]) + } + + private func customEventDelta(for identityModel: OSIdentityModel) -> OSDelta { + return delta( + OS_CUSTOM_EVENT_DELTA, + for: identityModel, + model: OSModel(changeNotifier: OSEventProducer()), + property: "test_event", + value: ["test_property": "test-value"] + ) + } + + // MARK: - Identity + + func testTheIdentityExecutorSendsOnlyTheIdentifiedUsersAlias() { + let executor = OSIdentityOperationExecutor(newRecordsState: newRecordsState, auth: auth) + executor.enqueueDelta(addAliasDelta(for: anonymous)) + executor.enqueueDelta(addAliasDelta(for: owned)) + + executor.removeOperationsWithoutExternalId() + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestAddAliases.self, expectedCount: 1)) + XCTAssertEqual(executedPaths(), [userPath("/identity")]) + } + + func testTheIdentityExecutorDropsBothAnonymousAliasRequests() { + holdIds() + let executor = OSIdentityOperationExecutor(newRecordsState: newRecordsState, auth: auth) + for identity in [anonymous, owned] { + executor.enqueueDelta(addAliasDelta(for: identity)) + executor.enqueueDelta(removeAliasDelta(for: identity)) + } + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + XCTAssertEqual(client.executedRequests.count, 0) + + executor.removeOperationsWithoutExternalId() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(cachedRequestOwners(OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, of: OSRequestAddAliases.self), [userA_EUID]) + XCTAssertEqual(cachedRequestOwners(OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, of: OSRequestRemoveAlias.self), [userA_EUID]) + } + + private func addAliasDelta(for identityModel: OSIdentityModel) -> OSDelta { + return delta( + OS_ADD_ALIAS_DELTA, + for: identityModel, + model: identityModel, + property: "aliases", + value: ["test_alias_label": "test-alias-id"] + ) + } + + private func removeAliasDelta(for identityModel: OSIdentityModel) -> OSDelta { + return delta( + OS_REMOVE_ALIAS_DELTA, + for: identityModel, + model: identityModel, + property: "aliases", + value: ["test_alias_label": ""] + ) + } + + // MARK: - Subscriptions + + func testTheSubscriptionExecutorSendsOnlyTheIdentifiedUsersNewSubscription() { + let executor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, auth: auth) + executor.enqueueDelta(addSubscriptionDelta(for: anonymous, subscriptionId: anonymousSubscriptionId)) + executor.enqueueDelta(addSubscriptionDelta(for: owned, subscriptionId: ownedSubscriptionId)) + + executor.removeOperationsWithoutExternalId() + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCreateSubscription.self, expectedCount: 1)) + XCTAssertEqual(executedPaths(), [userPath("/subscriptions")]) + } + + func testTheSubscriptionExecutorDropsTheAnonymousAddAndDeleteRequests() { + holdIds(anonymousSubscriptionId, ownedSubscriptionId) + let executor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, auth: auth) + enqueueSubscriptionWork(on: executor) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + XCTAssertEqual(client.executedRequests.count, 0) + + executor.removeOperationsWithoutExternalId() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(cachedRequestOwners(OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, of: OSRequestCreateSubscription.self), [userA_EUID]) + XCTAssertEqual(cachedRequestOwners(OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, of: OSRequestDeleteSubscription.self), [userA_EUID]) + } + + /// An Update Subscription is addressed by subscription ID and never signed, so it has no owner to be + /// judged by and the purge has to leave that queue alone: `logout()`'s unsubscribe travels in it. + func testTheSubscriptionExecutorKeepsEveryUpdateRequest() { + holdIds(anonymousSubscriptionId, ownedSubscriptionId) + let executor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, auth: auth) + enqueueSubscriptionWork(on: executor) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + executor.removeOperationsWithoutExternalId() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + let updateOwners = cachedRequestOwners(OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, of: OSRequestUpdateSubscription.self) + XCTAssertEqual(updateOwners.count, 2) + XCTAssertTrue(updateOwners.allSatisfy { $0 == nil }) + } + + /// An add, a delete and an update for each user, turned into Requests that cannot be sent yet. + private func enqueueSubscriptionWork(on executor: OSSubscriptionOperationExecutor) { + for (identity, subscriptionId) in [(anonymous, anonymousSubscriptionId), (owned, ownedSubscriptionId)] { + let model = subscription(id: subscriptionId) + for name in [OS_ADD_SUBSCRIPTION_DELTA, OS_REMOVE_SUBSCRIPTION_DELTA, OS_UPDATE_SUBSCRIPTION_DELTA] { + executor.enqueueDelta(subscriptionDelta(name, for: identity, subscription: model)) + } + } + executor.processDeltaQueue(inBackground: false) + } + + private func addSubscriptionDelta(for identityModel: OSIdentityModel, subscriptionId: String) -> OSDelta { + return subscriptionDelta(OS_ADD_SUBSCRIPTION_DELTA, for: identityModel, subscription: subscription(id: subscriptionId)) + } + + private func subscriptionDelta(_ name: String, for identityModel: OSIdentityModel, subscription: OSSubscriptionModel) -> OSDelta { + return delta(name, for: identityModel, model: subscription, property: "optedIn", value: true) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift index 12d969d74..f3972f321 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift @@ -40,7 +40,7 @@ private class CustomEventsMocks { init() { OneSignalCoreImpl.setSharedClient(client) - customEventsExecutor = OSCustomEventsExecutor(newRecordsState: newRecordsState) + customEventsExecutor = OSCustomEventsExecutor(newRecordsState: newRecordsState, auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift index 39ad19648..9fe199759 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift @@ -59,7 +59,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { let model = makePushSubscriptionModel(notificationTypes: promptedNeverAnswered, subscriptionId: subscriptionId) XCTAssertFalse(model.enabled) - let request = OSRequestUpdateSubscription(subscriptionModel: model) + let request = OSRequestUpdateSubscription(subscriptionModel: model, identityModel: nil) let atInit = try XCTUnwrap(request.parameters?["subscription"] as? [String: Any]) XCTAssertEqual(atInit["notification_types"] as? Int, promptedNeverAnswered) XCTAssertEqual(atInit["enabled"] as? Bool, false) @@ -68,7 +68,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { model.notificationTypes = subscribedNotificationTypes XCTAssertTrue(model.enabled) - XCTAssertTrue(request.prepareForExecution(newRecordsState: OSNewRecordsState())) + XCTAssertTrue(request.prepareForExecution(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth)) let refreshed = try XCTUnwrap(request.parameters?["subscription"] as? [String: Any]) XCTAssertEqual(refreshed["notification_types"] as? Int, subscribedNotificationTypes) @@ -85,7 +85,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { client.fireSuccessForAllRequests = true OneSignalCoreImpl.setSharedClient(client) - let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState()) + let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) // Without a subscriptionId, prepareForExecution keeps the update pending. let model = makePushSubscriptionModel(notificationTypes: promptedNeverAnswered, subscriptionId: nil) let identityModelId = UUID().uuidString @@ -145,7 +145,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { client.fireSuccessForAllRequests = true OneSignalCoreImpl.setSharedClient(client) - let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState()) + let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) let model = makePushSubscriptionModel(notificationTypes: promptedNeverAnswered, subscriptionId: subscriptionId) let identityModelId = UUID().uuidString @@ -204,7 +204,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { client.fireSuccessForAllRequests = true OneSignalCoreImpl.setSharedClient(client) - let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState()) + let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) let model = makePushSubscriptionModel(notificationTypes: promptedNeverAnswered, subscriptionId: subscriptionId) let identityModelId = UUID().uuidString diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift index 703079453..570986fe9 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift @@ -39,9 +39,11 @@ private class Mocks { let newRecordsState = MockNewRecordsState() let userExecutor: OSUserExecutor - init() { + /// Stub before building the executor so a seeded Request cache cannot race the init-time send. + init(stubResponses: (MockOneSignalClient) -> Void = { _ in }) { OneSignalCoreImpl.setSharedClient(client) - userExecutor = OSUserExecutor(newRecordsState: newRecordsState) + stubResponses(client) + userExecutor = OSUserExecutor(newRecordsState: newRecordsState, identityVerificationService: OneSignalUserManagerImpl.sharedInstance.identityVerificationService, auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) } func createUserInstance(externalId: String) -> OSUserInternal { @@ -69,9 +71,12 @@ final class UserExecutorTests: XCTestCase { /* Setup */ let mocks = Mocks() MockUserRequests.setDefaultCreateUserResponses(with: mocks.client, externalId: userA_EUID, subscriptionId: "push-sub-id") + let user = mocks.createUserInstance(externalId: userA_EUID) + // Current so Create User keeps push; otherwise a prior-user create omits subscriptions. + OneSignalUserManagerImpl.sharedInstance._user = user /* When */ - mocks.userExecutor.createUser(mocks.createUserInstance(externalId: userA_EUID)) + mocks.userExecutor.createUser(user) OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) /* Then */ @@ -79,6 +84,58 @@ final class UserExecutorTests: XCTestCase { XCTAssertTrue(mocks.newRecordsState.contains("push-sub-id")) } + /// A Create User for a prior login must not include the device push subscription, which the + /// current user now owns — sending it would transfer that push on the server. + func testCreateUser_forPriorIdentifiedUser_omitsPushSubscription() { + /* Setup */ + let mocks = Mocks() + MockUserRequests.setDefaultCreateUserResponses(with: mocks.client, externalId: userA_EUID, subscriptionId: "push-sub-id") + + let sharedPush = OSSubscriptionModel( + type: .push, + address: "test-push-token", + subscriptionId: "shared-push-id", + reachable: true, + isDisabled: false, + changeNotifier: OSEventProducer() + ) + let priorIdentity = OSIdentityModel(aliases: [OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()) + let priorProperties = OSPropertiesModel(changeNotifier: OSEventProducer()) + let priorCreate = OSRequestCreateUser( + identityModel: priorIdentity, + propertiesModel: priorProperties, + pushSubscriptionModel: sharedPush, + originalPushToken: sharedPush.address + ) + XCTAssertNotNil(priorCreate.parameters?["subscriptions"]) + + // Device push now belongs to the current user (B); the parked create still holds the same model. + let currentUser = mocks.createUserInstance(externalId: userB_EUID) + currentUser.pushSubscriptionModel.subscriptionId = "shared-push-id" + OneSignalUserManagerImpl.sharedInstance._user = currentUser + OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModelStore.add( + id: OS_PUSH_SUBSCRIPTION_MODEL_KEY, + model: sharedPush, + hydrating: false + ) + + /* When */ + mocks.userExecutor.executeCreateUserRequest(priorCreate) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + guard let sent = mocks.client.executedRequests.compactMap({ $0 as? OSRequestCreateUser }).first else { + XCTFail("Expected Create User to be sent") + return + } + XCTAssertNil(sent.parameters?["subscriptions"], "Must not transfer the current user's push to a prior Create User") + // Built as a real create, so cool-down the onesignal_id even though push was omitted at send. + XCTAssertTrue(mocks.newRecordsState.contains(userA_OSID)) + XCTAssertFalse(mocks.newRecordsState.contains("push-sub-id")) + XCTAssertFalse(mocks.newRecordsState.contains("shared-push-id")) + } + + /// Identify-409 recovery Create only hydrates an existing user, so its IDs are not new. func testCreateUser_withoutPushSubscription_doesNot_addToNewRecords() { /* Setup */ let mocks = Mocks() @@ -244,4 +301,151 @@ final class UserExecutorTests: XCTestCase { XCTAssertNil(currentUser.identityModel.aliases["stale_label"]) XCTAssertEqual(currentUser.identityModel.externalId, userA_EUID) } + + // MARK: - Identity Verification + + /// Cached Create User with no `external_id` must not go out once Identity Verification is required. + func testAnonymousCachedCreateUserIsDroppedWhenIdentityVerificationIsRequired() { + /* Setup */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + cacheUserRequests([makeAnonymousCreateUserRequest()]) + + /* When */ + let mocks = Mocks() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + } + + /// Same restored Create User goes out when Identity Verification is off. + func testAnonymousCachedCreateUserIsSentWhenIdentityVerificationIsOff() { + /* Setup */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + cacheUserRequests([makeAnonymousCreateUserRequest()]) + + /* When */ + let mocks = Mocks { MockUserRequests.setDefaultCreateAnonUserResponses(with: $0) } + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + } + + /// Nothing is sent while `requirement` is unknown; hydration releases the held Requests. + func testRequestsAreHeldUntilTheRequirementIsKnown() { + /* Setup */ + OSCoreMocks.resetSharedJwtConfig() + let mocks = Mocks() + MockUserRequests.setDefaultCreateUserResponses(with: mocks.client, externalId: userA_EUID) + + /* When */ + mocks.userExecutor.createUser(mocks.createUserInstance(externalId: userA_EUID)) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + + /* When the requirement arrives */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + } + + /// Identify User promotes an anonymous user, which Identity Verification does not allow. A restored one + /// belongs to a user a later `login` has already replaced, so there is no login left to carry over. + func testRestoredIdentifyUserIsDroppedWhenIdentityVerificationIsRequired() { + /* Setup */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + cacheUserRequests([makeIdentifyUserRequest()]) + + /* When */ + let mocks = Mocks { MockUserRequests.setDefaultIdentifyUserResponses(with: $0, externalId: userA_EUID, conflicted: false) } + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + } + + /// Same restored Identify User goes out when Identity Verification is off. + func testRestoredIdentifyUserIsSentWhenIdentityVerificationIsOff() { + /* Setup */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + cacheUserRequests([makeIdentifyUserRequest()]) + + /* When */ + let mocks = Mocks { MockUserRequests.setDefaultIdentifyUserResponses(with: $0, externalId: userA_EUID, conflicted: false) } + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + } + + /// `login` promotes while the requirement is still unknown, so turning out to require auth must not + /// strand that login: it becomes the Create User it would have been. + func testInSessionIdentifyUserBecomesACreateUserWhenIdentityVerificationIsRequired() { + /* Setup */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + let mocks = Mocks() + MockUserRequests.setDefaultIdentifyUserResponses(with: mocks.client, externalId: userA_EUID, conflicted: false) + MockUserRequests.setDefaultCreateUserResponses(with: mocks.client, externalId: userA_EUID) + + let anonIdentityModel = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userA_OSID], changeNotifier: OSEventProducer()) + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: nil) + user.identityModel.jwtBearerToken = "token-a" + + /* When */ + mocks.userExecutor.identifyUser(externalId: userA_EUID, identityModelToIdentify: anonIdentityModel, identityModelToUpdate: user.identityModel) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + } + + /// A promotion whose user a later `login` has already replaced has no login left to carry over. + func testInSessionIdentifyUserForAReplacedUserIsDroppedWhenIdentityVerificationIsRequired() { + /* Setup */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + let mocks = Mocks() + MockUserRequests.setDefaultIdentifyUserResponses(with: mocks.client, externalId: userA_EUID, conflicted: false) + + let anonIdentityModel = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userA_OSID], changeNotifier: OSEventProducer()) + let replacedIdentityModel = OSIdentityModel(aliases: [OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()) + _ = OneSignalUserMocks.setUserManagerInternalUser(externalId: userB_EUID, onesignalId: nil) + + /* When */ + mocks.userExecutor.identifyUser(externalId: userA_EUID, identityModelToIdentify: anonIdentityModel, identityModelToUpdate: replacedIdentityModel) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + } + + private func cacheUserRequests(_ requests: [OSUserRequest]) { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, withValue: requests) + } + + private func makeIdentifyUserRequest() -> OSRequestIdentifyUser { + return OSRequestIdentifyUser( + aliasLabel: OS_EXTERNAL_ID, + aliasId: userA_EUID, + identityModelToIdentify: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userA_OSID], changeNotifier: OSEventProducer()), + identityModelToUpdate: OSIdentityModel(aliases: [OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()) + ) + } + + private func makeAnonymousCreateUserRequest() -> OSRequestCreateUser { + let pushModel = OSSubscriptionModel(type: .push, address: nil, subscriptionId: nil, reachable: false, isDisabled: false, changeNotifier: OSEventProducer()) + return OSRequestCreateUser( + identityModel: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userA_OSID], changeNotifier: OSEventProducer()), + propertiesModel: OSPropertiesModel(changeNotifier: OSEventProducer()), + pushSubscriptionModel: pushModel, + originalPushToken: nil + ) + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSRequestAuthTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSRequestAuthTests.swift new file mode 100644 index 000000000..b7574e446 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSRequestAuthTests.swift @@ -0,0 +1,356 @@ +/* + 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 +@testable import OneSignalUser + +/// A stand-in for whichever concrete Request is being authorized; only ownership and the header matter here. +/// Named for the runtime because `OSUserRequest` requires `NSCoding` and a private class has no stable name. +@objc(OSStubUserRequest) +private class StubUserRequest: OneSignalRequest, OSUserRequest { + var sentToClient = false + let ownerExternalId: String? + let sendsUnsigned: Bool + + init(ownerExternalId: String?, sendsUnsigned: Bool = false) { + self.ownerExternalId = ownerExternalId + self.sendsUnsigned = sendsUnsigned + super.init() + } + + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { + return true + } + + func encode(with coder: NSCoder) { + coder.encode(ownerExternalId, forKey: "ownerExternalId") + coder.encode(sendsUnsigned, forKey: "sendsUnsigned") + } + + required init?(coder: NSCoder) { + self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String + self.sendsUnsigned = coder.decodeBool(forKey: "sendsUnsigned") + super.init() + } + + var authorizationHeader: String? { + return additionalHeaders?["Authorization"] + } +} + +private class StubJwtProvider: OSUserJwtProviding { + var tokens: [String: String] = [:] + private(set) var invalidatedCalls: [(externalId: String, rejectedToken: String)] = [] + private(set) var askedFor: [String] = [] + + func validJwt(externalId: String) -> String? { + return tokens[externalId] + } + + @discardableResult + func askForToken(externalId: String) -> Bool { + askedFor.append(externalId) + return true + } + + @discardableResult + func invalidateJwt(externalId: String, rejectedToken: String) -> Bool { + invalidatedCalls.append((externalId, rejectedToken)) + tokens.removeValue(forKey: externalId) + return true + } +} + +final class OSRequestAuthTests: XCTestCase { + private var jwtConfig: OSUserJwtConfig! + private var jwt: StubJwtProvider! + + override func setUp() { + super.setUp() + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_USE_IDENTITY_VERIFICATION) + jwtConfig = OSUserJwtConfig() + jwt = StubJwtProvider() + } + + override func tearDown() { + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_USE_IDENTITY_VERIFICATION) + super.tearDown() + } + + private func makeAuth(requiresUserAuth: Bool) -> OSRequestAuth { + jwtConfig.hydrate(requiresUserAuth: requiresUserAuth) + return makeAuth(enabledKeys: []) + } + + /// For the cases that turn on the rollout flag, or leave the requirement unhydrated, or both. + private func makeAuth(enabledKeys: Set) -> OSRequestAuth { + let service = OSIdentityVerificationService(featureManager: OSFeatureManager(enabledKeys: enabledKeys), jwtConfig: jwtConfig) + return OSRequestAuth(identityVerificationService: service, jwt: jwt) + } + + // MARK: - authorizeUserScoped + + func testUserScopedKeepsTheLegacyAliasAndSendsNoHeaderWhileIdentityVerificationIsOff() { + let auth = makeAuth(requiresUserAuth: false) + jwt.tokens["user-a"] = "token-a" + let request = StubUserRequest(ownerExternalId: "user-a") + + let alias = auth.authorizeUserScoped(request, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, "osid-a")) + + XCTAssertEqual(alias?.label, OS_ONESIGNAL_ID) + XCTAssertEqual(alias?.id, "osid-a") + XCTAssertNil(request.authorizationHeader) + } + + func testUserScopedSwapsToExternalIdAndSignsWhileIdentityVerificationIsOn() { + let auth = makeAuth(requiresUserAuth: true) + jwt.tokens["user-a"] = "token-a" + let request = StubUserRequest(ownerExternalId: "user-a") + + let alias = auth.authorizeUserScoped(request, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, "osid-a")) + + XCTAssertEqual(alias?.label, OS_EXTERNAL_ID) + XCTAssertEqual(alias?.id, "user-a") + XCTAssertEqual(request.authorizationHeader, "Bearer token-a") + } + + /// nil is the park signal: the caller leaves the Request queued rather than sending it unsigned. + func testUserScopedReturnsNilWhenTheOwnerHasNoToken() { + let auth = makeAuth(requiresUserAuth: true) + let request = StubUserRequest(ownerExternalId: "user-a") + + XCTAssertNil(auth.authorizeUserScoped(request, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, "osid-a"))) + XCTAssertNil(request.authorizationHeader) + // Nothing else prompts the app for a token the SDK never held, so parking has to. + XCTAssertEqual(jwt.askedFor, ["user-a"]) + } + + /// Addressing it by `onesignal_id` would send a user-scoped path unsigned, so it waits for the purge. + func testUserScopedRefusesAnUnownedRequest() { + let auth = makeAuth(requiresUserAuth: true) + let request = StubUserRequest(ownerExternalId: nil) + + XCTAssertNil(auth.authorizeUserScoped(request, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, "osid-a"))) + XCTAssertNil(request.authorizationHeader) + // There is no owner to ask on behalf of, so the app must not be prompted. + XCTAssertTrue(jwt.askedFor.isEmpty) + } + + func testUserScopedKeepsAddressingAnUnownedRequestWhileIdentityVerificationIsOff() { + let auth = makeAuth(requiresUserAuth: false) + let request = StubUserRequest(ownerExternalId: nil) + + let alias = auth.authorizeUserScoped(request, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, "osid-a")) + + XCTAssertEqual(alias?.label, OS_ONESIGNAL_ID) + XCTAssertEqual(alias?.id, "osid-a") + } + + /// Fetch User can be built to read an alias other than `onesignal_id`, and that survives the gate being off. + func testUserScopedPreservesACallerSuppliedLegacyAlias() { + let auth = makeAuth(requiresUserAuth: false) + let request = StubUserRequest(ownerExternalId: "user-a") + + let alias = auth.authorizeUserScoped(request, legacyAlias: OSAliasPair(OS_EXTERNAL_ID, "user-a")) + + XCTAssertEqual(alias?.label, OS_EXTERNAL_ID) + XCTAssertEqual(alias?.id, "user-a") + } + + // MARK: - authorize + + func testAuthorizeSendsNoHeaderWhileIdentityVerificationIsOff() { + let auth = makeAuth(requiresUserAuth: false) + jwt.tokens["user-a"] = "token-a" + let request = StubUserRequest(ownerExternalId: "user-a") + + XCTAssertTrue(auth.authorize(request)) + XCTAssertNil(request.authorizationHeader) + } + + func testAuthorizeSignsAnOwnedRequestWhileIdentityVerificationIsOn() { + let auth = makeAuth(requiresUserAuth: true) + jwt.tokens["user-a"] = "token-a" + let request = StubUserRequest(ownerExternalId: "user-a") + + XCTAssertTrue(auth.authorize(request)) + XCTAssertEqual(request.authorizationHeader, "Bearer token-a") + } + + func testAuthorizeParksAnOwnedRequestWithNoToken() { + let auth = makeAuth(requiresUserAuth: true) + let request = StubUserRequest(ownerExternalId: "user-a") + + XCTAssertFalse(auth.authorize(request)) + XCTAssertEqual(jwt.askedFor, ["user-a"]) + } + + /// A signed Request must not re-ask: the app has already answered for this user. + func testAuthorizeDoesNotAskWhenTheOwnerHasAToken() { + let auth = makeAuth(requiresUserAuth: true) + jwt.tokens["user-a"] = "token-a" + let request = StubUserRequest(ownerExternalId: "user-a") + + XCTAssertTrue(auth.authorize(request)) + XCTAssertTrue(jwt.askedFor.isEmpty) + } + + /// The push subscription update has no owner and must keep flowing under Identity Verification. + func testAuthorizeSendsAnUnownedRequestUnsignedWhenItIsExempt() { + let auth = makeAuth(requiresUserAuth: true) + let request = StubUserRequest(ownerExternalId: nil, sendsUnsigned: true) + + XCTAssertTrue(auth.authorize(request)) + XCTAssertNil(request.authorizationHeader) + } + + /// Everything else with no owner is a leftover the purge has yet to clear, and must not go out unsigned. + func testAuthorizeRefusesAnUnownedRequestThatIsNotExempt() { + let auth = makeAuth(requiresUserAuth: true) + let request = StubUserRequest(ownerExternalId: nil) + + XCTAssertFalse(auth.authorize(request)) + XCTAssertTrue(jwt.askedFor.isEmpty) + } + + func testAuthorizeSendsAnUnownedRequestWhileIdentityVerificationIsOff() { + let auth = makeAuth(requiresUserAuth: false) + let request = StubUserRequest(ownerExternalId: nil) + + XCTAssertTrue(auth.authorize(request)) + XCTAssertNil(request.authorizationHeader) + } + + // MARK: - handleUnauthorized + + func testHandleUnauthorizedInvalidatesTheSignedTokenAndRequeuesTheRequest() { + let auth = makeAuth(requiresUserAuth: true) + jwt.tokens["user-a"] = "token-a" + let request = StubUserRequest(ownerExternalId: "user-a") + _ = auth.authorize(request) + request.sentToClient = true + + XCTAssertTrue(auth.handleUnauthorized(request)) + + // The token that went out is the one invalidated, and the Request is left ready to re-sign. + XCTAssertEqual(jwt.invalidatedCalls.count, 1) + XCTAssertEqual(jwt.invalidatedCalls.first?.externalId, "user-a") + XCTAssertEqual(jwt.invalidatedCalls.first?.rejectedToken, "token-a") + XCTAssertNil(request.authorizationHeader) + XCTAssertFalse(request.sentToClient) + } + + /// The header carries the token, so a Request sent before the app supplied one has nothing to reject. + func testHandleUnauthorizedDeclinesAnUnsignedRequest() { + let auth = makeAuth(requiresUserAuth: true) + let request = StubUserRequest(ownerExternalId: "user-a") + request.sentToClient = true + + XCTAssertFalse(auth.handleUnauthorized(request)) + XCTAssertTrue(jwt.invalidatedCalls.isEmpty) + XCTAssertTrue(request.sentToClient) + } + + /// With the gate off a 401 stays on the executor's existing non-retryable path. + func testHandleUnauthorizedDeclinesWhileIdentityVerificationIsOff() { + let auth = makeAuth(requiresUserAuth: false) + let request = StubUserRequest(ownerExternalId: "user-a") + request.additionalHeaders = ["Authorization": "Bearer token-a"] + request.sentToClient = true + + XCTAssertFalse(auth.handleUnauthorized(request)) + XCTAssertTrue(jwt.invalidatedCalls.isEmpty) + XCTAssertTrue(request.sentToClient) + } + + // MARK: - authorization, for callers outside the Request queues + + /// The in-app message fetch addressed the subscription on its own before Identity Verification. + func testAuthorizationCarriesNoUserWhileTheNewCodePathsAreOff() { + let auth = makeAuth(requiresUserAuth: false) + jwt.tokens["user-a"] = "token-a" + + let authorization = auth.authorization(onesignalId: "osid-a", externalId: "user-a") + + XCTAssertNotNil(authorization) + XCTAssertNil(authorization?.alias) + XCTAssertEqual(authorization?.headers, [:]) + XCTAssertNil(authorization?.token) + } + + func testAuthorizationAddressesTheOnesignalIdWhileIdentityVerificationIsOff() { + jwtConfig.hydrate(requiresUserAuth: false) + let auth = makeAuth(enabledKeys: [OSFeatureFlag.identityVerification.rawValue]) + jwt.tokens["user-a"] = "token-a" + + let authorization = auth.authorization(onesignalId: "osid-a", externalId: "user-a") + + XCTAssertEqual(authorization?.alias?.label, OS_ONESIGNAL_ID) + XCTAssertEqual(authorization?.alias?.id, "osid-a") + XCTAssertEqual(authorization?.headers, [:]) + XCTAssertNil(authorization?.token) + } + + func testAuthorizationAddressesTheExternalIdAndSignsWhileIdentityVerificationIsOn() { + let auth = makeAuth(requiresUserAuth: true) + jwt.tokens["user-a"] = "token-a" + + let authorization = auth.authorization(onesignalId: "osid-a", externalId: "user-a") + + XCTAssertEqual(authorization?.alias?.label, OS_EXTERNAL_ID) + XCTAssertEqual(authorization?.alias?.id, "user-a") + XCTAssertEqual(authorization?.headers, ["Authorization": "Bearer token-a"]) + XCTAssertEqual(authorization?.token, "token-a") + } + + /// nil is the defer signal: an unsigned call would be rejected if the app turns out to require auth. + /// Holds even when the rollout flag is off — otherwise production would send legacy unsigned before + /// the first params answer. + func testAuthorizationDefersWhileTheRequirementIsUnknown() { + let auth = makeAuth(enabledKeys: []) + + XCTAssertNil(auth.authorization(onesignalId: "osid-a", externalId: "user-a")) + XCTAssertTrue(jwt.askedFor.isEmpty) + } + + /// Under Identity Verification the server has nothing to serve a device with no identified user. + func testAuthorizationDefersWhileNobodyIsLoggedIn() { + let auth = makeAuth(requiresUserAuth: true) + + XCTAssertNil(auth.authorization(onesignalId: "osid-a", externalId: nil)) + XCTAssertTrue(jwt.askedFor.isEmpty) + } + + func testAuthorizationDefersAndAsksWhenTheUserHasNoToken() { + let auth = makeAuth(requiresUserAuth: true) + + XCTAssertNil(auth.authorization(onesignalId: "osid-a", externalId: "user-a")) + XCTAssertEqual(jwt.askedFor, ["user-a"]) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift index bfe416b46..1b87cdf31 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift @@ -118,7 +118,7 @@ final class OneSignalUserTests: XCTestCase { OneSignalCoreImpl.setSharedClient(client) // Increase flush interval to allow all the updates to batch - OSOperationRepo.sharedInstance.pollIntervalMilliseconds = 300 + OneSignalUserManagerImpl.sharedInstance.operationRepo.pollIntervalMilliseconds = 300 // Wait to let any pending flushes in the Operation Repo to run OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.1) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/RequestPathEncodingTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/RequestPathEncodingTests.swift new file mode 100644 index 000000000..8ddc3f750 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/RequestPathEncodingTests.swift @@ -0,0 +1,125 @@ +/* + 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 +@testable import OneSignalUser + +/// Values the app chooses reach a URL path once Identity Verification addresses users by `external_id`, +/// so a path built from one has to survive characters that would otherwise change which endpoint it names. +final class RequestPathEncodingTests: XCTestCase { + private let appId = "test-app-id" + private let onesignalId = "test-onesignal-id" + private let externalId = "us er/a?b#c%d" + private let encodedExternalId = "us%20er%2Fa%3Fb%23c%25d" + + private var newRecordsState = MockNewRecordsState() + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = appId + newRecordsState = MockNewRecordsState() + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + } + + override func tearDownWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + } + + private var auth: OSRequestAuthorizing { + return OneSignalUserManagerImpl.sharedInstance.requestAuth + } + + /// A user the auth layer can address by `external_id` and sign for. + @discardableResult + private func addIdentifiedUser() -> OSIdentityModel { + let model = OSIdentityModel( + aliases: [OS_ONESIGNAL_ID: onesignalId, OS_EXTERNAL_ID: externalId], + changeNotifier: OSEventProducer() + ) + model.jwtBearerToken = "token-a" + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(model) + return model + } + + func testFetchUserPercentEncodesTheExternalId() { + let request = OSRequestFetchUser( + identityModel: addIdentifiedUser(), + aliasLabel: OS_ONESIGNAL_ID, + aliasId: onesignalId, + onNewSession: false + ) + + XCTAssertTrue(request.prepareForExecution(newRecordsState: newRecordsState, auth: auth)) + XCTAssertEqual(request.path, "apps/\(appId)/users/by/\(OS_EXTERNAL_ID)/\(encodedExternalId)") + } + + func testUpdatePropertiesPercentEncodesTheExternalId() { + let request = OSRequestUpdateProperties( + params: ["properties": ["language": "en"]], + identityModel: addIdentifiedUser(), + ownerExternalId: externalId + ) + + XCTAssertTrue(request.prepareForExecution(newRecordsState: newRecordsState, auth: auth)) + XCTAssertEqual(request.path, "apps/\(appId)/users/by/\(OS_EXTERNAL_ID)/\(encodedExternalId)") + } + + /// The label the app asks to remove is the other app-chosen value in a path. + func testRemoveAliasPercentEncodesBothTheExternalIdAndTheLabel() { + let request = OSRequestRemoveAlias( + labelToRemove: "my label/x", + identityModel: addIdentifiedUser(), + ownerExternalId: externalId + ) + + XCTAssertTrue(request.prepareForExecution(newRecordsState: newRecordsState, auth: auth)) + XCTAssertEqual( + request.path, + "apps/\(appId)/users/by/\(OS_EXTERNAL_ID)/\(encodedExternalId)/identity/my%20label%2Fx" + ) + } + + /// Server-assigned ids need no escaping, so the path an app without Identity Verification sends is byte + /// for byte what it was. + func testTheOnesignalIdPathIsUnchangedWhileIdentityVerificationIsOff() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + let request = OSRequestUpdateProperties( + params: ["properties": ["language": "en"]], + identityModel: addIdentifiedUser(), + ownerExternalId: externalId + ) + + XCTAssertTrue(request.prepareForExecution(newRecordsState: newRecordsState, auth: auth)) + XCTAssertEqual(request.path, "apps/\(appId)/users/by/\(OS_ONESIGNAL_ID)/\(onesignalId)") + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift index 2c598b6bb..88c14d1b8 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift @@ -319,7 +319,7 @@ final class SwitchUserIntegrationTests: XCTestCase { OneSignalCoreImpl.setSharedClient(client) // Increase flush interval to allow all the updates to batch - OSOperationRepo.sharedInstance.pollIntervalMilliseconds = 300 + OneSignalUserManagerImpl.sharedInstance.operationRepo.pollIntervalMilliseconds = 300 // Wait to let any pending flushes in the Operation Repo to run OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.3) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift index ac636e32e..81af4ed71 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift @@ -68,7 +68,7 @@ final class UserConcurrencyTests: XCTestCase { for _ in 1...4 { DispatchQueue.global().async { print("🧪 flushDeltaQueue on thread \(Thread.current)") - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() } } @@ -92,22 +92,22 @@ final class UserConcurrencyTests: XCTestCase { ) OneSignalCoreImpl.setSharedClient(client) - let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState()) - OSOperationRepo.sharedInstance.addExecutor(executor) + let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) + OneSignalUserManagerImpl.sharedInstance.operationRepo.addExecutor(executor) /* When */ DispatchQueue.concurrentPerform(iterations: 50) { _ in // 1. Enqueue Remove Subscription Deltas to the Operation Repo - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: "email", value: "email")) - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: "email", value: "email")) + OneSignalUserManagerImpl.sharedInstance.operationRepo.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: "email", value: "email")) + OneSignalUserManagerImpl.sharedInstance.operationRepo.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: "email", value: "email")) // 2. Flush Operation Repo - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() // 3. Simulate updating the executor's request queue from a network response - executor.executeDeleteSubscriptionRequest(OSRequestDeleteSubscription(subscriptionModel: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer())), inBackground: false) - executor.executeDeleteSubscriptionRequest(OSRequestDeleteSubscription(subscriptionModel: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer())), inBackground: false) + executor.executeDeleteSubscriptionRequest(OSRequestDeleteSubscription(subscriptionModel: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), ownerExternalId: nil), inBackground: false) + executor.executeDeleteSubscriptionRequest(OSRequestDeleteSubscription(subscriptionModel: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), ownerExternalId: nil), inBackground: false) } // 4. Run background threads @@ -131,22 +131,22 @@ final class UserConcurrencyTests: XCTestCase { OneSignalCoreImpl.setSharedClient(client) MockUserRequests.setAddAliasesResponse(with: client, aliases: aliases) - let executor = OSIdentityOperationExecutor(newRecordsState: OSNewRecordsState()) - OSOperationRepo.sharedInstance.addExecutor(executor) + let executor = OSIdentityOperationExecutor(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) + OneSignalUserManagerImpl.sharedInstance.operationRepo.addExecutor(executor) /* When */ DispatchQueue.concurrentPerform(iterations: 50) { _ in // 1. Enqueue Add Alias Deltas to the Operation Repo - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), property: "aliases", value: aliases)) - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), property: "aliases", value: aliases)) + OneSignalUserManagerImpl.sharedInstance.operationRepo.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), property: "aliases", value: aliases)) + OneSignalUserManagerImpl.sharedInstance.operationRepo.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), property: "aliases", value: aliases)) // 2. Flush Operation Repo - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() // 3. Simulate updating the executor's request queue from a network response - executor.executeAddAliasesRequest(OSRequestAddAliases(aliases: aliases, identityModel: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer())), inBackground: false) - executor.executeAddAliasesRequest(OSRequestAddAliases(aliases: aliases, identityModel: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer())), inBackground: false) + executor.executeAddAliasesRequest(OSRequestAddAliases(aliases: aliases, identityModel: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), ownerExternalId: nil), inBackground: false) + executor.executeAddAliasesRequest(OSRequestAddAliases(aliases: aliases, identityModel: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), ownerExternalId: nil), inBackground: false) } // 4. Run background threads @@ -172,21 +172,21 @@ final class UserConcurrencyTests: XCTestCase { let identityModel = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()) OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(identityModel) - let executor = OSPropertyOperationExecutor(newRecordsState: OSNewRecordsState()) - OSOperationRepo.sharedInstance.addExecutor(executor) + let executor = OSPropertyOperationExecutor(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) + OneSignalUserManagerImpl.sharedInstance.operationRepo.addExecutor(executor) /* When */ DispatchQueue.concurrentPerform(iterations: 50) { _ in // 1. Enqueue Deltas to the Operation Repo - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, externalId: identityModel.externalId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "language", value: UUID().uuidString)) - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, externalId: identityModel.externalId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "language", value: UUID().uuidString)) + OneSignalUserManagerImpl.sharedInstance.operationRepo.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, externalId: identityModel.externalId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "language", value: UUID().uuidString)) + OneSignalUserManagerImpl.sharedInstance.operationRepo.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, externalId: identityModel.externalId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "language", value: UUID().uuidString)) // 2. Flush Operation Repo - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() // 3. Simulate updating the executor's request queue from a network response - executor.executeUpdatePropertiesRequest(OSRequestUpdateProperties(params: ["properties": ["language": UUID().uuidString], "refresh_device_metadata": false], identityModel: identityModel), inBackground: false) + executor.executeUpdatePropertiesRequest(OSRequestUpdateProperties(params: ["properties": ["language": UUID().uuidString], "refresh_device_metadata": false], identityModel: identityModel, ownerExternalId: identityModel.externalId), inBackground: false) } // 4. Run background threads @@ -213,7 +213,7 @@ final class UserConcurrencyTests: XCTestCase { let identityModel1 = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()) let identityModel2 = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()) - let userExecutor = OSUserExecutor(newRecordsState: OSNewRecordsState()) + let userExecutor = OSUserExecutor(newRecordsState: OSNewRecordsState(), identityVerificationService: OneSignalUserManagerImpl.sharedInstance.identityVerificationService, auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) /* When */ diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtLifecycleTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtLifecycleTests.swift new file mode 100644 index 000000000..46e34ab01 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtLifecycleTests.swift @@ -0,0 +1,450 @@ +/* + 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 + +private class MockUserStateObserver: NSObject, OSUserStateObserver { + var states: [OSUserState] = [] + + func onUserStateDidChange(state: OSUserChangedState) { + states.append(state.current) + } +} + +/** + What `login` and `logout` do differently under Identity Verification: no anonymous user is ever sent to + the server, so login creates rather than promotes and logout has to silence the push subscription itself. + */ +final class UserJwtLifecycleTests: XCTestCase { + /// Any opted-in value; the point is that it survives unchanged, or is replaced by -2. + private let optedInNotificationTypes = 7 + + private var client = MockOneSignalClient() + private var observer = MockUserStateObserver() + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + + client = MockOneSignalClient() + MockUserRequests.setDefaultCreateAnonUserResponses(with: client) + MockUserRequests.setDefaultCreateUserResponses(with: client, externalId: userA_EUID) + MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: userA_EUID) + OneSignalCoreImpl.setSharedClient(client) + + // Held strongly for the test's lifetime: OSObservable keeps observers weakly. + observer = MockUserStateObserver() + OneSignalUserManagerImpl.sharedInstance.addObserver(observer) + } + + override func tearDownWithError() throws { + OneSignalUserManagerImpl.sharedInstance.removeObserver(observer) + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = false + OneSignalCoreMocks.clearUserDefaults() + OSFeatureManager.shared.setEnabledFeatureKeys([]) + } + + /// The push subscription as the server would see it right now. + private func pushSubscriptionPayload() -> [String: Any] { + return OneSignalUserManagerImpl.sharedInstance.user.pushSubscriptionModel.jsonRepresentation() + } + + /// Reports a token and notification types, so a silenced payload is distinguishable from the default. + @discardableResult + private func optInPushSubscription() -> OSSubscriptionModel { + let model = OneSignalUserManagerImpl.sharedInstance.user.pushSubscriptionModel + model.address = "push-token" + model.notificationTypes = optedInNotificationTypes + return model + } + + private func queuedUserRequests() -> [OSUserRequest] { + return OneSignalUserManagerImpl.sharedInstance.userExecutor?.userRequestQueue ?? [] + } + + /// The `external_id` of every queued Create User, so a test can tell the anonymous one apart. + private func queuedCreateUserExternalIds() -> [String] { + return queuedUserRequests().compactMap { ($0 as? OSRequestCreateUser)?.identityModel.externalId } + } + + /// The header the Create User went out with, so a test can tell that it was signed. + private func executedCreateUserAuthorization() -> String? { + return client.executedRequests.first { $0 is OSRequestCreateUser }?.additionalHeaders?["Authorization"] + } + + /// The Delta `logout()` produces by silencing the push subscription. + private func silencingDelta() -> OSDelta? { + return OneSignalUserManagerImpl.sharedInstance.operationRepo.deltaQueue.first { + $0.name == OS_UPDATE_SUBSCRIPTION_DELTA && $0.property == "isDisabledInternally" + } + } + + /// `start()` re-reads the cached requirement, so the setup default has to be cleared too. + private func makeRequirementUnknown() { + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_USE_IDENTITY_VERIFICATION) + OSCoreMocks.resetSharedJwtConfig() + } + + // MARK: - login + + func testLoginFromAnonymousPromotesTheUserWhileIdentityVerificationIsOff() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + _ = OneSignalUserManagerImpl.sharedInstance.user // anonymous user first + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + } + + /// Identify User adds an `external_id` to an anonymous user, which Identity Verification does not allow. + func testLoginFromAnonymousCreatesANewUserWhileIdentityVerificationIsRequired() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + _ = OneSignalUserManagerImpl.sharedInstance.user + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertFalse(client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + } + + /// Promoting before remote params answer is safe because nothing is sent while the requirement is + /// unknown, so the queued promotion can still be reshaped into the Create User auth requires. + func testLoginWhileTheRequirementIsUnknownBecomesACreateUserOnceAuthIsRequired() { + OSFeatureManager.shared.setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) + makeRequirementUnknown() + _ = OneSignalUserManagerImpl.sharedInstance.user + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertTrue(queuedUserRequests().contains { $0 is OSRequestIdentifyUser }) + XCTAssertFalse(client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + XCTAssertFalse(client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + // The login reaches the server as the Create User it should have been, signed with its own token, + // and the anonymous user it replaced is never created. + XCTAssertFalse(client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCreateUser.self, expectedCount: 1)) + XCTAssertEqual(executedCreateUserAuthorization(), "Bearer token-a") + } + + /// The same promotion when remote params answer the other way is simply sent. + func testLoginWhileTheRequirementIsUnknownIsSentOnceAuthIsKnownToBeOff() { + OSFeatureManager.shared.setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) + makeRequirementUnknown() + _ = OneSignalUserManagerImpl.sharedInstance.user + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + } + + /// With the rollout flag off, an unknown requirement has to behave exactly as it did before Identity + /// Verification existed. + func testLoginFromAnonymousPromotesTheUserWhileTheRequirementIsUnknownAndTheFlagIsOff() { + makeRequirementUnknown() + _ = OneSignalUserManagerImpl.sharedInstance.user + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertTrue(queuedUserRequests().contains { $0 is OSRequestIdentifyUser }) + XCTAssertFalse(queuedCreateUserExternalIds().contains(userA_EUID)) + } + + /// Re-logging in is how an app hands over a replacement token. + func testLoggingInAgainAsTheSameUserStoresTheNewToken() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-b") + + XCTAssertEqual(OneSignalUserManagerImpl.sharedInstance.userJwtRepo.validJwt(externalId: userA_EUID), "token-b") + } + + /// A token supplied by `login` answers the ask the same way `updateUserJwt` does, so a later rejection + /// can ask again. An ask left standing would silence the app for the rest of the session. + func testLoggingInWithATokenAnswersAPendingAskForThatUser() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + let jwtRepo = OneSignalUserManagerImpl.sharedInstance.userJwtRepo + + // The Create User parks for want of a token, which asks the app once. + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + XCTAssertFalse(jwtRepo.askForToken(externalId: userA_EUID)) + + // Log back in as the same user, which builds a new Identity Model rather than reusing the parked one. + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(jwtRepo.validJwt(externalId: userA_EUID), "token-a") + // `true` means this rejection reached the app, which only happens once the earlier ask was answered. + XCTAssertTrue(jwtRepo.invalidateJwt(externalId: userA_EUID, rejectedToken: "token-a")) + } + + // MARK: - a rejected token on the first Create User + + /** + A 401 on Create User is the likeliest one under Identity Verification, and the token that answers it + arrives through `updateUserJwt`, which resumes work by flushing. A paused Repo drops that flush, so the + app would supply a good token and see nothing happen until the next session. + */ + func testARejectedCreateUserLeavesTheRepoAbleToFlushTheReplacementToken() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + client.setMockFailureResponseForRequest( + request: "", + error: OneSignalClientError(code: 401, message: "unauthorized", responseHeaders: nil, response: nil, underlyingError: nil) + ) + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertFalse(OneSignalUserManagerImpl.sharedInstance.operationRepo.paused) + // Parked, not dropped, and the rejected token is gone so the retry cannot reuse it. + XCTAssertTrue(queuedCreateUserExternalIds().contains(userA_EUID)) + XCTAssertNil(OneSignalUserManagerImpl.sharedInstance.userJwtRepo.validJwt(externalId: userA_EUID)) + } + + /// Nothing else will send the held Create User: it is not a Delta, so the Repo flush does not reach it, + /// and the hold leaves no attempt in flight whose response would drive the queue on. + func testUpdateUserJwtSendsTheCreateUserThatARejectedTokenHeld() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + client.setMockFailureResponseForRequest( + request: "", + error: OneSignalClientError(code: 401, message: "unauthorized", responseHeaders: nil, response: nil, underlyingError: nil) + ) + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCreateUser.self, expectedCount: 1)) + + // The token the app mints in answer to the invalidated event, which the server accepts. + MockUserRequests.setDefaultCreateUserResponses(with: client, externalId: userA_EUID) + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userA_EUID, token: "token-b") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + // The same Request re-signed and accepted, so it carries the replacement token and leaves the queue. + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCreateUser.self, expectedCount: 2)) + XCTAssertEqual(executedCreateUserAuthorization(), "Bearer token-b") + XCTAssertFalse(queuedCreateUserExternalIds().contains(userA_EUID)) + } + + /// A failure the token cannot fix still stops the queue, since the user will never exist this session. + func testACreateUserThatFailsForAnotherReasonStillPausesTheRepo() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + client.setMockFailureResponseForRequest( + request: "", + error: OneSignalClientError(code: 400, message: "bad-request", responseHeaders: nil, response: nil, underlyingError: nil) + ) + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertTrue(OneSignalUserManagerImpl.sharedInstance.operationRepo.paused) + } + + // MARK: - logout + + func testLogoutUnderIdentityVerificationSilencesThePushSubscriptionAndReportsNoUser() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + optInPushSubscription() + observer.states.removeAll() + + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + let payload = pushSubscriptionPayload() + XCTAssertEqual(payload["enabled"] as? Bool, false) + XCTAssertEqual(payload["notification_types"] as? Int, -2) + // The replacement anonymous user is never created on the server, so nothing else would report it. + XCTAssertEqual(observer.states.count, 1) + XCTAssertNil(observer.states.first?.onesignalId) + XCTAssertNil(observer.states.first?.externalId) + } + + /// The silencing has to be stamped with the user being logged out so the unsubscribe is attributed to + /// them rather than the anonymous replacement. + func testLogoutStampsTheSilencedPushSubscriptionWithTheOutgoingUser() throws { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + optInPushSubscription() + // Deltas have to stay in the repo queue long enough to be inspected. + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(try XCTUnwrap(silencingDelta()).externalId, userA_EUID) + } + + /// Only the reported payload changes, so a later `login` can restore what the app asked for. + func testLogoutUnderIdentityVerificationLeavesTheAppsOptInAlone() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + let pushSubscription = optInPushSubscription() + + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertFalse(pushSubscription._isDisabled) + XCTAssertEqual(pushSubscription.notificationTypes, optedInNotificationTypes) + XCTAssertTrue(pushSubscription.optedIn) + } + + func testLogoutWhileIdentityVerificationIsOffLeavesThePushSubscriptionReporting() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + optInPushSubscription() + + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(pushSubscriptionPayload()["notification_types"] as? Int, optedInNotificationTypes) + } + + /// While the requirement is unknown, silence: the false positive is undone by hydrate-to-off, and the + /// other guess would keep delivering the logged-out user's pushes. + func testLogoutWhileIdentityVerificationIsUnknownSilencesThePushSubscription() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + optInPushSubscription() + observer.states.removeAll() + makeRequirementUnknown() + + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + let payload = pushSubscriptionPayload() + XCTAssertEqual(payload["enabled"] as? Bool, false) + XCTAssertEqual(payload["notification_types"] as? Int, -2) + XCTAssertEqual(observer.states.count, 1) + XCTAssertNil(observer.states.first?.onesignalId) + XCTAssertNil(observer.states.first?.externalId) + } + + /// The unknown-logout guess over-silences if the app does not require Identity Verification; hydrate + /// has to undo it the same way it undoes an on→off flip while logged out. + func testLogoutWhileIdentityVerificationIsUnknownThenOffRestoresThePushSubscription() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + optInPushSubscription() + makeRequirementUnknown() + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(pushSubscriptionPayload()["notification_types"] as? Int, optedInNotificationTypes) + } + + /// 404 recovery replaces a user the server no longer has; the device should keep reporting through it. + func testInternalLogoutLeavesThePushSubscriptionReportingUnderIdentityVerification() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + optInPushSubscription() + + OneSignalUserManagerImpl.sharedInstance._logout() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(pushSubscriptionPayload()["notification_types"] as? Int, optedInNotificationTypes) + } + + func testLoggingBackInRestoresThePushSubscription() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + optInPushSubscription() + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(pushSubscriptionPayload()["notification_types"] as? Int, optedInNotificationTypes) + } + + /// A device left logged out across a restart has to stay silenced, otherwise the next device-property + /// change would re-enable the logged-out user's subscription. + func testAnInternallyDisabledPushSubscriptionSurvivesArchiving() throws { + let model = OSSubscriptionModel(type: .push, address: "push-token", subscriptionId: testPushSubId, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()) + model.notificationTypes = optedInNotificationTypes + model._isDisabledInternally = true + + let data = try NSKeyedArchiver.archivedData(withRootObject: model, requiringSecureCoding: false) + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) + unarchiver.requiresSecureCoding = false + defer { unarchiver.finishDecoding() } + let decoded = try XCTUnwrap(unarchiver.decodeObject(forKey: NSKeyedArchiveRootObjectKey) as? OSSubscriptionModel) + + XCTAssertTrue(decoded._isDisabledInternally) + XCTAssertEqual(decoded.jsonRepresentation()["notification_types"] as? Int, -2) + } + + /// `login` is otherwise the only thing that clears the internal disable, which would leave an app that + /// turns Identity Verification off while logged out — or one whose logout guessed on while the + /// requirement was still unknown — silenced until the next login. + func testTurningIdentityVerificationOffRestoresAnInternallyDisabledPushSubscription() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + optInPushSubscription() + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(pushSubscriptionPayload()["notification_types"] as? Int, optedInNotificationTypes) + } +} From 3197590bb4091a6d5dc24c246653f626a200de69 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 11 Aug 2026 17:37:56 -0700 Subject: [PATCH 2/6] fix: [PR6] hold new-record IDs in purge tests under TEST delay Under TEST, OP_REPO_POST_CREATE_DELAY_SECONDS is 0, so canAccess released an ID the instant it was added and the Requests left the executor queues before removeOperationsWithoutExternalId could see them. MockNewRecordsState.holdWhilePresent keeps an ID inaccessible for as long as it is present. Purge tests opt in; every other consumer keeps the production timer behavior. Co-authored-by: Cursor --- .../OneSignalOSCoreMocks/MockNewRecordsState.swift | 14 ++++++++++++++ .../Executors/ExecutorAnonymousPurgeTests.swift | 2 ++ 2 files changed, 16 insertions(+) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/MockNewRecordsState.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/MockNewRecordsState.swift index 25a6444f7..b489cdeac 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/MockNewRecordsState.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/MockNewRecordsState.swift @@ -35,6 +35,13 @@ public class MockNewRecordsState: OSNewRecordsState { public var records: [MockNewRecord] = [] + /** + When true, an ID stays inaccessible for as long as it is present. Under TEST the post-create + delay is zero, so the production timer would otherwise release immediately and a Request would + leave the executor queue before a purge test can see it. + */ + public var holdWhilePresent = false + override public func add(_ key: String, _ overwrite: Bool = false) { let record = MockNewRecord(key: key, overwrite: overwrite) records.append(record) @@ -42,6 +49,13 @@ public class MockNewRecordsState: OSNewRecordsState { super.add(key, overwrite) } + override public func canAccess(_ key: String) -> Bool { + if holdWhilePresent { + return !contains(key) + } + return super.canAccess(key) + } + public func get(_ key: String?) -> [MockNewRecord] { return records.filter { $0.key == key } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift index b23a70d4e..62b1205e2 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift @@ -60,6 +60,8 @@ final class ExecutorAnonymousPurgeTests: XCTestCase { client.fireSuccessForAllRequests = true OneSignalCoreImpl.setSharedClient(client) newRecordsState = MockNewRecordsState() + // Presence is the hold: the production timer is a no-op under TEST. + newRecordsState.holdWhilePresent = true // The purge only ever runs because the requirement came back requiring auth. OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) From a642d1cb785c4e126fcbb68a2948939e7e3c0e98 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 11 Aug 2026 23:31:33 -0700 Subject: [PATCH 3/6] chore: [PR6] split types to clear SwiftLint length errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No behavior change — move code into extensions / a top-level OSPushSubscriptionImpl so file_length and type_body_length stay under error thresholds. Co-authored-by: Cursor --- .../OneSignal.xcodeproj/project.pbxproj | 4 + .../OSOperationRepoTestSupport.swift | 2 +- .../OSSubscriptionOperationExecutor.swift | 2 + .../Source/OSPushSubscriptionImpl.swift | 103 +++++++++++++++++ .../Source/OneSignalUserManagerImpl+Jwt.swift | 32 ++++++ .../Source/OneSignalUserManagerImpl.swift | 106 +----------------- 6 files changed, 145 insertions(+), 104 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPushSubscriptionImpl.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 06891b51d..0e4b40081 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -419,6 +419,7 @@ D465D9B81F58B242ADF14874 /* OSIdentityModelRepoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89DE5BB0EDD3964C20C5169F /* OSIdentityModelRepoTests.swift */; }; DAF9C81134248FCDB0C12E5B /* OSUserJwtInvalidatedEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */; }; DD2A89A8052E2D1912B0038B /* OSIamFetchReadyConditionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */; }; + DDE652EF2123473B808F8CCA /* OSPushSubscriptionImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = E78603AF16454A648A96DF7B /* OSPushSubscriptionImpl.swift */; }; DE16C14424D3724700670EFA /* OneSignalLifecycleObserver.m in Sources */ = {isa = PBXBuildFile; fileRef = DE16C14324D3724700670EFA /* OneSignalLifecycleObserver.m */; }; DE16C14524D3724700670EFA /* OneSignalLifecycleObserver.m in Sources */ = {isa = PBXBuildFile; fileRef = DE16C14324D3724700670EFA /* OneSignalLifecycleObserver.m */; }; DE16C14724D3727200670EFA /* OneSignalLifecycleObserver.h in Headers */ = {isa = PBXBuildFile; fileRef = DE16C14624D3727200670EFA /* OneSignalLifecycleObserver.h */; }; @@ -1882,6 +1883,7 @@ DEFB3E622BB731BD00E65DAD /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; }; DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLiveActivities.swift; sourceTree = ""; }; DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSStubLiveActivities.swift; sourceTree = ""; }; + E78603AF16454A648A96DF7B /* OSPushSubscriptionImpl.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSPushSubscriptionImpl.swift; sourceTree = ""; }; E9376A4957E9090C748BCB18 /* OSUserJwtConfigTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtConfigTests.swift; sourceTree = ""; }; F4855B81F170253FB0C1749D /* OSIdentityVerificationServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityVerificationServiceTests.swift; sourceTree = ""; }; F83E7BF2B518EA8B0B51B276 /* OSAliasPair.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSAliasPair.swift; sourceTree = ""; }; @@ -2859,6 +2861,7 @@ 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */, 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */, 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */, + E78603AF16454A648A96DF7B /* OSPushSubscriptionImpl.swift */, 9BF72AAEB5284C97B864A1A8 /* OSRequestAuth.swift */, ); path = Source; @@ -4840,6 +4843,7 @@ DAF9C81134248FCDB0C12E5B /* OSUserJwtInvalidatedEvent.swift in Sources */, FD1F1FCA05D555623DD53B54 /* OSUserJwtRepo.swift in Sources */, 257E219608960B8545199057 /* OneSignalUserManagerImpl+Jwt.swift in Sources */, + DDE652EF2123473B808F8CCA /* OSPushSubscriptionImpl.swift in Sources */, 32601EF1960CD92605D1ABF9 /* OSRequestAuth.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift index 0c6e4beac..c4f95de6f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift @@ -104,4 +104,4 @@ final class MockOperationExecutor: OSOperationExecutor { func removeOperationsWithoutExternalId() { removeOperationsWithoutExternalIdCallCount += 1 } -} \ No newline at end of file +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift index 70b0d6d75..fa91c9096 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift @@ -309,7 +309,9 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) } } +} +extension OSSubscriptionOperationExecutor { /// This method is called by `processDeltaQueue` only and does not need to be added to the dispatchQueue. private func processRequestQueue(inBackground: Bool) { let requestQueue: [OneSignalRequest] = addRequestQueue + removeRequestQueue + updateRequestQueue diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPushSubscriptionImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPushSubscriptionImpl.swift new file mode 100644 index 000000000..61e85e755 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPushSubscriptionImpl.swift @@ -0,0 +1,103 @@ +/* + Modified MIT License + + Copyright 2023 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 OneSignalCore +import OneSignalOSCore +import OneSignalNotifications + +/** + Implements the push subscription namespace. Lives on `OneSignalUserManagerImpl` so User and Push + Subscription can both expose `addObserver` without colliding on one type. + */ +@objc +public class OSPushSubscriptionImpl: NSObject, OSPushSubscription { + + let pushSubscriptionModelStore: OSModelStore + + private var _pushSubscriptionStateChangesObserver: OSObservable? + var pushSubscriptionStateChangesObserver: OSObservable { + if let observer = _pushSubscriptionStateChangesObserver { + return observer + } + let pushSubscriptionStateChangesObserver = OSObservable(change: #selector(OSPushSubscriptionObserver.onPushSubscriptionDidChange(state:))) + _pushSubscriptionStateChangesObserver = pushSubscriptionStateChangesObserver + + return pushSubscriptionStateChangesObserver + } + + init(pushSubscriptionModelStore: OSModelStore) { + self.pushSubscriptionModelStore = pushSubscriptionModelStore + } + + public func addObserver(_ observer: OSPushSubscriptionObserver) { + // Push Subscription namespace; does not require privacy consent first. + self.pushSubscriptionStateChangesObserver.addObserver(observer) + } + + public func removeObserver(_ observer: OSPushSubscriptionObserver) { + self.pushSubscriptionStateChangesObserver.removeObserver(observer) + } + + public var id: String? { + guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.id") else { + return nil + } + return pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?.subscriptionId + } + + public var token: String? { + guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.token") else { + return nil + } + return pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?.address + } + + public var optedIn: Bool { + guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.optedIn") else { + return false + } + return pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?.optedIn ?? false + } + + /** + Enable the push subscription, and prompts if needed. `optedIn` can still be `false` after `optIn()` is called if permission is not granted. + */ + public func optIn() { + guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.optIn") else { + return + } + pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?._isDisabled = false + OSNotificationsManager.requestPermission(nil, fallbackToSettings: true) + } + + public func optOut() { + guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.optOut") else { + return + } + pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?._isDisabled = true + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift index eaf4c7784..859353d90 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift @@ -33,6 +33,38 @@ import OneSignalOSCore tells it when that token stopped being accepted. */ extension OneSignalUserManagerImpl { + /** + Whether `login` may promote the current anonymous user with Identify User instead of creating a new one. + + Identify User adds an `external_id` to a user that has none, and under Identity Verification no such + user is ever sent to the server, so every login has to create its user instead. While the requirement is + unknown this still promotes: the queue is held until it is known, and `OSUserExecutor` then turns the + promotion into the Create User it should have been if the app turns out to require auth. + */ + var canPromoteAnonymousUser: Bool { + return !identityVerificationService.ivBehaviorActive + } + + /** + Stores a token for `externalId` and releases everything held for want of one, so it goes out now: + the Repo's Deltas, the User executor's own queue (which is not Repo-driven), and — over the + 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. + */ + func storeJwt(externalId: String, token: String) { + guard userJwtRepo.updateJwt(externalId: externalId, token: token) else { + return + } + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignalUserManager stored a JWT for externalId: \(externalId)") + guard identityVerificationService.newCodePathsRun else { + return + } + operationRepo.addFlushDeltaQueueToDispatchQueue() + userExecutor?.executePendingRequests() + } + /** Replays any ask that already fired this session, so a listener registered after `start` or `login` still hears who currently owes a token. diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 308bc1d14..1cf2695f3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -393,18 +393,6 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } - /** - Whether `login` may promote the current anonymous user with Identify User instead of creating a new one. - - Identify User adds an `external_id` to a user that has none, and under Identity Verification no such - user is ever sent to the server, so every login has to create its user instead. While the requirement is - unknown this still promotes: the queue is held until it is known, and `OSUserExecutor` then turns the - promotion into the Create User it should have been if the app turns out to require auth. - */ - private var canPromoteAnonymousUser: Bool { - return !identityVerificationService.ivBehaviorActive - } - /** Converting a 3.x player to a 5.x user. There is a cached legacy player, so we will create the user based on the legacy player ID. */ @@ -566,27 +554,11 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { _user = nil createUserIfNil() } +} - /** - Stores a token for `externalId` and releases everything held for want of one, so it goes out now: - the Repo's Deltas, the User executor's own queue (which is not Repo-driven), and — over the - 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. - */ - func storeJwt(externalId: String, token: String) { - guard userJwtRepo.updateJwt(externalId: externalId, token: token) else { - return - } - OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignalUserManager stored a JWT for externalId: \(externalId)") - guard identityVerificationService.newCodePathsRun else { - return - } - operationRepo.addFlushDeltaQueueToDispatchQueue() - userExecutor?.executePendingRequests() - } +// MARK: - User setup helpers +extension OneSignalUserManagerImpl { @objc public func clearAllModelsFromStores() { prepareForNewUser() @@ -771,7 +743,6 @@ extension OneSignalUserManagerImpl { operationRepo.addFlushDeltaQueueToDispatchQueue(inBackground: true) } } - extension OneSignalUserManagerImpl: OSUser { public var User: OSUser { start() @@ -974,77 +945,6 @@ extension OneSignalUserManagerImpl: OSUser { } } -extension OneSignalUserManagerImpl { - @objc - public class OSPushSubscriptionImpl: NSObject, OSPushSubscription { - - let pushSubscriptionModelStore: OSModelStore - - private var _pushSubscriptionStateChangesObserver: OSObservable? - var pushSubscriptionStateChangesObserver: OSObservable { - if let observer = _pushSubscriptionStateChangesObserver { - return observer - } - let pushSubscriptionStateChangesObserver = OSObservable(change: #selector(OSPushSubscriptionObserver.onPushSubscriptionDidChange(state:))) - _pushSubscriptionStateChangesObserver = pushSubscriptionStateChangesObserver - - return pushSubscriptionStateChangesObserver - } - - init(pushSubscriptionModelStore: OSModelStore) { - self.pushSubscriptionModelStore = pushSubscriptionModelStore - } - - public func addObserver(_ observer: OSPushSubscriptionObserver) { - // This is a method in the User namespace that doesn't require privacy consent first - self.pushSubscriptionStateChangesObserver.addObserver(observer) - } - - public func removeObserver(_ observer: OSPushSubscriptionObserver) { - self.pushSubscriptionStateChangesObserver.removeObserver(observer) - } - - public var id: String? { - guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.id") else { - return nil - } - return pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?.subscriptionId - } - - public var token: String? { - guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.token") else { - return nil - } - return pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?.address - } - - public var optedIn: Bool { - guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.optedIn") else { - return false - } - return pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?.optedIn ?? false - } - - /** - Enable the push subscription, and prompts if needed. `optedIn` can still be `false` after `optIn()` is called if permission is not granted. - */ - public func optIn() { - guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.optIn") else { - return - } - pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?._isDisabled = false - OSNotificationsManager.requestPermission(nil, fallbackToSettings: true) - } - - public func optOut() { - guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.optOut") else { - return - } - pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?._isDisabled = true - } - } -} - extension OneSignalUserManagerImpl: OneSignalNotificationsDelegate { // While we await app_id and privacy consent, these methods are a no-op // Once the UserManager is started in `init`, it calls these to set the state of the pushSubscriptionModel From ebeb006dce3fcc0894430706cec6c08f1c351c10 Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 12 Aug 2026 17:09:06 -0700 Subject: [PATCH 4/6] fix: [PR6] keep restored Identify under IV so reshape can promote it prepareForExecution is false when Identity Verification is on, which uncache treated as a drop. A cold-start Identify whose ToUpdate is still current never reached reshape, so the login had no Create User. Keep it when IV is on and let reshape convert or drop. Co-authored-by: Cursor --- .../Source/Executors/OSUserExecutor.swift | 39 +++++++++---------- .../Executors/UserExecutorTests.swift | 27 ++++++++++++- 2 files changed, 45 insertions(+), 21 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift index 18b4a1384..66c6450bb 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift @@ -150,29 +150,28 @@ class OSUserExecutor { userRequestQueue.append(req) } else if request.isKind(of: OSRequestIdentifyUser.self), let req = request as? OSRequestIdentifyUser { + let identifyInRepo = getIdentityModel(req.identityModelToIdentify.modelId) + let updateInRepo = getIdentityModel(req.identityModelToUpdate.modelId) + if let identifyInRepo { + req.identityModelToIdentify = identifyInRepo + } + if let updateInRepo { + req.identityModelToUpdate = updateInRepo + } - if let identityModelToIdentify = getIdentityModel(req.identityModelToIdentify.modelId), - let identityModelToUpdate = getIdentityModel(req.identityModelToUpdate.modelId) { - // 1. Both models exist in the repo, set it to be the Request's models - req.identityModelToIdentify = identityModelToIdentify - req.identityModelToUpdate = identityModelToUpdate - } else if let identityModelToIdentify = getIdentityModel(req.identityModelToIdentify.modelId), - getIdentityModel(req.identityModelToUpdate.modelId) == nil { - // 2. A model is in the repo, the other model does not exist - req.identityModelToIdentify = identityModelToIdentify - addIdentityModel(req.identityModelToUpdate) - } else { - // 3. Both models don't exist yet - // Drop the request if the identityModelToIdentify does not already exist AND the request is missing OSID - // Otherwise, this request will forever fail `prepareForExecution` and block pending requests such as recovery calls to `logout` or `login` - guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserExecutor.start() dropped: \(request)") - continue + // `prepareForExecution` is false under IV so `reshapeInvalidRequests` can promote + // this login; do not treat that as a permanent drop. + if auth.ivBehaviorActive || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + if identifyInRepo == nil { + addIdentityModel(req.identityModelToIdentify) } - addIdentityModel(req.identityModelToIdentify) - addIdentityModel(req.identityModelToUpdate) + if updateInRepo == nil { + addIdentityModel(req.identityModelToUpdate) + } + userRequestQueue.append(req) + } else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserExecutor.start() dropped: \(request)") } - userRequestQueue.append(req) } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift index 570986fe9..b5535a7d3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift @@ -355,7 +355,7 @@ final class UserExecutorTests: XCTestCase { } /// Identify User promotes an anonymous user, which Identity Verification does not allow. A restored one - /// belongs to a user a later `login` has already replaced, so there is no login left to carry over. + /// whose `identityModelToUpdate` is no longer current has no login left to carry over. func testRestoredIdentifyUserIsDroppedWhenIdentityVerificationIsRequired() { /* Setup */ OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) @@ -370,6 +370,31 @@ final class UserExecutorTests: XCTestCase { XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) } + /// Cold start: the anon `identityModelToIdentify` is gone from the repo, but ToUpdate is still the + /// current user, so reshape must turn the restored Identify into a Create User. + func testRestoredIdentifyUserBecomesACreateUserWhenItIsStillTheCurrentUser() { + /* Setup */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: nil) + user.identityModel.jwtBearerToken = "token-a" + cacheUserRequests([ + OSRequestIdentifyUser( + aliasLabel: OS_EXTERNAL_ID, + aliasId: userA_EUID, + identityModelToIdentify: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userA_OSID], changeNotifier: OSEventProducer()), + identityModelToUpdate: user.identityModel + ) + ]) + + /* When */ + let mocks = Mocks { MockUserRequests.setDefaultCreateUserResponses(with: $0, externalId: userA_EUID) } + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + } + /// Same restored Identify User goes out when Identity Verification is off. func testRestoredIdentifyUserIsSentWhenIdentityVerificationIsOff() { /* Setup */ From b39bcff42eaf8847ff7749d9777b5dc4506b230d Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 12 Aug 2026 17:17:53 -0700 Subject: [PATCH 5/6] fix: [PR6] default missing addsNewRecords to true on Create User decode decodeBool is false when the key is absent, so pre-upgrade Create User caches skipped newRecordsState and follow-ups could 404. A missing key cools down; recovery creates write the field explicitly. Co-authored-by: Cursor --- .../Source/Requests/OSRequestCreateUser.swift | 5 +- .../Executors/UserExecutorTests.swift | 64 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateUser.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateUser.swift index c992e6651..d039ce53a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateUser.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateUser.swift @@ -155,7 +155,10 @@ class OSRequestCreateUser: OneSignalRequest, OSUserRequest { self.identityModel = identityModel self.pushSubscriptionModel = coder.decodeObject(forKey: "pushSubscriptionModel") as? OSSubscriptionModel self.originalPushToken = coder.decodeObject(forKey: "originalPushToken") as? String - self.addsNewRecords = coder.decodeBool(forKey: "addsNewRecords") + // Safe if the key was never written: extra cool-down, not a skipped one. + self.addsNewRecords = coder.containsValue(forKey: "addsNewRecords") + ? coder.decodeBool(forKey: "addsNewRecords") + : true self.stringDescription = "" super.init() self.parameters = parameters diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift index b5535a7d3..25b0e1d14 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift @@ -474,3 +474,67 @@ final class UserExecutorTests: XCTestCase { ) } } + +/// Upgrade decode of `addsNewRecords` on a cached Create User. +final class OSRequestCreateUserArchiveTests: XCTestCase { + + func testAddsNewRecordsSurvivesAnArchiveRoundTrip() throws { + XCTAssertTrue(try archiveThenUnarchive(makeCreateWithPush()).addsNewRecords) + XCTAssertFalse(try archiveThenUnarchive(makeRecoveryCreate()).addsNewRecords) + } + + /// Omitting the key cools down, even when the body has no push subscription. + func testOmittingAddsNewRecordsDecodesAsTrue() throws { + XCTAssertTrue(try decodeOmittingAddsNewRecords(makeCreateWithPush()).addsNewRecords) + XCTAssertTrue(try decodeOmittingAddsNewRecords(makeRecoveryCreate()).addsNewRecords) + } + + private func makeCreateWithPush() -> OSRequestCreateUser { + OSRequestCreateUser( + identityModel: OSIdentityModel(aliases: [OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()), + propertiesModel: OSPropertiesModel(changeNotifier: OSEventProducer()), + pushSubscriptionModel: OSSubscriptionModel( + type: .push, + address: "", + subscriptionId: "test-subscription-id", + reachable: false, + isDisabled: false, + changeNotifier: OSEventProducer() + ), + originalPushToken: nil + ) + } + + private func makeRecoveryCreate() -> OSRequestCreateUser { + OSRequestCreateUser( + aliasLabel: OS_EXTERNAL_ID, + aliasId: userA_EUID, + identityModel: OSIdentityModel(aliases: [OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()) + ) + } + + private func archiveThenUnarchive(_ request: OSRequestCreateUser) throws -> OSRequestCreateUser { + let data = try NSKeyedArchiver.archivedData(withRootObject: request, requiringSecureCoding: false) + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) + unarchiver.requiresSecureCoding = false + defer { unarchiver.finishDecoding() } + return try XCTUnwrap(unarchiver.decodeObject(forKey: NSKeyedArchiveRootObjectKey) as? OSRequestCreateUser) + } + + /// Encodes the same fields as `OSRequestCreateUser.encode`, without `addsNewRecords`. + private func decodeOmittingAddsNewRecords(_ request: OSRequestCreateUser) throws -> OSRequestCreateUser { + let archiver = NSKeyedArchiver(requiringSecureCoding: false) + archiver.encode(request.identityModel, forKey: "identityModel") + archiver.encode(request.pushSubscriptionModel, forKey: "pushSubscriptionModel") + archiver.encode(request.originalPushToken, forKey: "originalPushToken") + archiver.encode(request.parameters, forKey: "parameters") + archiver.encode(request.method.rawValue, forKey: "method") + archiver.encode(request.timestamp, forKey: "timestamp") + archiver.finishEncoding() + + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: archiver.encodedData) + unarchiver.requiresSecureCoding = false + defer { unarchiver.finishDecoding() } + return try XCTUnwrap(OSRequestCreateUser(coder: unarchiver)) + } +} From 1a3a237f719ba9dfd6273c14ae1b6eea9f96bd26 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 13 Aug 2026 08:55:17 -0700 Subject: [PATCH 6/6] fix: [PR6] keep uncached owned requests only while Identity Verification is on Without it, an owned Request whose identity model is gone can never become sendable, so uncache must drop it instead of keeping it forever. Co-authored-by: Cursor --- .../Executors/OSCustomEventsExecutor.swift | 4 +- .../OSIdentityOperationExecutor.swift | 8 ++-- .../OSPropertyOperationExecutor.swift | 4 +- .../OSSubscriptionOperationExecutor.swift | 6 +-- .../OneSignalUser/Source/OSRequestAuth.swift | 7 ++++ .../ExecutorAnonymousPurgeTests.swift | 41 +++++++++++++++++++ 6 files changed, 59 insertions(+), 11 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift index e6c372e61..b6571ac08 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift @@ -87,8 +87,8 @@ class OSCustomEventsExecutor: OSOperationExecutor { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { // 1. The identity model exist in the repo, set it to be the Request's model request.identityModel = identityModel - } else if request.ownerExternalId != nil || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { - // 2. The Request is owned, so a token can still arrive for it, or it can be sent as is; add the model to the repo + } else if auth.keepUncachedOwned(request) || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // 2. Owned while Identity Verification is on, so a token can still arrive; or it can be sent as is OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) } else { // 3. The identitymodel do not exist AND this request cannot be sent, drop this Request diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift index 46f357854..c3e1825bc 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift @@ -76,8 +76,8 @@ class OSIdentityOperationExecutor: OSOperationExecutor { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { // 1. The model exists in the repo, so set it to be the Request's models request.identityModel = identityModel - } else if request.ownerExternalId != nil || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { - // 2. The Request is owned, so a token can still arrive for it, or it can be sent as is; add the model to the repo + } else if auth.keepUncachedOwned(request) || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // 2. Owned while Identity Verification is on, so a token can still arrive; or it can be sent as is OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) } else { // 3. The model do not exist AND this request cannot be sent, drop this Request @@ -99,8 +99,8 @@ class OSIdentityOperationExecutor: OSOperationExecutor { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { // 1. The model exists in the repo, so set it to be the Request's model request.identityModel = identityModel - } else if request.ownerExternalId != nil || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { - // 2. The Request is owned, so a token can still arrive for it, or it can be sent as is; add the model to the repo + } else if auth.keepUncachedOwned(request) || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // 2. Owned while Identity Verification is on, so a token can still arrive; or it can be sent as is OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) } else { // 3. The model does not exist AND this request cannot be sent, drop this Request diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift index 0ba3b65a4..c91eb50fe 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift @@ -107,8 +107,8 @@ class OSPropertyOperationExecutor: OSOperationExecutor { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { // 1. The identity model exist in the repo, set it to be the Request's model request.identityModel = identityModel - } else if request.ownerExternalId != nil || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { - // 2. The Request is owned, so a token can still arrive for it, or it can be sent as is; add the model to the repo + } else if auth.keepUncachedOwned(request) || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // 2. Owned while Identity Verification is on, so a token can still arrive; or it can be sent as is OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) } else { // 3. The identitymodel do not exist AND this request cannot be sent, drop this Request diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift index fa91c9096..43d3f63b9 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift @@ -93,8 +93,8 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { // a. The model exist in the repo request.identityModel = identityModel - } else if request.ownerExternalId != nil || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { - // b. The Request is owned, so a token can still arrive for it, or it can be sent as is; add the model to the repo + } else if auth.keepUncachedOwned(request) || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // b. Owned while Identity Verification is on, so a token can still arrive; or it can be sent as is OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) } else { // c. The model do not exist AND this request cannot be sent, drop this Request @@ -120,7 +120,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } else if let subscriptionModel = subscriptionModels[request.subscriptionModel.modelId] { // 2. The model exists in the dict of seen subscription models request.subscriptionModel = subscriptionModel - } else if request.ownerExternalId == nil, + } else if !auth.keepUncachedOwned(request), !request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { // 3. The model does not exist AND no token can arrive to make this sendable, drop it OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionOperationExecutor.init dropped \(request)") diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift index 42e780212..5678d9747 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift @@ -83,6 +83,13 @@ protocol OSRequestAuthorizing: AnyObject { func authorization(onesignalId: String?, externalId: String?) -> OSUserRequestAuthorization? } +extension OSRequestAuthorizing { + /// Returns `true` when `ivBehaviorActive` and the Request has an owner, so a token can still arrive for an uncached Request whose identity model is gone. + func keepUncachedOwned(_ request: OSUserRequest) -> Bool { + ivBehaviorActive && request.ownerExternalId != nil + } +} + /** How another module should address and sign one user-scoped call. diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift index 62b1205e2..defb4c1a6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift @@ -39,6 +39,9 @@ import OneSignalUserMocks Only ownership is judged: a Delta or Request carries the `external_id` of the user it was built for, and one carrying none can never be signed. The auth layer refuses to send those, so the purge is what keeps them from sitting in the queues unsendable for the rest of the session. + + Uncache also drops an owned Request whose identity model is gone, unless Identity Verification is on: + without it that Request can never become sendable. */ final class ExecutorAnonymousPurgeTests: XCTestCase { private let anonymousOSID = "test-anonymous-onesignal-id" @@ -357,4 +360,42 @@ final class ExecutorAnonymousPurgeTests: XCTestCase { private func subscriptionDelta(_ name: String, for identityModel: OSIdentityModel, subscription: OSSubscriptionModel) -> OSDelta { return delta(name, for: identityModel, model: subscription, property: "optedIn", value: true) } + + // MARK: - Uncache of owned Requests + + /// Without Identity Verification, an owned Request whose identity model is gone can never become sendable. + func testAnOwnedRequestWhoseModelIsGoneIsDroppedWhenIdentityVerificationIsOff() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + let modelId = cacheOwnedPropertyUpdateWithNoIdentityModelInRepo() + + _ = OSPropertyOperationExecutor(newRecordsState: newRecordsState, auth: auth) + + XCTAssertEqual(cachedRequestOwners(OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, of: OSRequestUpdateProperties.self), []) + XCTAssertNil(OneSignalUserManagerImpl.sharedInstance.getIdentityModel(modelId)) + } + + /// The same Request is kept: a token can still arrive for its owner. + func testAnOwnedRequestWhoseModelIsGoneIsKeptWhenIdentityVerificationIsOn() { + let modelId = cacheOwnedPropertyUpdateWithNoIdentityModelInRepo() + + _ = OSPropertyOperationExecutor(newRecordsState: newRecordsState, auth: auth) + + XCTAssertEqual(cachedRequestOwners(OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, of: OSRequestUpdateProperties.self), [userA_EUID]) + XCTAssertNotNil(OneSignalUserManagerImpl.sharedInstance.getIdentityModel(modelId)) + } + + /// No `onesignal_id`, so `prepareForExecution` fails. + private func cacheOwnedPropertyUpdateWithNoIdentityModelInRepo() -> String { + let orphan = OSIdentityModel(aliases: [OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()) + let request = OSRequestUpdateProperties( + params: ["properties": ["language": "en"]], + identityModel: orphan, + ownerExternalId: userA_EUID + ) + OneSignalUserDefaults.initShared().saveCodeableData( + forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, + withValue: [request] + ) + return orphan.modelId + } }