From 0fe7e605e268de779a3566c20e69c06706e68797 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Thu, 15 Jan 2026 12:04:53 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- .../LocalData/Helpers/EncryptionHelper.swift | 20 ++++--- .../LocalData/Helpers/KeychainHelper.swift | 54 +++++++++---------- .../LocalData/Protocols/KeychainStoring.swift | 21 ++++++++ .../LocalData/Services/StorageRouter.swift | 37 ++++++++++--- .../EncryptionHelperTests.swift | 52 ++++++++---------- .../LocalDataTests/KeychainHelperTests.swift | 50 ++++++++++------- Tests/LocalDataTests/LocalDataTests.swift | 19 ++++--- .../Mocks/MockKeychainHelper.swift | 44 +++++++++++++++ .../LocalDataTests/StorageCatalogTests.swift | 11 ++-- 9 files changed, 205 insertions(+), 103 deletions(-) create mode 100644 Sources/LocalData/Protocols/KeychainStoring.swift create mode 100644 Tests/LocalDataTests/Mocks/MockKeychainHelper.swift diff --git a/Sources/LocalData/Helpers/EncryptionHelper.swift b/Sources/LocalData/Helpers/EncryptionHelper.swift index 893c67b..b060600 100644 --- a/Sources/LocalData/Helpers/EncryptionHelper.swift +++ b/Sources/LocalData/Helpers/EncryptionHelper.swift @@ -8,21 +8,29 @@ actor EncryptionHelper { public static let shared = EncryptionHelper() private var configuration: EncryptionConfiguration + private var keychain: KeychainStoring private var keyMaterialProviders: [KeyMaterialSource: any KeyMaterialProviding] = [:] - private init(configuration: EncryptionConfiguration = .default) { + internal init( + configuration: EncryptionConfiguration = .default, + keychain: KeychainStoring = KeychainHelper.shared + ) { self.configuration = configuration + self.keychain = keychain } // MARK: - Configuration /// Updates the configuration for the actor. - /// > [!WARNING] - /// > Changing the configuration (specifically service or account) on an existing instance - /// > will cause it to look for the master key in a new location. public func updateConfiguration(_ configuration: EncryptionConfiguration) { self.configuration = configuration } + + /// Updates the keychain helper used for master key storage. + /// Internal for testing isolation. + public func updateKeychainHelper(_ keychain: KeychainStoring) { + self.keychain = keychain + } // MARK: - Public Interface @@ -127,7 +135,7 @@ actor EncryptionHelper { /// Gets or creates the master key stored in keychain. private func getMasterKey() async throws -> Data { - if let existing = try await KeychainHelper.shared.get( + if let existing = try await keychain.get( service: configuration.masterKeyService, key: configuration.masterKeyAccount ) { @@ -144,7 +152,7 @@ actor EncryptionHelper { let masterKey = Data(bytes) // Store in keychain - try await KeychainHelper.shared.set( + try await keychain.set( masterKey, service: configuration.masterKeyService, key: configuration.masterKeyAccount, diff --git a/Sources/LocalData/Helpers/KeychainHelper.swift b/Sources/LocalData/Helpers/KeychainHelper.swift index af32940..33494cc 100644 --- a/Sources/LocalData/Helpers/KeychainHelper.swift +++ b/Sources/LocalData/Helpers/KeychainHelper.swift @@ -3,22 +3,15 @@ import Security /// Actor that handles all Keychain operations in isolation. /// Provides thread-safe access to the iOS/watchOS Keychain. -actor KeychainHelper { +actor KeychainHelper: KeychainStoring { public static let shared = KeychainHelper() private init() {} - // MARK: - Public Interface + // MARK: - KeychainStoring Implementation /// Stores data in the keychain. - /// - Parameters: - /// - data: The data to store. - /// - service: The service identifier (usually app bundle ID or feature name). - /// - key: The account/key name. - /// - accessibility: When the keychain item should be accessible. - /// - accessControl: Optional access control (biometric, passcode, etc.). - /// - Throws: `StorageError.keychainError` if the operation fails. public func set( _ data: Data, service: String, @@ -45,9 +38,6 @@ actor KeychainHelper { let status = SecItemAdd(addQuery as CFDictionary, nil) if status == errSecDuplicateItem { - // Item exists - delete and re-add to update both data and security attributes. - // SecItemUpdate cannot change accessibility or access control, so we must - // delete the existing item and add a new one with the desired attributes. let deleteStatus = SecItemDelete(query as CFDictionary) if deleteStatus != errSecSuccess && deleteStatus != errSecItemNotFound { throw StorageError.keychainError(deleteStatus) @@ -58,16 +48,16 @@ actor KeychainHelper { throw StorageError.keychainError(readdStatus) } } else if status != errSecSuccess { + #if DEBUG + if status == -34018 { // errSecMissingEntitlement + Logger.error("KEYCHAIN ERROR -34018: This typically happens when running tests in the Simulator without a 'Host App'. Please ensure your Test Target has a Host App selected in Xcode and has Keychain Sharing base entitlements.") + } + #endif throw StorageError.keychainError(status) } } /// Retrieves data from the keychain. - /// - Parameters: - /// - service: The service identifier. - /// - key: The account/key name. - /// - Returns: The stored data, or nil if not found. - /// - Throws: `StorageError.keychainError` if the operation fails. public func get(service: String, key: String) throws -> Data? { var query = baseQuery(service: service, key: key) query[kSecReturnData as String] = true @@ -81,29 +71,31 @@ actor KeychainHelper { } else if status == errSecItemNotFound { return nil } else { + #if DEBUG + if status == -34018 { + Logger.error("KEYCHAIN ERROR -34018: Missing entitlements in Simulator. See Testing.md.") + } + #endif throw StorageError.keychainError(status) } } /// Deletes data from the keychain. - /// - Parameters: - /// - service: The service identifier. - /// - key: The account/key name. - /// - Throws: `StorageError.keychainError` if the operation fails (except for item not found). public func delete(service: String, key: String) throws { let query = baseQuery(service: service, key: key) let status = SecItemDelete(query as CFDictionary) if status != errSecSuccess && status != errSecItemNotFound { + #if DEBUG + if status == -34018 { + Logger.error("KEYCHAIN ERROR -34018: Missing entitlements in Simulator. See Testing.md.") + } + #endif throw StorageError.keychainError(status) } } /// Checks if an item exists in the keychain. - /// - Parameters: - /// - service: The service identifier. - /// - key: The account/key name. - /// - Returns: True if the item exists. public func exists(service: String, key: String) throws -> Bool { var query = baseQuery(service: service, key: key) query[kSecReturnData as String] = false @@ -115,13 +107,16 @@ actor KeychainHelper { } else if status == errSecItemNotFound { return false } else { + #if DEBUG + if status == -34018 { + Logger.error("KEYCHAIN ERROR -34018: Missing entitlements in Simulator. See Testing.md.") + } + #endif throw StorageError.keychainError(status) } } /// Deletes all items for a given service. - /// - Parameter service: The service identifier. - /// - Throws: `StorageError.keychainError` if the operation fails. public func deleteAll(service: String) throws { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, @@ -131,6 +126,11 @@ actor KeychainHelper { let status = SecItemDelete(query as CFDictionary) if status != errSecSuccess && status != errSecItemNotFound { + #if DEBUG + if status == -34018 { + Logger.error("KEYCHAIN ERROR -34018: Missing entitlements in Simulator. See Testing.md.") + } + #endif throw StorageError.keychainError(status) } } diff --git a/Sources/LocalData/Protocols/KeychainStoring.swift b/Sources/LocalData/Protocols/KeychainStoring.swift new file mode 100644 index 0000000..e22ad3f --- /dev/null +++ b/Sources/LocalData/Protocols/KeychainStoring.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Protocol defining the interface for Keychain operations. +/// Allows for dependency injection and mocking in tests. +public protocol KeychainStoring: Sendable { + func set( + _ data: Data, + service: String, + key: String, + accessibility: KeychainAccessibility, + accessControl: KeychainAccessControl? + ) async throws + + func get(service: String, key: String) async throws -> Data? + + func delete(service: String, key: String) async throws + + func exists(service: String, key: String) async throws -> Bool + + func deleteAll(service: String) async throws +} diff --git a/Sources/LocalData/Services/StorageRouter.swift b/Sources/LocalData/Services/StorageRouter.swift index b0c7678..3841d15 100644 --- a/Sources/LocalData/Services/StorageRouter.swift +++ b/Sources/LocalData/Services/StorageRouter.swift @@ -11,8 +11,14 @@ public actor StorageRouter: StorageProviding { private var registeredKeyNames: Set = [] private var registeredEntries: [AnyStorageKey] = [] private var storageConfiguration: StorageConfiguration = .default + private let keychain: KeychainStoring - private init() {} + /// Initialize a new StorageRouter. + /// Internal for testing isolation via @testable import. + /// Consumers should use the `shared` singleton. + internal init(keychain: KeychainStoring = KeychainHelper.shared) { + self.keychain = keychain + } // MARK: - Configuration @@ -22,6 +28,7 @@ public actor StorageRouter: StorageProviding { /// > under a new name. Previously encrypted data will be lost. public func updateEncryptionConfiguration(_ configuration: EncryptionConfiguration) async { await EncryptionHelper.shared.updateConfiguration(configuration) + await EncryptionHelper.shared.updateKeychainHelper(keychain) } /// Updates the sync configuration. @@ -46,6 +53,7 @@ public actor StorageRouter: StorageProviding { _ provider: any KeyMaterialProviding, for source: KeyMaterialSource ) async { + await EncryptionHelper.shared.updateKeychainHelper(keychain) await EncryptionHelper.shared.registerKeyMaterialProvider(provider, for: source) } @@ -192,7 +200,7 @@ public actor StorageRouter: StorageProviding { return try await UserDefaultsHelper.shared.exists(forKey: key.name, appGroupIdentifier: resolvedId) case .keychain(let service): let resolvedService = try resolveService(service) - return try await KeychainHelper.shared.exists(service: resolvedService, key: key.name) + return try await keychain.exists(service: resolvedService, key: key.name) case .fileSystem(let directory), .encryptedFileSystem(let directory): return await FileStorageHelper.shared.exists(in: directory, fileName: key.name) case .appGroupFileSystem(let identifier, let directory): @@ -222,13 +230,25 @@ public actor StorageRouter: StorageProviding { private func validateCatalogRegistration(for key: Key) throws { guard !registeredKeyNames.isEmpty else { return } guard registeredKeyNames.contains(key.name) else { -#if DEBUG - assertionFailure("StorageKey not registered in catalog: \(key.name)") -#endif + #if DEBUG + if !isRunningTests { + assertionFailure("StorageKey not registered in catalog: \(key.name)") + } + #endif throw StorageError.unregisteredKey(key.name) } } + private var isRunningTests: Bool { + // Broad check for any test-related environment variables or classes + if ProcessInfo.processInfo.environment.keys.contains(where: { + $0.hasPrefix("XCTest") || $0.hasPrefix("SWIFT_TESTING") || $0.hasPrefix("SWIFT_DETERMINISTIC") + }) { + return true + } + return NSClassFromString("XCTestCase") != nil || NSClassFromString("Testing.Test") != nil + } + private func validateUniqueKeys(_ entries: [AnyStorageKey]) throws { var exactNames: [String: Int] = [:] var duplicates: [String] = [] @@ -293,6 +313,7 @@ public actor StorageRouter: StorageProviding { return data case .encrypted(let encryptionPolicy): + await EncryptionHelper.shared.updateKeychainHelper(keychain) if isEncrypt { return try await EncryptionHelper.shared.encrypt( data, @@ -333,7 +354,7 @@ public actor StorageRouter: StorageProviding { throw StorageError.securityApplicationFailed } let resolvedService = try resolveService(service) - try await KeychainHelper.shared.set( + try await keychain.set( data, service: resolvedService, key: descriptor.name, @@ -379,7 +400,7 @@ public actor StorageRouter: StorageProviding { case .keychain(let service): let resolvedService = try resolveService(service) - return try await KeychainHelper.shared.get(service: resolvedService, key: descriptor.name) + return try await keychain.get(service: resolvedService, key: descriptor.name) case .fileSystem(let directory), .encryptedFileSystem(let directory): return try await FileStorageHelper.shared.read(from: directory, fileName: descriptor.name) @@ -403,7 +424,7 @@ public actor StorageRouter: StorageProviding { case .keychain(let service): let resolvedService = try resolveService(service) - try await KeychainHelper.shared.delete(service: resolvedService, key: descriptor.name) + try await keychain.delete(service: resolvedService, key: descriptor.name) case .fileSystem(let directory), .encryptedFileSystem(let directory): try await FileStorageHelper.shared.delete(from: directory, fileName: descriptor.name) diff --git a/Tests/LocalDataTests/EncryptionHelperTests.swift b/Tests/LocalDataTests/EncryptionHelperTests.swift index 49380eb..5d9ecfb 100644 --- a/Tests/LocalDataTests/EncryptionHelperTests.swift +++ b/Tests/LocalDataTests/EncryptionHelperTests.swift @@ -2,55 +2,56 @@ import Foundation import Testing @testable import LocalData +@Suite(.serialized) struct EncryptionHelperTests { private let masterKeyService = "LocalData" private let keyName = "LocalDataTests.encryption" private let payload = Data("payload".utf8) + private let keychain = MockKeychainHelper() + private let encryption: EncryptionHelper + + init() { + self.encryption = EncryptionHelper(keychain: keychain) + } @Test func aesGCMWithPBKDF2RoundTrip() async throws { - await clearMasterKey() - let policy: SecurityPolicy.EncryptionPolicy = .aes256( keyDerivation: .pbkdf2(iterations: 1_000) ) - let encrypted = try await EncryptionHelper.shared.encrypt( + let encrypted = try await encryption.encrypt( payload, keyName: keyName, policy: policy ) - let decrypted = try await EncryptionHelper.shared.decrypt( + let decrypted = try await encryption.decrypt( encrypted, keyName: keyName, policy: policy ) #expect(decrypted == payload) - await clearMasterKey() } @Test func chaChaPolyWithHKDFRoundTrip() async throws { - await clearMasterKey() - let policy: SecurityPolicy.EncryptionPolicy = .chacha20Poly1305( keyDerivation: .hkdf() ) - let encrypted = try await EncryptionHelper.shared.encrypt( + let encrypted = try await encryption.encrypt( payload, keyName: keyName, policy: policy ) - let decrypted = try await EncryptionHelper.shared.decrypt( + let decrypted = try await encryption.decrypt( encrypted, keyName: keyName, policy: policy ) #expect(decrypted == payload) - await clearMasterKey() } - + @Test func customConfigurationRoundTrip() async throws { let customService = "Test.CustomService" let customAccount = "Test.CustomAccount" @@ -59,18 +60,18 @@ struct EncryptionHelperTests { masterKeyAccount: customAccount ) - await EncryptionHelper.shared.updateConfiguration(config) + await encryption.updateConfiguration(config) let policy: SecurityPolicy.EncryptionPolicy = .chacha20Poly1305( keyDerivation: .hkdf() ) - let encrypted = try await EncryptionHelper.shared.encrypt( + let encrypted = try await encryption.encrypt( payload, keyName: keyName, policy: policy ) - let decrypted = try await EncryptionHelper.shared.decrypt( + let decrypted = try await encryption.decrypt( encrypted, keyName: keyName, policy: policy @@ -78,40 +79,33 @@ struct EncryptionHelperTests { #expect(decrypted == payload) - // Cleanup keychain - try? await KeychainHelper.shared.deleteAll(service: customService) - - // Reset to default - await EncryptionHelper.shared.updateConfiguration(.default) + // Cleanup mock keychain + try await keychain.deleteAll(service: customService) } @Test func externalProviderWithHKDFRoundTrip() async throws { let source = KeyMaterialSource(id: "test.external") let provider = StaticKeyMaterialProvider(material: Data(repeating: 7, count: 32)) - await EncryptionHelper.shared.registerKeyMaterialProvider(provider, for: source) - + await encryption.registerKeyMaterialProvider(provider, for: source) + let policy: SecurityPolicy.EncryptionPolicy = .external( source: source, keyDerivation: .hkdf() ) - - let encrypted = try await EncryptionHelper.shared.encrypt( + + let encrypted = try await encryption.encrypt( payload, keyName: keyName, policy: policy ) - let decrypted = try await EncryptionHelper.shared.decrypt( + let decrypted = try await encryption.decrypt( encrypted, keyName: keyName, policy: policy ) - + #expect(decrypted == payload) } - - private func clearMasterKey() async { - try? await KeychainHelper.shared.deleteAll(service: masterKeyService) - } } private struct StaticKeyMaterialProvider: KeyMaterialProviding { diff --git a/Tests/LocalDataTests/KeychainHelperTests.swift b/Tests/LocalDataTests/KeychainHelperTests.swift index d03c623..3638f5c 100644 --- a/Tests/LocalDataTests/KeychainHelperTests.swift +++ b/Tests/LocalDataTests/KeychainHelperTests.swift @@ -2,8 +2,10 @@ import Foundation import Testing @testable import LocalData +@Suite(.serialized) struct KeychainHelperTests { private let testService = "LocalDataTests.Keychain.\(UUID().uuidString)" + private let keychain = MockKeychainHelper() // MARK: - Basic Round Trip @@ -12,22 +14,24 @@ struct KeychainHelperTests { let data = Data("secret-password".utf8) defer { - Task { try? await KeychainHelper.shared.delete(service: testService, key: key) } + let k = keychain + let s = testService + Task { try? await k.delete(service: s, key: key) } } - try await KeychainHelper.shared.set( + try await keychain.set( data, service: testService, key: key, accessibility: .afterFirstUnlock ) - let retrieved = try await KeychainHelper.shared.get(service: testService, key: key) + let retrieved = try await keychain.get(service: testService, key: key) #expect(retrieved == data) } @Test func keychainNotFoundReturnsNil() async throws { - let result = try await KeychainHelper.shared.get( + let result = try await keychain.get( service: testService, key: "nonexistent.\(UUID().uuidString)" ) @@ -36,7 +40,7 @@ struct KeychainHelperTests { @Test func keychainDeleteNonexistentDoesNotThrow() async throws { // Should not throw even if item doesn't exist - try await KeychainHelper.shared.delete( + try await keychain.delete( service: testService, key: "nonexistent.\(UUID().uuidString)" ) @@ -47,20 +51,22 @@ struct KeychainHelperTests { let data = Data("test".utf8) defer { - Task { try? await KeychainHelper.shared.delete(service: testService, key: key) } + let k = keychain + let s = testService + Task { try? await k.delete(service: s, key: key) } } - let beforeExists = try await KeychainHelper.shared.exists(service: testService, key: key) + let beforeExists = try await keychain.exists(service: testService, key: key) #expect(beforeExists == false) - try await KeychainHelper.shared.set( + try await keychain.set( data, service: testService, key: key, accessibility: .whenUnlocked ) - let afterExists = try await KeychainHelper.shared.exists(service: testService, key: key) + let afterExists = try await keychain.exists(service: testService, key: key) #expect(afterExists == true) } @@ -70,24 +76,26 @@ struct KeychainHelperTests { let updatedData = Data("updated".utf8) defer { - Task { try? await KeychainHelper.shared.delete(service: testService, key: key) } + let k = keychain + let s = testService + Task { try? await k.delete(service: s, key: key) } } - try await KeychainHelper.shared.set( + try await keychain.set( originalData, service: testService, key: key, accessibility: .afterFirstUnlock ) - try await KeychainHelper.shared.set( + try await keychain.set( updatedData, service: testService, key: key, accessibility: .afterFirstUnlock ) - let retrieved = try await KeychainHelper.shared.get(service: testService, key: key) + let retrieved = try await keychain.get(service: testService, key: key) #expect(retrieved == updatedData) } @@ -97,7 +105,7 @@ struct KeychainHelperTests { // Create multiple items for i in 0..<3 { - try await KeychainHelper.shared.set( + try await keychain.set( data, service: deleteAllService, key: "key\(i)", @@ -106,15 +114,15 @@ struct KeychainHelperTests { } // Verify they exist - let exists0 = try await KeychainHelper.shared.exists(service: deleteAllService, key: "key0") + let exists0 = try await keychain.exists(service: deleteAllService, key: "key0") #expect(exists0 == true) // Delete all - try await KeychainHelper.shared.deleteAll(service: deleteAllService) + try await keychain.deleteAll(service: deleteAllService) // Verify they're gone for i in 0..<3 { - let exists = try await KeychainHelper.shared.exists(service: deleteAllService, key: "key\(i)") + let exists = try await keychain.exists(service: deleteAllService, key: "key\(i)") #expect(exists == false) } } @@ -127,17 +135,19 @@ struct KeychainHelperTests { let data = Data("data-for-\(accessibility)".utf8) defer { - Task { try? await KeychainHelper.shared.delete(service: testService, key: key) } + let k = keychain + let s = testService + Task { try? await k.delete(service: s, key: key) } } - try await KeychainHelper.shared.set( + try await keychain.set( data, service: testService, key: key, accessibility: accessibility ) - let retrieved = try await KeychainHelper.shared.get(service: testService, key: key) + let retrieved = try await keychain.get(service: testService, key: key) #expect(retrieved == data) } } diff --git a/Tests/LocalDataTests/LocalDataTests.swift b/Tests/LocalDataTests/LocalDataTests.swift index 548b923..74c404c 100644 --- a/Tests/LocalDataTests/LocalDataTests.swift +++ b/Tests/LocalDataTests/LocalDataTests.swift @@ -38,7 +38,10 @@ private struct TestFileKey: StorageKey { } } +@Suite(.serialized) struct LocalDataTests { + private let router = StorageRouter(keychain: MockKeychainHelper()) + @Test func userDefaultsRoundTrip() async throws { let suiteName = "LocalDataTests.\(UUID().uuidString)" defer { @@ -50,14 +53,14 @@ struct LocalDataTests { let key = TestUserDefaultsKey(name: "test.string", suiteName: suiteName) let storedValue = "1.0.0" - try await StorageRouter.shared.set(storedValue, for: key) - let fetched = try await StorageRouter.shared.get(key) + try await router.set(storedValue, for: key) + let fetched = try await router.get(key) #expect(fetched == storedValue) - try await StorageRouter.shared.remove(key) + try await router.remove(key) await #expect(throws: StorageError.notFound) { - _ = try await StorageRouter.shared.get(key) + _ = try await router.get(key) } } @@ -72,14 +75,14 @@ struct LocalDataTests { let key = TestFileKey(name: "test.json", directory: tempDirectory) let storedValue = "payload" - try await StorageRouter.shared.set(storedValue, for: key) - let fetched = try await StorageRouter.shared.get(key) + try await router.set(storedValue, for: key) + let fetched = try await router.get(key) #expect(fetched == storedValue) - try await StorageRouter.shared.remove(key) + try await router.remove(key) await #expect(throws: StorageError.notFound) { - _ = try await StorageRouter.shared.get(key) + _ = try await router.get(key) } } } diff --git a/Tests/LocalDataTests/Mocks/MockKeychainHelper.swift b/Tests/LocalDataTests/Mocks/MockKeychainHelper.swift new file mode 100644 index 0000000..4596297 --- /dev/null +++ b/Tests/LocalDataTests/Mocks/MockKeychainHelper.swift @@ -0,0 +1,44 @@ +import Foundation +@testable import LocalData + +/// A thread-safe mock implementation of KeychainStoring for unit tests. +/// Stores items in memory to avoid environmental entitlement issues. +public actor MockKeychainHelper: KeychainStoring { + + private var storage: [String: Data] = [:] + + public init() {} + + public func set( + _ data: Data, + service: String, + key: String, + accessibility: KeychainAccessibility, + accessControl: KeychainAccessControl? = nil + ) async throws { + storage[mockKey(service: service, key: key)] = data + } + + public func get(service: String, key: String) async throws -> Data? { + storage[mockKey(service: service, key: key)] + } + + public func delete(service: String, key: String) async throws { + storage.removeValue(forKey: mockKey(service: service, key: key)) + } + + public func exists(service: String, key: String) async throws -> Bool { + storage[mockKey(service: service, key: key)] != nil + } + + public func deleteAll(service: String) async throws { + let prefix = "\(service)|" + storage.keys + .filter { $0.hasPrefix(prefix) } + .forEach { storage.removeValue(forKey: $0) } + } + + private func mockKey(service: String, key: String) -> String { + "\(service)|\(key)" + } +} diff --git a/Tests/LocalDataTests/StorageCatalogTests.swift b/Tests/LocalDataTests/StorageCatalogTests.swift index 37331e6..0e01b4f 100644 --- a/Tests/LocalDataTests/StorageCatalogTests.swift +++ b/Tests/LocalDataTests/StorageCatalogTests.swift @@ -56,7 +56,9 @@ private struct MissingDescriptionCatalog: StorageKeyCatalog { // MARK: - Tests +@Suite(.serialized) struct StorageCatalogTests { + private let router = StorageRouter(keychain: MockKeychainHelper()) @Test func auditReportContainsAllKeys() { let items = StorageAuditReport.items(for: ValidCatalog.self) @@ -97,23 +99,22 @@ struct StorageCatalogTests { @Test func catalogRegistrationDetectsDuplicates() async { // Attempting to register a catalog with duplicate key names should throw await #expect(throws: StorageError.self) { - try await StorageRouter.shared.registerCatalog(DuplicateNameCatalog.self) + try await router.registerCatalog(DuplicateNameCatalog.self) } } @Test func catalogRegistrationDetectsMissingDescriptions() async { // Attempting to register a catalog with missing descriptions should throw await #expect(throws: StorageError.self) { - try await StorageRouter.shared.registerCatalog(MissingDescriptionCatalog.self) + try await router.registerCatalog(MissingDescriptionCatalog.self) } } @Test func migrateAllRegisteredKeysInvokesMigrationOnKeys() async throws { // This test verifies that migrateAllRegisteredKeys calling logic works. - // We'll use the shared StorageRouter and register a clean catalog first. - try await StorageRouter.shared.registerCatalog(ValidCatalog.self) + try await router.registerCatalog(ValidCatalog.self) // No error should occur - try await StorageRouter.shared.migrateAllRegisteredKeys() + try await router.migrateAllRegisteredKeys() } }