Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
92c451fa33
commit
0fe7e605e2
@ -8,22 +8,30 @@ 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
|
||||
|
||||
/// Registers a key material provider for external encryption policies.
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
21
Sources/LocalData/Protocols/KeychainStoring.swift
Normal file
21
Sources/LocalData/Protocols/KeychainStoring.swift
Normal 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
|
||||
}
|
||||
@ -11,8 +11,14 @@ public actor StorageRouter: StorageProviding {
|
||||
private var registeredKeyNames: Set<String> = []
|
||||
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):
|
||||
@ -223,12 +231,24 @@ public actor StorageRouter: StorageProviding {
|
||||
guard !registeredKeyNames.isEmpty else { return }
|
||||
guard registeredKeyNames.contains(key.name) else {
|
||||
#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)
|
||||
|
||||
@ -2,53 +2,54 @@ 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 {
|
||||
@ -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,29 +79,26 @@ 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
|
||||
@ -108,10 +106,6 @@ struct EncryptionHelperTests {
|
||||
|
||||
#expect(decrypted == payload)
|
||||
}
|
||||
|
||||
private func clearMasterKey() async {
|
||||
try? await KeychainHelper.shared.deleteAll(service: masterKeyService)
|
||||
}
|
||||
}
|
||||
|
||||
private struct StaticKeyMaterialProvider: KeyMaterialProviding {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
44
Tests/LocalDataTests/Mocks/MockKeychainHelper.swift
Normal file
44
Tests/LocalDataTests/Mocks/MockKeychainHelper.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user