diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 8bea2dbee..0e4b40081 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,9 +415,11 @@ 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 */; }; + 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 */; }; @@ -1344,6 +1354,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 +1362,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 +1557,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 +1570,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 +1647,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 +1672,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 = ""; }; @@ -1867,9 +1883,13 @@ 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 = ""; }; + 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 +2331,7 @@ DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */, A843B922174496E99F2D00A8 /* Jwt */, C7DA9EEED644F1160CAD9A38 /* OSFeatureManager.swift */, + 8596A3E728FB90691E43BCA0 /* OSUrlPath.swift */, ); path = Source; sourceTree = ""; @@ -2439,6 +2460,9 @@ B5809B47EB4F2478099697CE /* OSUserJwtRepoTests.swift */, 80DC5517E6EB5B26CF980CC5 /* UserJwtApiTests.swift */, 3881024646E7F0DE05158442 /* DeltaOwnershipTests.swift */, + 047D8F5E1095A20C9C54FD33 /* OSRequestAuthTests.swift */, + 3016921C1F6B7B7793F67567 /* RequestPathEncodingTests.swift */, + 5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */, ); path = OneSignalUserTests; sourceTree = ""; @@ -2457,6 +2481,7 @@ 3CF11E3C2C6D6155002856F5 /* UserExecutorTests.swift */, 3CA93BC3300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift */, 3CB331692F281692000E1801 /* OSCustomEventsExecutorTests.swift */, + FE740F9E6B87D215510B5982 /* ExecutorAnonymousPurgeTests.swift */, ); path = Executors; sourceTree = ""; @@ -2618,6 +2643,8 @@ 4795885CE6CFFB1998AC7D09 /* Feature */, 5B47CE0CE255AC7128442FFF /* Jwt */, 557653D2007BFF86EA8342E4 /* OSDeltaTests.swift */, + FFBB451F692A15639A1A08AE /* OSOperationRepoIdentityVerificationTests.swift */, + 5221EEDBA5A74BD565490D52 /* OSOperationRepoTestSupport.swift */, ); path = OneSignalOSCoreTests; sourceTree = ""; @@ -2788,6 +2815,7 @@ children = ( 1C4EAEA1BC62D8FC57927511 /* OSIdentityVerificationService.swift */, 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */, + F83E7BF2B518EA8B0B51B276 /* OSAliasPair.swift */, ); name = Jwt; path = Jwt; @@ -2833,6 +2861,8 @@ 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */, 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */, 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */, + E78603AF16454A648A96DF7B /* OSPushSubscriptionImpl.swift */, + 9BF72AAEB5284C97B864A1A8 /* OSRequestAuth.swift */, ); path = Source; sourceTree = ""; @@ -4530,6 +4560,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 +4634,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 +4717,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 +4843,8 @@ 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/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/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/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..c4f95de6f --- /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 + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift index 7feb01a05..b6571ac08 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 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 @@ -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..c3e1825bc 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 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 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 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 @@ -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..c91eb50fe 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 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 @@ -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..43d3f63b9 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 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 @@ -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 !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)") 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,13 +298,20 @@ 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) } } +} +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 @@ -275,7 +340,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 +403,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 +421,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 +447,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 +472,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 +489,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..66c6450bb 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] = [] @@ -79,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) 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) } } } @@ -164,33 +234,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 +313,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 +342,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 +376,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 +413,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 +466,7 @@ extension OSUserExecutor { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { executePendingRequests(withDelay: true) return } @@ -437,7 +545,7 @@ extension OSUserExecutor { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { executePendingRequests(withDelay: true) return } @@ -489,6 +597,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/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/OSRequestAuth.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift new file mode 100644 index 000000000..5678d9747 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift @@ -0,0 +1,256 @@ +/* + 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? +} + +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. + + `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+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 f58e983b3..1cf2695f3 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) @@ -403,6 +428,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 +445,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 +459,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 +471,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 +523,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() { @@ -494,20 +554,11 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { _user = nil createUserIfNil() } +} - /** - Stores a token for `externalId`, so that the pending ask for this user is cleared and a later - rejection can ask again. - - Every app-supplied token arrives here, from `login` as well as `updateUserJwt`. - */ - 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)") - } +// MARK: - User setup helpers +extension OneSignalUserManagerImpl { @objc public func clearAllModelsFromStores() { prepareForNewUser() @@ -618,7 +669,6 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } updatePropertiesDeltas(property: .purchases, value: purchases) } - } // MARK: - Sessions @@ -633,7 +683,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 +722,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,10 +740,9 @@ extension OneSignalUserManagerImpl { */ @objc public func runBackgroundTasks() { - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue(inBackground: true) + operationRepo.addFlushDeltaQueueToDispatchQueue(inBackground: true) } } - extension OneSignalUserManagerImpl: OSUser { public var User: OSUser { start() @@ -892,78 +941,7 @@ extension OneSignalUserManagerImpl: OSUser { property: name, value: processedProperties ) - OSOperationRepo.sharedInstance.enqueueDelta(delta) - } -} - -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 - } + 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..d039ce53a 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,10 @@ class OSRequestCreateUser: OneSignalRequest, OSUserRequest { self.identityModel = identityModel self.pushSubscriptionModel = coder.decodeObject(forKey: "pushSubscriptionModel") as? OSSubscriptionModel self.originalPushToken = coder.decodeObject(forKey: "originalPushToken") as? String + // 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/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..defb4c1a6 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift @@ -0,0 +1,401 @@ +/* + 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. + + 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" + 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() + // 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) + 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) + } + + // 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 + } +} 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..25b0e1d14 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,240 @@ 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 + /// whose `identityModelToUpdate` is no longer current has 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)) + } + + /// 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 */ + 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 + ) + } +} + +/// 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)) + } } 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) + } +}