diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 4a68492ba..4eac096a7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -367,6 +367,7 @@ 7EB69F3B404D0AEF46EC1536 /* UserJwtLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */; }; E42087CB1AB15481E55D34FE /* UserStateReportingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA6355B75EDC9A3100B98F5 /* UserStateReportingTests.swift */; }; 9345FA2E8878EA1B0216A49A /* UserJwtAskTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3567F486AA4335CCA79D659B /* UserJwtAskTests.swift */; }; + 59611FB5A88143468425CCB3 /* CachedUserWithoutAnIdTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5249D3FD71F02D4BCAF0995B /* CachedUserWithoutAnIdTests.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 */; }; @@ -1607,6 +1608,7 @@ 5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserJwtLifecycleTests.swift; sourceTree = ""; }; FDA6355B75EDC9A3100B98F5 /* UserStateReportingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserStateReportingTests.swift; sourceTree = ""; }; 3567F486AA4335CCA79D659B /* UserJwtAskTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserJwtAskTests.swift; sourceTree = ""; }; + 5249D3FD71F02D4BCAF0995B /* CachedUserWithoutAnIdTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CachedUserWithoutAnIdTests.swift; sourceTree = ""; }; 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtConfig.swift; sourceTree = ""; }; 67ECA2928D863073B785F93F /* IamFetchIdentityVerificationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IamFetchIdentityVerificationTests.swift; sourceTree = ""; }; 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtRepo.swift; sourceTree = ""; }; @@ -2547,6 +2549,7 @@ 5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */, FDA6355B75EDC9A3100B98F5 /* UserStateReportingTests.swift */, 3567F486AA4335CCA79D659B /* UserJwtAskTests.swift */, + 5249D3FD71F02D4BCAF0995B /* CachedUserWithoutAnIdTests.swift */, ); path = OneSignalUserTests; sourceTree = ""; @@ -4745,6 +4748,7 @@ 7EB69F3B404D0AEF46EC1536 /* UserJwtLifecycleTests.swift in Sources */, E42087CB1AB15481E55D34FE /* UserStateReportingTests.swift in Sources */, 9345FA2E8878EA1B0216A49A /* UserJwtAskTests.swift in Sources */, + 59611FB5A88143468425CCB3 /* CachedUserWithoutAnIdTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h index 0c3b6cd96..3c54ee549 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h @@ -378,6 +378,17 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP #define OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY @"OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY" #define OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY @"OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY" +// A codeable value larger than this is not written, and a cached queue blob larger than it is dropped without +// decoding it. Real queues are a few KB. CFPreferences rejects a value of 4 MB or more on iOS. +#define OS_USER_DEFAULTS_MAX_VALUE_BYTES 1048576 + +// Written only by the 5.3.0-beta Identity Verification builds. Nothing reads them anymore. +#define OS_IV_BETA_USER_EXECUTOR_PENDING_QUEUE_KEY @"OS_USER_EXECUTOR_PENDING_QUEUE_KEY" +#define OS_IV_BETA_IDENTITY_EXECUTOR_PENDING_QUEUE_KEY @"OS_IDENTITY_EXECUTOR_PENDING_QUEUE_KEY" +#define OS_IV_BETA_PROPERTIES_EXECUTOR_PENDING_QUEUE_KEY @"OS_PROPERTIES_EXECUTOR_PENDING_QUEUE_KEY" +#define OS_IV_BETA_SUBSCRIPTION_EXECUTOR_PENDING_QUEUE_KEY @"OS_SUBSCRIPTION_EXECUTOR_PENDING_QUEUE_KEY" +#define OS_IV_BETA_CUSTOM_EVENTS_EXECUTOR_PENDING_QUEUE_KEY @"OS_CUSTOM_EVENTS_EXECUTOR_PENDING_QUEUE_KEY" + // Live Activies Executor #define OS_LIVE_ACTIVITIES_EXECUTOR_UPDATE_TOKENS_KEY @"OS_LIVE_ACTIVITIES_EXECUTOR_UPDATE_TOKENS_KEY" #define OS_LIVE_ACTIVITIES_EXECUTOR_START_TOKENS_KEY @"OS_LIVE_ACTIVITIES_EXECUTOR_START_TOKENS_KEY" diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.h index 89a03fe3e..ed3dc6477 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.h @@ -69,7 +69,11 @@ - (void)saveObjectForKey:(NSString * _Nonnull)key withValue:(id _Nullable)value; // NSUserDefaults for storing and getting saved codeable data (custom objects) +/// A blob that is not archived data or cannot be decoded is logged, left in place and `value` returned. - (id _Nullable)getSavedCodeableDataForKey:(NSString * _Nonnull)key defaultValue:(id _Nullable)value; +/// Same, except that a blob larger than `maxBytes`, not archived data or undecodable is removed. 0 means no limit, and nothing is removed. +- (id _Nullable)getSavedCodeableDataForKey:(NSString * _Nonnull)key defaultValue:(id _Nullable)value maxBytes:(NSUInteger)maxBytes; +/// A value whose archive is larger than `OS_USER_DEFAULTS_MAX_VALUE_BYTES` is not written and the key is removed. - (void)saveCodeableDataForKey:(NSString * _Nonnull)key withValue:(id _Nullable)value; @end diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m index fb48bacf5..2f4eaf12e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m @@ -28,6 +28,7 @@ #import #import "OneSignalUserDefaults.h" #import "OneSignalCommonDefines.h" +#import "OneSignalLog.h" @implementation OneSignalUserDefaults : NSObject @@ -152,15 +153,65 @@ - (void)saveObjectForKey:(NSString * _Nonnull)key withValue:(id _Nullable)object } - (id _Nullable)getSavedCodeableDataForKey:(NSString * _Nonnull)key defaultValue:(id _Nullable)value { - if ([self keyExists:key]) - return [NSKeyedUnarchiver unarchiveObjectWithData:[self.userDefaults objectForKey:key]]; - - return value; + return [self getSavedCodeableDataForKey:key defaultValue:value maxBytes:0]; +} + +- (id _Nullable)getSavedCodeableDataForKey:(NSString * _Nonnull)key defaultValue:(id _Nullable)value maxBytes:(NSUInteger)maxBytes { + if (![self keyExists:key]) + return value; + + id stored = [self.userDefaults objectForKey:key]; + if (![stored isKindOfClass:[NSData class]]) { + [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults cannot decode %@: not archived data", key]]; + [self removeUndecodableValueForKey:key maxBytes:maxBytes]; + return value; + } + NSData *data = stored; + if (maxBytes > 0 && data.length > maxBytes) { + [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults dropping %@: %lu bytes, limit %lu", key, (unsigned long)data.length, (unsigned long)maxBytes]]; + [self removeValueForKey:key]; + return value; + } + @try { + // A blob that decodes to nil is treated like a missing key. + return [NSKeyedUnarchiver unarchiveObjectWithData:data] ?: value; + } @catch (NSException *exception) { + [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults cannot decode %@: %@", key, exception.reason]]; + [self removeUndecodableValueForKey:key maxBytes:maxBytes]; + return value; + } +} + +/// Only a capped read, a queue of this SDK's own Requests, heals by deletion. An uncapped read leaves the blob to +/// whichever module wrote it; a model store, for one, is overwritten by the user the SDK creates in its place. +- (void)removeUndecodableValueForKey:(NSString *)key maxBytes:(NSUInteger)maxBytes { + if (maxBytes > 0) { + [self removeValueForKey:key]; + } } - (void)saveCodeableDataForKey:(NSString * _Nonnull)key withValue:(id _Nullable)value { - [self.userDefaults setObject:[NSKeyedArchiver archivedDataWithRootObject:value] forKey:key]; - [self.userDefaults synchronize]; + NSData *data; + @try { + data = [NSKeyedArchiver archivedDataWithRootObject:value]; + } @catch (NSException *exception) { + [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults could not archive %@: %@", key, exception.reason]]; + return; + } + // Nothing the SDK caches is legitimately this large: a queue that reached it is the bloat the read cap drops, + // so the previous blob goes too rather than resend the Requests it held. CFPreferences refuses a value of 4 MB + // or more and can stop persisting the suite afterwards. + if (data.length > OS_USER_DEFAULTS_MAX_VALUE_BYTES) { + [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults not caching %@: %lu bytes", key, (unsigned long)data.length]]; + [self removeValueForKey:key]; + return; + } + @try { + [self.userDefaults setObject:data forKey:key]; + [self.userDefaults synchronize]; + } @catch (NSException *exception) { + [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults could not cache %@: %@", key, exception.reason]]; + } } //gets the NSBundle of the primary application - NOT the app extension diff --git a/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests.swift b/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests.swift index 7187b5f0c..e3d387366 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests.swift @@ -73,4 +73,99 @@ final class OneSignalCoreTests: XCTestCase { XCTAssertEqual(templateId, "templateId123") XCTAssertEqual(templateName, "Template name") } + + // MARK: - Codeable data guards + + private let guardKey = "OS_CORE_TESTS_CODEABLE_GUARD" + + func testOversizedCodeableDataIsDroppedNotDecoded() { + let defaults = OneSignalUserDefaults.initShared() + // A real archive over the cap; without the cap it would decode. + defaults.saveCodeableData(forKey: guardKey, withValue: [String(repeating: "x", count: 2048)]) + + let restored = defaults.getSavedCodeableData(forKey: guardKey, defaultValue: ["default"], maxBytes: 1024) as? [String] + + XCTAssertEqual(restored, ["default"]) + XCTAssertFalse(defaults.keyExists(guardKey)) + } + + func testCodeableDataWithinTheLimitIsDecoded() { + let defaults = OneSignalUserDefaults.initShared() + defaults.saveCodeableData(forKey: guardKey, withValue: ["cached"]) + + let restored = defaults.getSavedCodeableData(forKey: guardKey, defaultValue: [], maxBytes: 1024) as? [String] + + XCTAssertEqual(restored, ["cached"]) + defaults.removeValue(forKey: guardKey) + } + + func testUnreadableCodeableDataReturnsTheDefaultWithoutThrowing() { + let defaults = OneSignalUserDefaults.initShared() + defaults.saveObject(forKey: guardKey, withValue: Data("not an archive".utf8)) + + let restored = defaults.getSavedCodeableData(forKey: guardKey, defaultValue: ["default"]) as? [String] + + XCTAssertEqual(restored, ["default"]) + defaults.removeValue(forKey: guardKey) + } + + func testUnarchivableValueLeavesTheCachedValueInPlace() { + let defaults = OneSignalUserDefaults.initShared() + defaults.saveCodeableData(forKey: guardKey, withValue: ["cached"]) + + // NSObject does not adopt NSCoding, so archiving it raises. + defaults.saveCodeableData(forKey: guardKey, withValue: NSObject()) + + XCTAssertEqual(defaults.getSavedCodeableData(forKey: guardKey, defaultValue: nil) as? [String], ["cached"]) + defaults.removeValue(forKey: guardKey) + } + + func testValueOverTheLimitIsNotCachedAndThePreviousBlobGoesWithIt() { + let defaults = OneSignalUserDefaults.initShared() + defaults.saveCodeableData(forKey: guardKey, withValue: ["cached"]) + + defaults.saveCodeableData(forKey: guardKey, withValue: Data(count: Int(OS_USER_DEFAULTS_MAX_VALUE_BYTES))) + + XCTAssertFalse(defaults.keyExists(guardKey)) + } + + func testBlobThatIsNotArchivedDataIsRemovedOnlyByACappedRead() { + let defaults = OneSignalUserDefaults.initShared() + defaults.saveObject(forKey: guardKey, withValue: "not data") + + XCTAssertEqual(defaults.getSavedCodeableData(forKey: guardKey, defaultValue: ["default"]) as? [String], ["default"]) + XCTAssertTrue(defaults.keyExists(guardKey)) + + XCTAssertEqual(defaults.getSavedCodeableData(forKey: guardKey, defaultValue: ["default"], maxBytes: 1024) as? [String], ["default"]) + XCTAssertFalse(defaults.keyExists(guardKey)) + } + + func testUndecodableBlobIsRemovedOnlyByACappedRead() throws { + let defaults = OneSignalUserDefaults.initShared() + defaults.saveObject(forKey: guardKey, withValue: try archiveNamingAnUnknownClass()) + + XCTAssertEqual(defaults.getSavedCodeableData(forKey: guardKey, defaultValue: ["default"]) as? [String], ["default"]) + XCTAssertTrue(defaults.keyExists(guardKey)) + + XCTAssertEqual(defaults.getSavedCodeableData(forKey: guardKey, defaultValue: ["default"], maxBytes: 1024) as? [String], ["default"]) + XCTAssertFalse(defaults.keyExists(guardKey)) + } + + /// A keyed archive whose root is of a class this process does not have. Decoding it raises, as a queue written + /// by a newer build does. + private func archiveNamingAnUnknownClass() throws -> Data { + let archive = try NSKeyedArchiver.archivedData(withRootObject: ["cached"], requiringSecureCoding: false) + var plist = try XCTUnwrap(try PropertyListSerialization.propertyList(from: archive, format: nil) as? [String: Any]) + var objects = try XCTUnwrap(plist["$objects"] as? [Any]) + for (index, object) in objects.enumerated() { + guard var descriptor = object as? [String: Any], descriptor["$classname"] != nil else { + continue + } + descriptor["$classname"] = "OSNoSuchClass" + descriptor["$classes"] = ["OSNoSuchClass"] + objects[index] = descriptor + } + plist["$objects"] = objects + return try PropertyListSerialization.data(fromPropertyList: plist, format: .binary, options: 0) + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift index d1b3a36e8..c1274ef84 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift @@ -78,7 +78,7 @@ public class OSOperationRepo: NSObject { } private func uncacheDeltaQueue() { - guard let cached = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] else { + guard let cached = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSDelta] else { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSOperationRepo is unable to uncache the OSDelta queue.") return } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift index b6571ac08..dc948efb7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift @@ -63,7 +63,7 @@ class OSCustomEventsExecutor: OSOperationExecutor { } private func uncacheDeltas() { - if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] { + if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSDelta] { for (index, delta) in deltaQueue.enumerated().reversed() { if OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) == nil { // The identity model does not exist, drop this Delta @@ -81,7 +81,7 @@ class OSCustomEventsExecutor: OSOperationExecutor { } private func uncacheRequests() { - if var requestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestCustomEvents] { + if var requestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestCustomEvents] { // Hook each uncached Request to the model in the store for (index, request) in requestQueue.enumerated().reversed() { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift index c3e1825bc..93a15057d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift @@ -50,7 +50,7 @@ class OSIdentityOperationExecutor: OSOperationExecutor { } private func uncacheDeltas() { - if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] { + if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSDelta] { // Hook each uncached Delta to the model in the store for (index, delta) in deltaQueue.enumerated().reversed() { if let modelInStore = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.model.modelId) { @@ -70,7 +70,7 @@ class OSIdentityOperationExecutor: OSOperationExecutor { } private func uncacheAddAliasRequests() { - if var addRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestAddAliases] { + if var addRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestAddAliases] { // Hook each uncached Request to the model in the store for (index, request) in addRequestQueue.enumerated().reversed() { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { @@ -93,7 +93,7 @@ class OSIdentityOperationExecutor: OSOperationExecutor { } private func uncacheRemoveAliasRequests() { - if var removeRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestRemoveAlias] { + if var removeRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestRemoveAlias] { // Hook each uncached Request to the model in the store for (index, request) in removeRequestQueue.enumerated().reversed() { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift index 4dbb9e25c..2189459f5 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift @@ -85,7 +85,7 @@ class OSPropertyOperationExecutor: OSOperationExecutor { } private func uncacheDeltas() { - if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] { + if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSDelta] { for (index, delta) in deltaQueue.enumerated().reversed() { if OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) == nil { // The identity model does not exist, drop this Delta @@ -101,7 +101,7 @@ class OSPropertyOperationExecutor: OSOperationExecutor { } private func uncacheUpdateRequests() { - if var updateRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestUpdateProperties] { + if var updateRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestUpdateProperties] { // Hook each uncached Request to the model in the store for (index, request) in updateRequestQueue.enumerated().reversed() { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift index aeee2c099..9b4c805f7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift @@ -53,7 +53,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } private func uncacheDeltas() { - if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] { + if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSDelta] { // Hook each uncached Delta to the model in the store for (index, delta) in deltaQueue.enumerated().reversed() { if let modelInStore = getSubscriptionModelFromStores(modelId: delta.model.modelId) { @@ -75,7 +75,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { private func uncacheCreateSubscriptionRequests() { var requestQueue: [OSRequestCreateSubscription] = [] - if let cachedAddRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestCreateSubscription] { + if let cachedAddRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestCreateSubscription] { // Hook each uncached Request to the model in the store for request in cachedAddRequestQueue { // 1. Hook up the subscription model @@ -111,7 +111,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } private func uncacheDeleteSubscriptionRequests() { - if var removeRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestDeleteSubscription] { + if var removeRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestDeleteSubscription] { // Hook each uncached Request to the model in the store for (index, request) in removeRequestQueue.enumerated().reversed() { if let subscriptionModel = getSubscriptionModelFromStores(modelId: request.subscriptionModel.modelId) { @@ -136,7 +136,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } private func uncacheUpdateSubscriptionRequests() { - if var updateRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestUpdateSubscription] { + if var updateRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestUpdateSubscription] { // Hook each uncached Request to the model in the store for (index, request) in updateRequestQueue.enumerated().reversed() { if let subscriptionModel = getSubscriptionModelFromStores(modelId: request.subscriptionModel.modelId) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift index 392fb3408..da751dd62 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift @@ -50,6 +50,7 @@ class OSUserExecutor { self.auth = auth uncacheUserRequests() migrateTransferSubscriptionRequests() + createUserIfNeverCreated(OneSignalUserManagerImpl.sharedInstance._user) identityVerificationService.addOnJwtConfigHydratedHandler(for: .userExecutor) { [weak self] _ in // Including an unchanged value: Requests held while `requirement` was unknown wait on this. @@ -167,7 +168,7 @@ class OSUserExecutor { var userRequestQueue: [OSUserRequest] = [] // Read unfinished Create User + Identify User + Get Identity By Subscription requests from cache, if any... - if let cachedRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSUserRequest] { + if let cachedRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSUserRequest] { // Hook each uncached Request to the right model reference for request in cachedRequestQueue { if request.isKind(of: OSRequestFetchIdentityBySubscription.self), let req = request as? OSRequestFetchIdentityBySubscription { @@ -244,6 +245,50 @@ class OSUserExecutor { } } + /// A cached user with no `onesignal_id` never reached the server, and only a Create User gets it there. + /// Queue one unless a queued Request already exists. Skip an anonymous user under Identity + /// Verification, where `reshapeInvalidRequests` would drop the Create User anyway. `cachedUser` is the + /// user `start()` loaded from the cache, read in `init` before Path 2 or 3 can replace it. The check runs + /// on the executor queue so it sees what `migrateTransferSubscriptionRequests` appended. + private func createUserIfNeverCreated(_ cachedUser: OSUserInternal?) { + guard let user = cachedUser else { + return + } + dispatchQueue.async { + guard user.identityModel.onesignalId == nil, + user.identityModel.externalId != nil || !self.identityVerificationService.ivBehaviorActive, + !self.queueSuppliesAnId(for: user.identityModel.modelId) + else { + return + } + OneSignalLog.onesignalLog(.LL_WARN, message: "OSUserExecutor queued a Create User for the cached user") + let request = OSRequestCreateUser( + identityModel: user.identityModel, + propertiesModel: user.propertiesModel, + pushSubscriptionModel: user.pushSubscriptionModel, + originalPushToken: user.pushSubscriptionModel.address + ) + self.userRequestQueue.append(request) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, withValue: self.userRequestQueue) + } + } + + /// Whether a queued Request gives the identity model an `onesignal_id` when it completes. + private func queueSuppliesAnId(for modelId: String) -> Bool { + return userRequestQueue.contains { request in + switch request { + case let createUser as OSRequestCreateUser: + return createUser.identityModel.modelId == modelId + case let fetchIdentity as OSRequestFetchIdentityBySubscription: + return fetchIdentity.identityModel.modelId == modelId + case let identifyUser as OSRequestIdentifyUser: + return identifyUser.identityModelToUpdate.modelId == modelId + default: + return false + } + } + } + private func getIdentityModel(_ modelId: String) -> OSIdentityModel? { return OneSignalUserManagerImpl.sharedInstance.getIdentityModel(modelId) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 652c7836e..04f0f2a86 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -238,6 +238,23 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { self.pushSubscriptionImpl = OSPushSubscriptionImpl(pushSubscriptionModelStore: pushSubscriptionModelStore) } + /// The 5.3.0-beta builds parked Requests awaiting a JWT under these keys and never trimmed them, so an + /// upgrading device can carry a large blob nothing reads anymore. + private func removeIdentityVerificationBetaCaches() { + let defaults = OneSignalUserDefaults.initShared() + let keys = [ + OS_IV_BETA_USER_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_IDENTITY_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_PROPERTIES_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_SUBSCRIPTION_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_CUSTOM_EVENTS_EXECUTOR_PENDING_QUEUE_KEY + ] + for key in keys where defaults.keyExists(key) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OneSignalUserManager removing the Identity Verification beta cache \(key)") + defaults.removeValue(forKey: key) + } + } + @objc public func start() { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { @@ -251,6 +268,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignalUserManager calling start") + removeIdentityVerificationBetaCaches() // The model stores load their in-memory `models` dict once in their initializer. // If the singleton was first touched while protected data was unavailable (iOS app diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/CachedUserWithoutAnIdTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/CachedUserWithoutAnIdTests.swift new file mode 100644 index 000000000..c64e2aa6a --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/CachedUserWithoutAnIdTests.swift @@ -0,0 +1,191 @@ +/* + 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 + +/** + A cached user with no `onesignal_id` was never created on the server. Only its Create User does that, and a + launch can lose the Request: the queue it sat in was dropped as oversized or unreadable, or a 5.3.0-beta build + parked it under a key `start()` removes. `start()` queues it again rather than leave the device unreachable. + */ +final class CachedUserWithoutAnIdTests: XCTestCase { + private var client = MockOneSignalClient() + + 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) + OneSignalCoreImpl.setSharedClient(client) + } + + override func tearDownWithError() throws { + // The mock answers 50ms late, so a Request still in flight would otherwise land mid-next-test. + OneSignalCoreMocks.waitUntil("A Request was still in flight at teardown") { self.client.isIdle } + OneSignalCoreMocks.clearUserDefaults() + } + + /// The models a launch leaves behind when it is killed before its Create User is sent, with the queue empty. + @discardableResult + private func cacheUser(externalId: String?) -> OSUserInternal { + return OneSignalUserManagerImpl.sharedInstance.setNewInternalUser(externalId: externalId, pushSubscriptionModel: nil) + } + + private func sentCreateUsers() -> [OSRequestCreateUser] { + return client.executedRequests.compactMap { $0 as? OSRequestCreateUser } + } + + private func externalId(of request: OSRequestCreateUser) -> String? { + return (request.parameters?["identity"] as? [String: String])?[OS_EXTERNAL_ID] + } + + func testStartCreatesACachedIdentifiedUserWithNoOneSignalId() { + let user = cacheUser(externalId: userA_EUID) + + OneSignalUserManagerImpl.sharedInstance.start() + + OneSignalCoreMocks.waitUntil("The cached user was not created") { + self.client.hasCompletedRequestOfType(OSRequestCreateUser.self) + } + XCTAssertEqual(sentCreateUsers().map(externalId(of:)), [userA_EUID]) + OneSignalCoreMocks.waitUntil("The response did not hydrate the cached user") { user.identityModel.onesignalId != nil } + } + + func testStartCreatesACachedAnonymousUserWithNoOneSignalId() { + cacheUser(externalId: nil) + + OneSignalUserManagerImpl.sharedInstance.start() + + OneSignalCoreMocks.waitUntil("The cached user was not created") { + self.client.hasCompletedRequestOfType(OSRequestCreateUser.self) + } + XCTAssertEqual(sentCreateUsers().count, 1) + XCTAssertNil(sentCreateUsers().first.flatMap(externalId(of:))) + } + + func testStartSendsACachedCreateUserOnceWhenItIsStillQueued() { + let user = cacheUser(externalId: userA_EUID) + let queued = OSRequestCreateUser( + identityModel: user.identityModel, + propertiesModel: user.propertiesModel, + pushSubscriptionModel: user.pushSubscriptionModel, + originalPushToken: nil + ) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, withValue: [queued]) + + OneSignalUserManagerImpl.sharedInstance.start() + + OneSignalCoreMocks.waitUntil("The cached Create User did not complete") { + self.client.hasCompletedRequestOfType(OSRequestCreateUser.self) + } + // A second one would still be queued here: it goes out only after the post-create delay. + OneSignalCoreMocks.waitUntil("A Create User is still queued") { + OneSignalUserManagerImpl.sharedInstance.userExecutor?.userRequestQueue.contains { $0 is OSRequestCreateUser } == false + } + XCTAssertEqual(sentCreateUsers().count, 1) + } + + func testStartAsksForATokenForACachedIdentifiedUserUnderIdentityVerification() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + // Held strongly for the test's lifetime: the observer keeps listeners weakly. + let listener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(listener) + defer { OneSignalUserManagerImpl.sharedInstance.removeUserJwtInvalidatedListener(listener) } + cacheUser(externalId: userA_EUID) + + OneSignalUserManagerImpl.sharedInstance.start() + + OneSignalCoreMocks.waitUntil("The app was not asked for a token") { listener.invalidatedExternalIds == [userA_EUID] } + XCTAssertTrue(sentCreateUsers().isEmpty) + + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userA_EUID, token: "a-token") + + OneSignalCoreMocks.waitUntil("The Create User did not go out with the token") { + self.client.hasCompletedRequestOfType(OSRequestCreateUser.self) + } + XCTAssertEqual(sentCreateUsers().map(externalId(of:)), [userA_EUID]) + } + + /// Path 3 creates the user after the executor exists. The recovery must not see that user. + func testFreshStartSendsOneCreateUser() { + OneSignalUserManagerImpl.sharedInstance.start() + + OneSignalCoreMocks.waitUntil("The anonymous user was not created") { + self.client.hasCompletedRequestOfType(OSRequestCreateUser.self) + } + OneSignalCoreMocks.waitUntil("A Create User is still queued") { + OneSignalUserManagerImpl.sharedInstance.userExecutor?.userRequestQueue.contains { $0 is OSRequestCreateUser } == false + } + XCTAssertEqual(sentCreateUsers().count, 1) + } + + /// Path 2 creates the user after the executor exists too, and a Create User ahead of its Fetch Identity + /// By Subscription would make a new user instead of adopting the 3.x player. + func testLegacyPlayerStartQueuesNoCreateUser() { + let legacyPlayerId = "legacy_player_id" + let legacyOnesignalId = "legacy_player_onesignal_id" + OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: legacyPlayerId) + client.setMockResponseForRequest( + request: "OSRequestFetchIdentityBySubscription with subscriptionId: \(legacyPlayerId)", + response: MockUserRequests.testIdentityPayload(onesignalId: legacyOnesignalId, externalId: nil) + ) + client.setMockResponseForRequest( + request: "", + response: MockUserRequests.testIdentityPayload(onesignalId: legacyOnesignalId, externalId: nil) + ) + + OneSignalUserManagerImpl.sharedInstance.start() + + OneSignalCoreMocks.waitUntil("The legacy player's identity was not fetched") { + self.client.hasCompletedRequestOfType(OSRequestFetchIdentityBySubscription.self) + } + XCTAssertTrue(sentCreateUsers().isEmpty) + XCTAssertFalse(OneSignalUserManagerImpl.sharedInstance.userExecutor?.userRequestQueue.contains { $0 is OSRequestCreateUser } ?? true) + XCTAssertEqual(OneSignalUserManagerImpl.sharedInstance._user?.identityModel.onesignalId, legacyOnesignalId) + } + + func testStartLeavesACachedAnonymousUserAloneUnderIdentityVerification() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + cacheUser(externalId: nil) + + OneSignalUserManagerImpl.sharedInstance.start() + + // Nothing positive to wait for: give the executor's queue a turn, then look. + Thread.sleep(forTimeInterval: 0.2) + XCTAssertTrue(sentCreateUsers().isEmpty) + XCTAssertEqual(OneSignalUserManagerImpl.sharedInstance.userExecutor?.userRequestQueue.isEmpty, true) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift index 2a4f8ebb8..42b18a85e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift @@ -605,7 +605,6 @@ final class OneSignalUserTests: XCTestCase { } } - /** `optedIn` is the only signal the public API gives an app for "will push reach this device", so a subscription the app owner turned off remotely has to report false there. Kept in its own class @@ -829,6 +828,59 @@ final class RemoteDisableOptedInTests: XCTestCase { } } +/// The 5.3.0-beta line left queues on disk that this SDK does not read. `start()` clears them and +/// refuses to decode an oversized queue it does read. +final class IdentityVerificationBetaCacheTests: XCTestCase { + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + } + + override func tearDownWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + } + + func testStartRemovesIdentityVerificationBetaCachesAndAnOversizedQueue() throws { + let client = MockOneSignalClient() + client.executeInstantaneously = true + MockUserRequests.setDefaultCreateAnonUserResponses(with: client) + OneSignalCoreImpl.setSharedClient(client) + + let defaults = OneSignalUserDefaults.initShared() + let betaKeys = [ + OS_IV_BETA_USER_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_IDENTITY_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_PROPERTIES_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_SUBSCRIPTION_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_CUSTOM_EVENTS_EXECUTOR_PENDING_QUEUE_KEY + ] + for key in betaKeys { + defaults.saveCodeableData(forKey: key, withValue: ["nan01": []]) + } + // A real archive over the cap. Without the cap it decodes to strings, which fails the cast and leaves the key as is. + // Distinct strings: the archiver stores a repeated object once, which would keep this under the cap. + let oversized = (0..<(Int(OS_USER_DEFAULTS_MAX_VALUE_BYTES) / 1024 + 64)).map { String(repeating: "x", count: 1024) + String($0) } + // Written directly: `saveCodeableData` refuses a value this large. The builds that wrote these blobs did not. + defaults.saveObject( + forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, + withValue: try NSKeyedArchiver.archivedData(withRootObject: oversized, requiringSecureCoding: false) + ) + + OneSignalUserManagerImpl.sharedInstance.start() + OneSignalCoreMocks.waitUntil("Anonymous user creation did not complete") { + client.hasCompletedRequestOfType(OSRequestCreateUser.self) + } + + for key in betaKeys { + XCTAssertFalse(defaults.keyExists(key), "\(key) survived start()") + } + // The property executor replaced the blob with its empty queue rather than decoding it. + let queue = defaults.getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, defaultValue: nil) as? [Any] + XCTAssertEqual(queue?.count, 0) + } +} + /// Records the properties a model reported as changed, split by whether the change was meant to /// reach the server. `hydrating` is true for writes that only mirror state the server already has. private class SpyPushSubscriptionObserver: NSObject, OSPushSubscriptionObserver {