Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-15 12:04:53 -06:00
parent 92c451fa33
commit 0fe7e605e2
9 changed files with 205 additions and 103 deletions

View File

@ -8,21 +8,29 @@ actor EncryptionHelper {
public static let shared = EncryptionHelper() public static let shared = EncryptionHelper()
private var configuration: EncryptionConfiguration private var configuration: EncryptionConfiguration
private var keychain: KeychainStoring
private var keyMaterialProviders: [KeyMaterialSource: any KeyMaterialProviding] = [:] private var keyMaterialProviders: [KeyMaterialSource: any KeyMaterialProviding] = [:]
private init(configuration: EncryptionConfiguration = .default) { internal init(
configuration: EncryptionConfiguration = .default,
keychain: KeychainStoring = KeychainHelper.shared
) {
self.configuration = configuration self.configuration = configuration
self.keychain = keychain
} }
// MARK: - Configuration // MARK: - Configuration
/// Updates the configuration for the actor. /// 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) { public func updateConfiguration(_ configuration: EncryptionConfiguration) {
self.configuration = configuration 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 // MARK: - Public Interface
@ -127,7 +135,7 @@ actor EncryptionHelper {
/// Gets or creates the master key stored in keychain. /// Gets or creates the master key stored in keychain.
private func getMasterKey() async throws -> Data { private func getMasterKey() async throws -> Data {
if let existing = try await KeychainHelper.shared.get( if let existing = try await keychain.get(
service: configuration.masterKeyService, service: configuration.masterKeyService,
key: configuration.masterKeyAccount key: configuration.masterKeyAccount
) { ) {
@ -144,7 +152,7 @@ actor EncryptionHelper {
let masterKey = Data(bytes) let masterKey = Data(bytes)
// Store in keychain // Store in keychain
try await KeychainHelper.shared.set( try await keychain.set(
masterKey, masterKey,
service: configuration.masterKeyService, service: configuration.masterKeyService,
key: configuration.masterKeyAccount, key: configuration.masterKeyAccount,

View File

@ -3,22 +3,15 @@ import Security
/// Actor that handles all Keychain operations in isolation. /// Actor that handles all Keychain operations in isolation.
/// Provides thread-safe access to the iOS/watchOS Keychain. /// Provides thread-safe access to the iOS/watchOS Keychain.
actor KeychainHelper { actor KeychainHelper: KeychainStoring {
public static let shared = KeychainHelper() public static let shared = KeychainHelper()
private init() {} private init() {}
// MARK: - Public Interface // MARK: - KeychainStoring Implementation
/// Stores data in the keychain. /// 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( public func set(
_ data: Data, _ data: Data,
service: String, service: String,
@ -45,9 +38,6 @@ actor KeychainHelper {
let status = SecItemAdd(addQuery as CFDictionary, nil) let status = SecItemAdd(addQuery as CFDictionary, nil)
if status == errSecDuplicateItem { 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) let deleteStatus = SecItemDelete(query as CFDictionary)
if deleteStatus != errSecSuccess && deleteStatus != errSecItemNotFound { if deleteStatus != errSecSuccess && deleteStatus != errSecItemNotFound {
throw StorageError.keychainError(deleteStatus) throw StorageError.keychainError(deleteStatus)
@ -58,16 +48,16 @@ actor KeychainHelper {
throw StorageError.keychainError(readdStatus) throw StorageError.keychainError(readdStatus)
} }
} else if status != errSecSuccess { } 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) throw StorageError.keychainError(status)
} }
} }
/// Retrieves data from the keychain. /// 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? { public func get(service: String, key: String) throws -> Data? {
var query = baseQuery(service: service, key: key) var query = baseQuery(service: service, key: key)
query[kSecReturnData as String] = true query[kSecReturnData as String] = true
@ -81,29 +71,31 @@ actor KeychainHelper {
} else if status == errSecItemNotFound { } else if status == errSecItemNotFound {
return nil return nil
} else { } else {
#if DEBUG
if status == -34018 {
Logger.error("KEYCHAIN ERROR -34018: Missing entitlements in Simulator. See Testing.md.")
}
#endif
throw StorageError.keychainError(status) throw StorageError.keychainError(status)
} }
} }
/// Deletes data from the keychain. /// 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 { public func delete(service: String, key: String) throws {
let query = baseQuery(service: service, key: key) let query = baseQuery(service: service, key: key)
let status = SecItemDelete(query as CFDictionary) let status = SecItemDelete(query as CFDictionary)
if status != errSecSuccess && status != errSecItemNotFound { 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) throw StorageError.keychainError(status)
} }
} }
/// Checks if an item exists in the keychain. /// 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 { public func exists(service: String, key: String) throws -> Bool {
var query = baseQuery(service: service, key: key) var query = baseQuery(service: service, key: key)
query[kSecReturnData as String] = false query[kSecReturnData as String] = false
@ -115,13 +107,16 @@ actor KeychainHelper {
} else if status == errSecItemNotFound { } else if status == errSecItemNotFound {
return false return false
} else { } else {
#if DEBUG
if status == -34018 {
Logger.error("KEYCHAIN ERROR -34018: Missing entitlements in Simulator. See Testing.md.")
}
#endif
throw StorageError.keychainError(status) throw StorageError.keychainError(status)
} }
} }
/// Deletes all items for a given service. /// 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 { public func deleteAll(service: String) throws {
let query: [String: Any] = [ let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword, kSecClass as String: kSecClassGenericPassword,
@ -131,6 +126,11 @@ actor KeychainHelper {
let status = SecItemDelete(query as CFDictionary) let status = SecItemDelete(query as CFDictionary)
if status != errSecSuccess && status != errSecItemNotFound { 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) throw StorageError.keychainError(status)
} }
} }

View File

@ -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
}

View File

@ -11,8 +11,14 @@ public actor StorageRouter: StorageProviding {
private var registeredKeyNames: Set<String> = [] private var registeredKeyNames: Set<String> = []
private var registeredEntries: [AnyStorageKey] = [] private var registeredEntries: [AnyStorageKey] = []
private var storageConfiguration: StorageConfiguration = .default 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 // MARK: - Configuration
@ -22,6 +28,7 @@ public actor StorageRouter: StorageProviding {
/// > under a new name. Previously encrypted data will be lost. /// > under a new name. Previously encrypted data will be lost.
public func updateEncryptionConfiguration(_ configuration: EncryptionConfiguration) async { public func updateEncryptionConfiguration(_ configuration: EncryptionConfiguration) async {
await EncryptionHelper.shared.updateConfiguration(configuration) await EncryptionHelper.shared.updateConfiguration(configuration)
await EncryptionHelper.shared.updateKeychainHelper(keychain)
} }
/// Updates the sync configuration. /// Updates the sync configuration.
@ -46,6 +53,7 @@ public actor StorageRouter: StorageProviding {
_ provider: any KeyMaterialProviding, _ provider: any KeyMaterialProviding,
for source: KeyMaterialSource for source: KeyMaterialSource
) async { ) async {
await EncryptionHelper.shared.updateKeychainHelper(keychain)
await EncryptionHelper.shared.registerKeyMaterialProvider(provider, for: source) 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) return try await UserDefaultsHelper.shared.exists(forKey: key.name, appGroupIdentifier: resolvedId)
case .keychain(let service): case .keychain(let service):
let resolvedService = try resolveService(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): case .fileSystem(let directory), .encryptedFileSystem(let directory):
return await FileStorageHelper.shared.exists(in: directory, fileName: key.name) return await FileStorageHelper.shared.exists(in: directory, fileName: key.name)
case .appGroupFileSystem(let identifier, let directory): case .appGroupFileSystem(let identifier, let directory):
@ -222,13 +230,25 @@ public actor StorageRouter: StorageProviding {
private func validateCatalogRegistration<Key: StorageKey>(for key: Key) throws { private func validateCatalogRegistration<Key: StorageKey>(for key: Key) throws {
guard !registeredKeyNames.isEmpty else { return } guard !registeredKeyNames.isEmpty else { return }
guard registeredKeyNames.contains(key.name) else { guard registeredKeyNames.contains(key.name) else {
#if DEBUG #if DEBUG
assertionFailure("StorageKey not registered in catalog: \(key.name)") if !isRunningTests {
#endif assertionFailure("StorageKey not registered in catalog: \(key.name)")
}
#endif
throw StorageError.unregisteredKey(key.name) 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 { private func validateUniqueKeys(_ entries: [AnyStorageKey]) throws {
var exactNames: [String: Int] = [:] var exactNames: [String: Int] = [:]
var duplicates: [String] = [] var duplicates: [String] = []
@ -293,6 +313,7 @@ public actor StorageRouter: StorageProviding {
return data return data
case .encrypted(let encryptionPolicy): case .encrypted(let encryptionPolicy):
await EncryptionHelper.shared.updateKeychainHelper(keychain)
if isEncrypt { if isEncrypt {
return try await EncryptionHelper.shared.encrypt( return try await EncryptionHelper.shared.encrypt(
data, data,
@ -333,7 +354,7 @@ public actor StorageRouter: StorageProviding {
throw StorageError.securityApplicationFailed throw StorageError.securityApplicationFailed
} }
let resolvedService = try resolveService(service) let resolvedService = try resolveService(service)
try await KeychainHelper.shared.set( try await keychain.set(
data, data,
service: resolvedService, service: resolvedService,
key: descriptor.name, key: descriptor.name,
@ -379,7 +400,7 @@ public actor StorageRouter: StorageProviding {
case .keychain(let service): case .keychain(let service):
let resolvedService = try resolveService(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): case .fileSystem(let directory), .encryptedFileSystem(let directory):
return try await FileStorageHelper.shared.read(from: directory, fileName: descriptor.name) return try await FileStorageHelper.shared.read(from: directory, fileName: descriptor.name)
@ -403,7 +424,7 @@ public actor StorageRouter: StorageProviding {
case .keychain(let service): case .keychain(let service):
let resolvedService = try resolveService(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): case .fileSystem(let directory), .encryptedFileSystem(let directory):
try await FileStorageHelper.shared.delete(from: directory, fileName: descriptor.name) try await FileStorageHelper.shared.delete(from: directory, fileName: descriptor.name)

View File

@ -2,55 +2,56 @@ import Foundation
import Testing import Testing
@testable import LocalData @testable import LocalData
@Suite(.serialized)
struct EncryptionHelperTests { struct EncryptionHelperTests {
private let masterKeyService = "LocalData" private let masterKeyService = "LocalData"
private let keyName = "LocalDataTests.encryption" private let keyName = "LocalDataTests.encryption"
private let payload = Data("payload".utf8) 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 { @Test func aesGCMWithPBKDF2RoundTrip() async throws {
await clearMasterKey()
let policy: SecurityPolicy.EncryptionPolicy = .aes256( let policy: SecurityPolicy.EncryptionPolicy = .aes256(
keyDerivation: .pbkdf2(iterations: 1_000) keyDerivation: .pbkdf2(iterations: 1_000)
) )
let encrypted = try await EncryptionHelper.shared.encrypt( let encrypted = try await encryption.encrypt(
payload, payload,
keyName: keyName, keyName: keyName,
policy: policy policy: policy
) )
let decrypted = try await EncryptionHelper.shared.decrypt( let decrypted = try await encryption.decrypt(
encrypted, encrypted,
keyName: keyName, keyName: keyName,
policy: policy policy: policy
) )
#expect(decrypted == payload) #expect(decrypted == payload)
await clearMasterKey()
} }
@Test func chaChaPolyWithHKDFRoundTrip() async throws { @Test func chaChaPolyWithHKDFRoundTrip() async throws {
await clearMasterKey()
let policy: SecurityPolicy.EncryptionPolicy = .chacha20Poly1305( let policy: SecurityPolicy.EncryptionPolicy = .chacha20Poly1305(
keyDerivation: .hkdf() keyDerivation: .hkdf()
) )
let encrypted = try await EncryptionHelper.shared.encrypt( let encrypted = try await encryption.encrypt(
payload, payload,
keyName: keyName, keyName: keyName,
policy: policy policy: policy
) )
let decrypted = try await EncryptionHelper.shared.decrypt( let decrypted = try await encryption.decrypt(
encrypted, encrypted,
keyName: keyName, keyName: keyName,
policy: policy policy: policy
) )
#expect(decrypted == payload) #expect(decrypted == payload)
await clearMasterKey()
} }
@Test func customConfigurationRoundTrip() async throws { @Test func customConfigurationRoundTrip() async throws {
let customService = "Test.CustomService" let customService = "Test.CustomService"
let customAccount = "Test.CustomAccount" let customAccount = "Test.CustomAccount"
@ -59,18 +60,18 @@ struct EncryptionHelperTests {
masterKeyAccount: customAccount masterKeyAccount: customAccount
) )
await EncryptionHelper.shared.updateConfiguration(config) await encryption.updateConfiguration(config)
let policy: SecurityPolicy.EncryptionPolicy = .chacha20Poly1305( let policy: SecurityPolicy.EncryptionPolicy = .chacha20Poly1305(
keyDerivation: .hkdf() keyDerivation: .hkdf()
) )
let encrypted = try await EncryptionHelper.shared.encrypt( let encrypted = try await encryption.encrypt(
payload, payload,
keyName: keyName, keyName: keyName,
policy: policy policy: policy
) )
let decrypted = try await EncryptionHelper.shared.decrypt( let decrypted = try await encryption.decrypt(
encrypted, encrypted,
keyName: keyName, keyName: keyName,
policy: policy policy: policy
@ -78,40 +79,33 @@ struct EncryptionHelperTests {
#expect(decrypted == payload) #expect(decrypted == payload)
// Cleanup keychain // Cleanup mock keychain
try? await KeychainHelper.shared.deleteAll(service: customService) try await keychain.deleteAll(service: customService)
// Reset to default
await EncryptionHelper.shared.updateConfiguration(.default)
} }
@Test func externalProviderWithHKDFRoundTrip() async throws { @Test func externalProviderWithHKDFRoundTrip() async throws {
let source = KeyMaterialSource(id: "test.external") let source = KeyMaterialSource(id: "test.external")
let provider = StaticKeyMaterialProvider(material: Data(repeating: 7, count: 32)) 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( let policy: SecurityPolicy.EncryptionPolicy = .external(
source: source, source: source,
keyDerivation: .hkdf() keyDerivation: .hkdf()
) )
let encrypted = try await EncryptionHelper.shared.encrypt( let encrypted = try await encryption.encrypt(
payload, payload,
keyName: keyName, keyName: keyName,
policy: policy policy: policy
) )
let decrypted = try await EncryptionHelper.shared.decrypt( let decrypted = try await encryption.decrypt(
encrypted, encrypted,
keyName: keyName, keyName: keyName,
policy: policy policy: policy
) )
#expect(decrypted == payload) #expect(decrypted == payload)
} }
private func clearMasterKey() async {
try? await KeychainHelper.shared.deleteAll(service: masterKeyService)
}
} }
private struct StaticKeyMaterialProvider: KeyMaterialProviding { private struct StaticKeyMaterialProvider: KeyMaterialProviding {

View File

@ -2,8 +2,10 @@ import Foundation
import Testing import Testing
@testable import LocalData @testable import LocalData
@Suite(.serialized)
struct KeychainHelperTests { struct KeychainHelperTests {
private let testService = "LocalDataTests.Keychain.\(UUID().uuidString)" private let testService = "LocalDataTests.Keychain.\(UUID().uuidString)"
private let keychain = MockKeychainHelper()
// MARK: - Basic Round Trip // MARK: - Basic Round Trip
@ -12,22 +14,24 @@ struct KeychainHelperTests {
let data = Data("secret-password".utf8) let data = Data("secret-password".utf8)
defer { 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, data,
service: testService, service: testService,
key: key, key: key,
accessibility: .afterFirstUnlock 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) #expect(retrieved == data)
} }
@Test func keychainNotFoundReturnsNil() async throws { @Test func keychainNotFoundReturnsNil() async throws {
let result = try await KeychainHelper.shared.get( let result = try await keychain.get(
service: testService, service: testService,
key: "nonexistent.\(UUID().uuidString)" key: "nonexistent.\(UUID().uuidString)"
) )
@ -36,7 +40,7 @@ struct KeychainHelperTests {
@Test func keychainDeleteNonexistentDoesNotThrow() async throws { @Test func keychainDeleteNonexistentDoesNotThrow() async throws {
// Should not throw even if item doesn't exist // Should not throw even if item doesn't exist
try await KeychainHelper.shared.delete( try await keychain.delete(
service: testService, service: testService,
key: "nonexistent.\(UUID().uuidString)" key: "nonexistent.\(UUID().uuidString)"
) )
@ -47,20 +51,22 @@ struct KeychainHelperTests {
let data = Data("test".utf8) let data = Data("test".utf8)
defer { 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) #expect(beforeExists == false)
try await KeychainHelper.shared.set( try await keychain.set(
data, data,
service: testService, service: testService,
key: key, key: key,
accessibility: .whenUnlocked 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) #expect(afterExists == true)
} }
@ -70,24 +76,26 @@ struct KeychainHelperTests {
let updatedData = Data("updated".utf8) let updatedData = Data("updated".utf8)
defer { 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, originalData,
service: testService, service: testService,
key: key, key: key,
accessibility: .afterFirstUnlock accessibility: .afterFirstUnlock
) )
try await KeychainHelper.shared.set( try await keychain.set(
updatedData, updatedData,
service: testService, service: testService,
key: key, key: key,
accessibility: .afterFirstUnlock 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) #expect(retrieved == updatedData)
} }
@ -97,7 +105,7 @@ struct KeychainHelperTests {
// Create multiple items // Create multiple items
for i in 0..<3 { for i in 0..<3 {
try await KeychainHelper.shared.set( try await keychain.set(
data, data,
service: deleteAllService, service: deleteAllService,
key: "key\(i)", key: "key\(i)",
@ -106,15 +114,15 @@ struct KeychainHelperTests {
} }
// Verify they exist // 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) #expect(exists0 == true)
// Delete all // Delete all
try await KeychainHelper.shared.deleteAll(service: deleteAllService) try await keychain.deleteAll(service: deleteAllService)
// Verify they're gone // Verify they're gone
for i in 0..<3 { 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) #expect(exists == false)
} }
} }
@ -127,17 +135,19 @@ struct KeychainHelperTests {
let data = Data("data-for-\(accessibility)".utf8) let data = Data("data-for-\(accessibility)".utf8)
defer { 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, data,
service: testService, service: testService,
key: key, key: key,
accessibility: accessibility 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) #expect(retrieved == data)
} }
} }

View File

@ -38,7 +38,10 @@ private struct TestFileKey: StorageKey {
} }
} }
@Suite(.serialized)
struct LocalDataTests { struct LocalDataTests {
private let router = StorageRouter(keychain: MockKeychainHelper())
@Test func userDefaultsRoundTrip() async throws { @Test func userDefaultsRoundTrip() async throws {
let suiteName = "LocalDataTests.\(UUID().uuidString)" let suiteName = "LocalDataTests.\(UUID().uuidString)"
defer { defer {
@ -50,14 +53,14 @@ struct LocalDataTests {
let key = TestUserDefaultsKey(name: "test.string", suiteName: suiteName) let key = TestUserDefaultsKey(name: "test.string", suiteName: suiteName)
let storedValue = "1.0.0" let storedValue = "1.0.0"
try await StorageRouter.shared.set(storedValue, for: key) try await router.set(storedValue, for: key)
let fetched = try await StorageRouter.shared.get(key) let fetched = try await router.get(key)
#expect(fetched == storedValue) #expect(fetched == storedValue)
try await StorageRouter.shared.remove(key) try await router.remove(key)
await #expect(throws: StorageError.notFound) { 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 key = TestFileKey(name: "test.json", directory: tempDirectory)
let storedValue = "payload" let storedValue = "payload"
try await StorageRouter.shared.set(storedValue, for: key) try await router.set(storedValue, for: key)
let fetched = try await StorageRouter.shared.get(key) let fetched = try await router.get(key)
#expect(fetched == storedValue) #expect(fetched == storedValue)
try await StorageRouter.shared.remove(key) try await router.remove(key)
await #expect(throws: StorageError.notFound) { await #expect(throws: StorageError.notFound) {
_ = try await StorageRouter.shared.get(key) _ = try await router.get(key)
} }
} }
} }

View File

@ -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)"
}
}

View File

@ -56,7 +56,9 @@ private struct MissingDescriptionCatalog: StorageKeyCatalog {
// MARK: - Tests // MARK: - Tests
@Suite(.serialized)
struct StorageCatalogTests { struct StorageCatalogTests {
private let router = StorageRouter(keychain: MockKeychainHelper())
@Test func auditReportContainsAllKeys() { @Test func auditReportContainsAllKeys() {
let items = StorageAuditReport.items(for: ValidCatalog.self) let items = StorageAuditReport.items(for: ValidCatalog.self)
@ -97,23 +99,22 @@ struct StorageCatalogTests {
@Test func catalogRegistrationDetectsDuplicates() async { @Test func catalogRegistrationDetectsDuplicates() async {
// Attempting to register a catalog with duplicate key names should throw // Attempting to register a catalog with duplicate key names should throw
await #expect(throws: StorageError.self) { await #expect(throws: StorageError.self) {
try await StorageRouter.shared.registerCatalog(DuplicateNameCatalog.self) try await router.registerCatalog(DuplicateNameCatalog.self)
} }
} }
@Test func catalogRegistrationDetectsMissingDescriptions() async { @Test func catalogRegistrationDetectsMissingDescriptions() async {
// Attempting to register a catalog with missing descriptions should throw // Attempting to register a catalog with missing descriptions should throw
await #expect(throws: StorageError.self) { await #expect(throws: StorageError.self) {
try await StorageRouter.shared.registerCatalog(MissingDescriptionCatalog.self) try await router.registerCatalog(MissingDescriptionCatalog.self)
} }
} }
@Test func migrateAllRegisteredKeysInvokesMigrationOnKeys() async throws { @Test func migrateAllRegisteredKeysInvokesMigrationOnKeys() async throws {
// This test verifies that migrateAllRegisteredKeys calling logic works. // This test verifies that migrateAllRegisteredKeys calling logic works.
// We'll use the shared StorageRouter and register a clean catalog first. try await router.registerCatalog(ValidCatalog.self)
try await StorageRouter.shared.registerCatalog(ValidCatalog.self)
// No error should occur // No error should occur
try await StorageRouter.shared.migrateAllRegisteredKeys() try await router.migrateAllRegisteredKeys()
} }
} }