Compare commits
No commits in common. "ef882bb6b2c586fa217f827773ac2a81fc79082c" and "b911aa9a4fe04c43551f026b2b0169bdcd6d27fa" have entirely different histories.
ef882bb6b2
...
b911aa9a4f
@ -1,38 +0,0 @@
|
|||||||
# LocalData Architecture and Design
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
`LocalData` is a typed, discoverable namespace for persisted application data. It provides a consistent API for reading, writing, and removing data across multiple storage domains while enforcing security and serialization policies.
|
|
||||||
|
|
||||||
## Key Components
|
|
||||||
|
|
||||||
### StorageRouter
|
|
||||||
The central `actor` that coordinates all storage operations. It acts as the primary API surface and handles routing, catalog validation, and migration.
|
|
||||||
|
|
||||||
### StorageKey
|
|
||||||
A protocol that defines the metadata for a single piece of persistent data.
|
|
||||||
- **Value**: The type of the data (Codable).
|
|
||||||
- **Domain**: Where the data is stored (UserDefaults, Keychain, FileSystem, etc.).
|
|
||||||
- **Security**: How the data is secured (None, Keychain-native, or custom Encryption).
|
|
||||||
- **Serializer**: How the data is encoded to/from `Data` (JSON, Plist, etc.).
|
|
||||||
- **SyncPolicy**: Rules for syncing data between iPhone and Watch.
|
|
||||||
|
|
||||||
### Helper Actors
|
|
||||||
Specialized actors for each storage domain:
|
|
||||||
- `KeychainHelper`: Manages Keychain operations.
|
|
||||||
- `UserDefaultsHelper`: Manages UserDefaults and App Group defaults.
|
|
||||||
- `FileStorageHelper`: Manages local and App Group file storage.
|
|
||||||
- `EncryptionHelper`: Provides AES and ChaCha20 encryption.
|
|
||||||
- `SyncHelper`: Manages WatchConnectivity synchronization.
|
|
||||||
|
|
||||||
## Routing Logic
|
|
||||||
1. **Validation**: Check if the key is registered in the catalog (if registered) and if it's available on the current platform.
|
|
||||||
2. **Serialization**: Convert the value to `Data` using the specified serializer.
|
|
||||||
3. **Security (Apply)**: Apply encryption or security policies.
|
|
||||||
4. **Storage**: Delegate the write operation to the appropriate helper.
|
|
||||||
5. **Sync**: Trigger a sync update if the policy allows.
|
|
||||||
|
|
||||||
## Security Model
|
|
||||||
- **None**: Data is stored as-is (e.g., standard UserDefaults).
|
|
||||||
- **Keychain**: Native hardware security using the iOS Keychain.
|
|
||||||
- **Encrypted**: Custom encryption (AES-256-GCM or ChaCha20-Poly1305) with key derivation (PBKDF2/HKDF).
|
|
||||||
- **File Protection**: Uses iOS "Complete File Protection" for encrypted file system writes.
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
# LocalData Migration Guide
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
`LocalData` provides built-in support for migrating data from legacy storage locations or keys to modern `StorageKey` definitions.
|
|
||||||
|
|
||||||
## Automatic Migration
|
|
||||||
When calling `get(_:)` on a key, the `StorageRouter` automatically:
|
|
||||||
1. Checks the primary location.
|
|
||||||
2. If not found, iterates through `migrationSources` defined on the key.
|
|
||||||
3. If data is found in a source:
|
|
||||||
- Unsecures it using the source's old policy.
|
|
||||||
- Re-secures it using the new key's policy.
|
|
||||||
- Stores it in the new location.
|
|
||||||
- Deletes the legacy data.
|
|
||||||
- Returns the value.
|
|
||||||
|
|
||||||
## Proactive Migration (Sweep)
|
|
||||||
You can trigger a sweep of all registered keys at app launch:
|
|
||||||
```swift
|
|
||||||
try await StorageRouter.shared.registerCatalog(MyCatalog.self, migrateImmediately: true)
|
|
||||||
```
|
|
||||||
This iterates through all keys in the catalog and calls `migrate(for:)` on each, ensuring all legacy data is consolidated.
|
|
||||||
|
|
||||||
## Defining Migration Sources
|
|
||||||
When defining a `StorageKey`, add legacy descriptors to the `migrationSources` array:
|
|
||||||
```swift
|
|
||||||
struct MyNewKey: StorageKey {
|
|
||||||
// ...
|
|
||||||
var migrationSources: [AnyStorageKey] {
|
|
||||||
[
|
|
||||||
.key(LegacyKey(name: "old_key_name", domain: .userDefaults(suite: nil)))
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
# LocalData Testing Strategy
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
To ensure high reliability for data persistence, security, and migration across all supported platforms (iOS and watchOS).
|
|
||||||
|
|
||||||
## Test Suites
|
|
||||||
|
|
||||||
### Unit Tests (`Tests/LocalDataTests/`)
|
|
||||||
- **LocalDataTests.swift**: Core round-trip tests for each storage domain (UserDefaults, FileSystem).
|
|
||||||
- **KeychainHelperTests.swift**: Verification of Keychain API interactions (add, update, delete, exists).
|
|
||||||
- **EncryptionHelperTests.swift**: Round-trip tests for AES and ChaCha20 encryption/decryption with various key derivation methods.
|
|
||||||
- **StorageCatalogTests.swift**: Validation of catalog registration, duplicate detection, and missing description checks.
|
|
||||||
|
|
||||||
## Key Testing Patterns
|
|
||||||
|
|
||||||
### 1. Domain Round-Trips
|
|
||||||
Always test the full cycle: `set` -> `get` (compare) -> `remove` -> `get` (expect `notFound`).
|
|
||||||
|
|
||||||
### 2. Migration Tests
|
|
||||||
Simulate legacy data by writing to a "legacy" key first, then verifying that the "modern" key can retrieve and consolidate that data.
|
|
||||||
|
|
||||||
### 3. Error Handling
|
|
||||||
Verify that the correct `StorageError` is thrown for:
|
|
||||||
- `notFound`
|
|
||||||
- `unregisteredKey`
|
|
||||||
- `dataTooLargeForSync`
|
|
||||||
- Domain-specific failures (e.g., Keychain errors)
|
|
||||||
|
|
||||||
## Running Tests
|
|
||||||
Run all tests from the package root:
|
|
||||||
```bash
|
|
||||||
swift test
|
|
||||||
```
|
|
||||||
23
Package.resolved
Normal file
23
Package.resolved
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "swift-syntax",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/swiftlang/swift-syntax.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "0687f71944021d616d34d922343dcef086855920",
|
||||||
|
"version" : "600.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-testing",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-testing.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211",
|
||||||
|
"version" : "0.99.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 2
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
// swift-tools-version: 5.10
|
// swift-tools-version: 5.9
|
||||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
@ -15,7 +15,9 @@ let package = Package(
|
|||||||
targets: ["LocalData"]
|
targets: ["LocalData"]
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
dependencies: [],
|
dependencies: [
|
||||||
|
.package(url: "https://github.com/apple/swift-testing.git", from: "0.7.0")
|
||||||
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
name: "LocalData"
|
name: "LocalData"
|
||||||
@ -23,7 +25,8 @@ let package = Package(
|
|||||||
.testTarget(
|
.testTarget(
|
||||||
name: "LocalDataTests",
|
name: "LocalDataTests",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
"LocalData"
|
"LocalData",
|
||||||
|
.product(name: "Testing", package: "swift-testing")
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -6,13 +6,8 @@ public struct FileStorageConfiguration: Sendable {
|
|||||||
/// If provided, files will be stored in `.../Documents/{subDirectory}/` instead of `.../Documents/`.
|
/// If provided, files will be stored in `.../Documents/{subDirectory}/` instead of `.../Documents/`.
|
||||||
public let subDirectory: String?
|
public let subDirectory: String?
|
||||||
|
|
||||||
/// An optional base URL to override the default system directories.
|
public init(subDirectory: String? = nil) {
|
||||||
/// Primarily used for testing isolation.
|
|
||||||
public let baseURL: URL?
|
|
||||||
|
|
||||||
public init(subDirectory: String? = nil, baseURL: URL? = nil) {
|
|
||||||
self.subDirectory = subDirectory
|
self.subDirectory = subDirectory
|
||||||
self.baseURL = baseURL
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static let `default` = FileStorageConfiguration()
|
public static let `default` = FileStorageConfiguration()
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import Security
|
|||||||
|
|
||||||
/// Defines additional access control requirements for keychain items.
|
/// Defines additional access control requirements for keychain items.
|
||||||
/// These flags can require user authentication before accessing the item.
|
/// These flags can require user authentication before accessing the item.
|
||||||
public enum KeychainAccessControl: Equatable, Sendable, CaseIterable {
|
public enum KeychainAccessControl: Sendable, CaseIterable {
|
||||||
/// Requires any form of user presence (biometric or passcode).
|
/// Requires any form of user presence (biometric or passcode).
|
||||||
case userPresence
|
case userPresence
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import Security
|
|||||||
|
|
||||||
/// Defines when a keychain item can be accessed.
|
/// Defines when a keychain item can be accessed.
|
||||||
/// Maps directly to Security framework's kSecAttrAccessible constants.
|
/// Maps directly to Security framework's kSecAttrAccessible constants.
|
||||||
public enum KeychainAccessibility: Equatable, Sendable, CaseIterable {
|
public enum KeychainAccessibility: Sendable, CaseIterable {
|
||||||
/// Item is only accessible while the device is unlocked.
|
/// Item is only accessible while the device is unlocked.
|
||||||
/// This is the most restrictive option for general use.
|
/// This is the most restrictive option for general use.
|
||||||
case whenUnlocked
|
case whenUnlocked
|
||||||
@ -20,6 +20,13 @@ public enum KeychainAccessibility: Equatable, Sendable, CaseIterable {
|
|||||||
/// Data is not migrated to a new device.
|
/// Data is not migrated to a new device.
|
||||||
case afterFirstUnlockThisDeviceOnly
|
case afterFirstUnlockThisDeviceOnly
|
||||||
|
|
||||||
|
/// Item is always accessible, regardless of device lock state.
|
||||||
|
/// Least secure - use only when absolutely necessary.
|
||||||
|
case always
|
||||||
|
|
||||||
|
/// Item is always accessible but not migrated to new devices.
|
||||||
|
case alwaysThisDeviceOnly
|
||||||
|
|
||||||
/// Item is only accessible when the device has a passcode set.
|
/// Item is only accessible when the device has a passcode set.
|
||||||
/// If passcode is removed, item becomes inaccessible.
|
/// If passcode is removed, item becomes inaccessible.
|
||||||
case whenPasscodeSetThisDeviceOnly
|
case whenPasscodeSetThisDeviceOnly
|
||||||
@ -35,6 +42,10 @@ public enum KeychainAccessibility: Equatable, Sendable, CaseIterable {
|
|||||||
return kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
return kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
||||||
case .afterFirstUnlockThisDeviceOnly:
|
case .afterFirstUnlockThisDeviceOnly:
|
||||||
return kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
return kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||||
|
case .always:
|
||||||
|
return kSecAttrAccessibleAlways
|
||||||
|
case .alwaysThisDeviceOnly:
|
||||||
|
return kSecAttrAccessibleAlwaysThisDeviceOnly
|
||||||
case .whenPasscodeSetThisDeviceOnly:
|
case .whenPasscodeSetThisDeviceOnly:
|
||||||
return kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
|
return kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
|
||||||
}
|
}
|
||||||
@ -51,18 +62,12 @@ public enum KeychainAccessibility: Equatable, Sendable, CaseIterable {
|
|||||||
return "When Unlocked (This Device)"
|
return "When Unlocked (This Device)"
|
||||||
case .afterFirstUnlockThisDeviceOnly:
|
case .afterFirstUnlockThisDeviceOnly:
|
||||||
return "After First Unlock (This Device)"
|
return "After First Unlock (This Device)"
|
||||||
|
case .always:
|
||||||
|
return "Always"
|
||||||
|
case .alwaysThisDeviceOnly:
|
||||||
|
return "Always (This Device)"
|
||||||
case .whenPasscodeSetThisDeviceOnly:
|
case .whenPasscodeSetThisDeviceOnly:
|
||||||
return "When Passcode Set (This Device)"
|
return "When Passcode Set (This Device)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static var allCases: [KeychainAccessibility] {
|
|
||||||
[
|
|
||||||
.whenUnlocked,
|
|
||||||
.afterFirstUnlock,
|
|
||||||
.whenUnlockedThisDeviceOnly,
|
|
||||||
.afterFirstUnlockThisDeviceOnly,
|
|
||||||
.whenPasscodeSetThisDeviceOnly
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,14 +2,14 @@ import Foundation
|
|||||||
import CryptoKit
|
import CryptoKit
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
public enum SecurityPolicy: Equatable, Sendable {
|
public enum SecurityPolicy: Sendable {
|
||||||
case none
|
case none
|
||||||
case encrypted(EncryptionPolicy)
|
case encrypted(EncryptionPolicy)
|
||||||
case keychain(accessibility: KeychainAccessibility, accessControl: KeychainAccessControl?)
|
case keychain(accessibility: KeychainAccessibility, accessControl: KeychainAccessControl?)
|
||||||
|
|
||||||
public static let recommended: SecurityPolicy = .encrypted(.recommended)
|
public static let recommended: SecurityPolicy = .encrypted(.recommended)
|
||||||
|
|
||||||
public enum EncryptionPolicy: Equatable, Sendable {
|
public enum EncryptionPolicy: Sendable {
|
||||||
case aes256(keyDerivation: KeyDerivation)
|
case aes256(keyDerivation: KeyDerivation)
|
||||||
case chacha20Poly1305(keyDerivation: KeyDerivation)
|
case chacha20Poly1305(keyDerivation: KeyDerivation)
|
||||||
case external(source: KeyMaterialSource, keyDerivation: KeyDerivation)
|
case external(source: KeyMaterialSource, keyDerivation: KeyDerivation)
|
||||||
@ -20,7 +20,7 @@ public enum SecurityPolicy: Equatable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum KeyDerivation: Equatable, Sendable {
|
public enum KeyDerivation: Sendable {
|
||||||
case pbkdf2(iterations: Int? = nil, salt: Data? = nil)
|
case pbkdf2(iterations: Int? = nil, salt: Data? = nil)
|
||||||
case hkdf(salt: Data? = nil, info: Data? = nil)
|
case hkdf(salt: Data? = nil, info: Data? = nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public enum StorageError: Error, Equatable {
|
public enum StorageError: Error {
|
||||||
case serializationFailed, deserializationFailed
|
case serializationFailed, deserializationFailed
|
||||||
case securityApplicationFailed
|
case securityApplicationFailed
|
||||||
case keychainError(OSStatus)
|
case keychainError(OSStatus)
|
||||||
case fileError(String) // Changed from Error to String for easier Equatable conformance
|
case fileError(Error)
|
||||||
case phoneOnlyKeyAccessedOnWatch(String)
|
case phoneOnlyKeyAccessedOnWatch(String)
|
||||||
case watchOnlyKeyAccessedOnPhone(String)
|
case watchOnlyKeyAccessedOnPhone(String)
|
||||||
case invalidUserDefaultsSuite(String)
|
case invalidUserDefaultsSuite(String)
|
||||||
@ -14,32 +14,6 @@ public enum StorageError: Error, Equatable {
|
|||||||
case unregisteredKey(String)
|
case unregisteredKey(String)
|
||||||
case duplicateRegisteredKeys([String])
|
case duplicateRegisteredKeys([String])
|
||||||
case missingDescription(String)
|
case missingDescription(String)
|
||||||
|
|
||||||
public static func == (lhs: StorageError, rhs: StorageError) -> Bool {
|
|
||||||
switch (lhs, rhs) {
|
|
||||||
case (.serializationFailed, .serializationFailed),
|
|
||||||
(.deserializationFailed, .deserializationFailed),
|
|
||||||
(.securityApplicationFailed, .securityApplicationFailed),
|
|
||||||
(.dataTooLargeForSync, .dataTooLargeForSync),
|
|
||||||
(.notFound, .notFound):
|
|
||||||
return true
|
|
||||||
case (.keychainError(let l), .keychainError(let r)):
|
|
||||||
return l == r
|
|
||||||
case (.fileError(let l), .fileError(let r)):
|
|
||||||
return l == r
|
|
||||||
case (.phoneOnlyKeyAccessedOnWatch(let l), .phoneOnlyKeyAccessedOnWatch(let r)),
|
|
||||||
(.watchOnlyKeyAccessedOnPhone(let l), .watchOnlyKeyAccessedOnPhone(let r)),
|
|
||||||
(.invalidUserDefaultsSuite(let l), .invalidUserDefaultsSuite(let r)),
|
|
||||||
(.invalidAppGroupIdentifier(let l), .invalidAppGroupIdentifier(let r)),
|
|
||||||
(.unregisteredKey(let l), .unregisteredKey(let r)),
|
|
||||||
(.missingDescription(let l), .missingDescription(let r)):
|
|
||||||
return l == r
|
|
||||||
case (.duplicateRegisteredKeys(let l), .duplicateRegisteredKeys(let r)):
|
|
||||||
return l == r
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension StorageError: @unchecked Sendable {}
|
extension StorageError: @unchecked Sendable {}
|
||||||
|
|||||||
@ -1,21 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -8,30 +8,22 @@ 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] = [:]
|
||||||
|
|
||||||
internal init(
|
private init(configuration: EncryptionConfiguration = .default) {
|
||||||
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
|
||||||
|
|
||||||
/// Registers a key material provider for external encryption policies.
|
/// Registers a key material provider for external encryption policies.
|
||||||
@ -135,7 +127,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 keychain.get(
|
if let existing = try await KeychainHelper.shared.get(
|
||||||
service: configuration.masterKeyService,
|
service: configuration.masterKeyService,
|
||||||
key: configuration.masterKeyAccount
|
key: configuration.masterKeyAccount
|
||||||
) {
|
) {
|
||||||
@ -152,7 +144,7 @@ actor EncryptionHelper {
|
|||||||
let masterKey = Data(bytes)
|
let masterKey = Data(bytes)
|
||||||
|
|
||||||
// Store in keychain
|
// Store in keychain
|
||||||
try await keychain.set(
|
try await KeychainHelper.shared.set(
|
||||||
masterKey,
|
masterKey,
|
||||||
service: configuration.masterKeyService,
|
service: configuration.masterKeyService,
|
||||||
key: configuration.masterKeyAccount,
|
key: configuration.masterKeyAccount,
|
||||||
@ -8,7 +8,7 @@ actor FileStorageHelper {
|
|||||||
|
|
||||||
private var configuration: FileStorageConfiguration
|
private var configuration: FileStorageConfiguration
|
||||||
|
|
||||||
internal init(configuration: FileStorageConfiguration = .default) {
|
private init(configuration: FileStorageConfiguration = .default) {
|
||||||
self.configuration = configuration
|
self.configuration = configuration
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -207,7 +207,7 @@ actor FileStorageHelper {
|
|||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
Logger.error("Failed to create directory", error: error)
|
Logger.error("Failed to create directory", error: error)
|
||||||
throw StorageError.fileError(error.localizedDescription)
|
throw StorageError.fileError(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -222,7 +222,7 @@ actor FileStorageHelper {
|
|||||||
Logger.debug("Successfully wrote \(data.count) bytes to \(url.lastPathComponent)")
|
Logger.debug("Successfully wrote \(data.count) bytes to \(url.lastPathComponent)")
|
||||||
} catch {
|
} catch {
|
||||||
Logger.error("Failed to write to \(url.path)", error: error)
|
Logger.error("Failed to write to \(url.path)", error: error)
|
||||||
throw StorageError.fileError(error.localizedDescription)
|
throw StorageError.fileError(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -234,7 +234,7 @@ actor FileStorageHelper {
|
|||||||
do {
|
do {
|
||||||
return try Data(contentsOf: url)
|
return try Data(contentsOf: url)
|
||||||
} catch {
|
} catch {
|
||||||
throw StorageError.fileError(error.localizedDescription)
|
throw StorageError.fileError(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -246,7 +246,7 @@ actor FileStorageHelper {
|
|||||||
do {
|
do {
|
||||||
try FileManager.default.removeItem(at: url)
|
try FileManager.default.removeItem(at: url)
|
||||||
} catch {
|
} catch {
|
||||||
throw StorageError.fileError(error.localizedDescription)
|
throw StorageError.fileError(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -258,7 +258,7 @@ actor FileStorageHelper {
|
|||||||
do {
|
do {
|
||||||
return try FileManager.default.contentsOfDirectory(atPath: url.path)
|
return try FileManager.default.contentsOfDirectory(atPath: url.path)
|
||||||
} catch {
|
} catch {
|
||||||
throw StorageError.fileError(error.localizedDescription)
|
throw StorageError.fileError(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -271,7 +271,7 @@ actor FileStorageHelper {
|
|||||||
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
|
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||||
return attributes[.size] as? Int64
|
return attributes[.size] as? Int64
|
||||||
} catch {
|
} catch {
|
||||||
throw StorageError.fileError(error.localizedDescription)
|
throw StorageError.fileError(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -286,23 +286,17 @@ actor FileStorageHelper {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveDirectoryURL(baseURL overrideURL: URL? = nil, directory: FileDirectory) throws -> URL {
|
private func resolveDirectoryURL(baseURL: URL? = nil, directory: FileDirectory) throws -> URL {
|
||||||
let base: URL
|
let base: URL
|
||||||
// Priority: 1. Method override, 2. Configuration override, 3. System default
|
if let baseURL = baseURL {
|
||||||
if let explicitBase = overrideURL ?? configuration.baseURL {
|
|
||||||
switch directory {
|
switch directory {
|
||||||
case .documents:
|
case .documents:
|
||||||
base = explicitBase.appending(path: "Documents")
|
base = baseURL.appending(path: "Documents")
|
||||||
case .caches:
|
case .caches:
|
||||||
base = explicitBase.appending(path: "Library/Caches")
|
base = baseURL.appending(path: "Library/Caches")
|
||||||
case .custom(let url):
|
case .custom(let url):
|
||||||
// If it's a custom URL, we treat it as relative to the base if it's not absolute or just use it.
|
let relativePath = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
// But for isolation, if baseURL is set, we might want to nest it.
|
return baseURL.appending(path: relativePath)
|
||||||
// For now, let's keep custom as is OR nest if it looks relative.
|
|
||||||
if url.isFileURL && url.path.hasPrefix("/") {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
return explicitBase.appending(path: url.path)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
base = directory.url()
|
base = directory.url()
|
||||||
@ -3,15 +3,22 @@ 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: KeychainStoring {
|
actor KeychainHelper {
|
||||||
|
|
||||||
public static let shared = KeychainHelper()
|
public static let shared = KeychainHelper()
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
// MARK: - KeychainStoring Implementation
|
// MARK: - Public Interface
|
||||||
|
|
||||||
/// 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,
|
||||||
@ -38,6 +45,9 @@ actor KeychainHelper: KeychainStoring {
|
|||||||
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)
|
||||||
@ -48,16 +58,16 @@ actor KeychainHelper: KeychainStoring {
|
|||||||
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
|
||||||
@ -71,31 +81,29 @@ actor KeychainHelper: KeychainStoring {
|
|||||||
} 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
|
||||||
@ -107,16 +115,13 @@ actor KeychainHelper: KeychainStoring {
|
|||||||
} 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,
|
||||||
@ -126,11 +131,6 @@ actor KeychainHelper: KeychainStoring {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -104,7 +104,7 @@ public struct StorageAuditReport: Sendable {
|
|||||||
private static func string(for derivation: SecurityPolicy.KeyDerivation) -> String {
|
private static func string(for derivation: SecurityPolicy.KeyDerivation) -> String {
|
||||||
switch derivation {
|
switch derivation {
|
||||||
case .pbkdf2(let iterations, _):
|
case .pbkdf2(let iterations, _):
|
||||||
return "pbkdf2(\(iterations ?? 0))"
|
return "pbkdf2(\(iterations))"
|
||||||
case .hkdf:
|
case .hkdf:
|
||||||
return "hkdf"
|
return "hkdf"
|
||||||
}
|
}
|
||||||
@ -1,6 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
#if os(iOS) || os(watchOS)
|
||||||
import WatchConnectivity
|
import WatchConnectivity
|
||||||
|
#endif
|
||||||
|
|
||||||
/// The main storage router that coordinates all storage operations.
|
/// The main storage router that coordinates all storage operations.
|
||||||
/// Uses specialized helper actors for each storage domain.
|
/// Uses specialized helper actors for each storage domain.
|
||||||
@ -11,28 +13,8 @@ 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 let encryption: EncryptionHelper
|
|
||||||
private let file: FileStorageHelper
|
|
||||||
private let defaults: UserDefaultsHelper
|
|
||||||
private let sync: SyncHelper
|
|
||||||
|
|
||||||
/// Initialize a new StorageRouter.
|
private init() {}
|
||||||
/// Internal for testing isolation via @testable import.
|
|
||||||
/// Consumers should use the `shared` singleton.
|
|
||||||
internal init(
|
|
||||||
keychain: KeychainStoring = KeychainHelper.shared,
|
|
||||||
encryption: EncryptionHelper = .shared,
|
|
||||||
file: FileStorageHelper = .shared,
|
|
||||||
defaults: UserDefaultsHelper = .shared,
|
|
||||||
sync: SyncHelper = .shared
|
|
||||||
) {
|
|
||||||
self.keychain = keychain
|
|
||||||
self.encryption = encryption
|
|
||||||
self.file = file
|
|
||||||
self.defaults = defaults
|
|
||||||
self.sync = sync
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Configuration
|
// MARK: - Configuration
|
||||||
|
|
||||||
@ -41,18 +23,17 @@ public actor StorageRouter: StorageProviding {
|
|||||||
/// > Changing these constants in an existing app will cause the app to look for the master key
|
/// > Changing these constants in an existing app will cause the app to look for the master key
|
||||||
/// > 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 encryption.updateConfiguration(configuration)
|
await EncryptionHelper.shared.updateConfiguration(configuration)
|
||||||
await encryption.updateKeychainHelper(keychain)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates the sync configuration.
|
/// Updates the sync configuration.
|
||||||
public func updateSyncConfiguration(_ configuration: SyncConfiguration) async {
|
public func updateSyncConfiguration(_ configuration: SyncConfiguration) async {
|
||||||
await sync.updateConfiguration(configuration)
|
await SyncHelper.shared.updateConfiguration(configuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates the file storage configuration.
|
/// Updates the file storage configuration.
|
||||||
public func updateFileStorageConfiguration(_ configuration: FileStorageConfiguration) async {
|
public func updateFileStorageConfiguration(_ configuration: FileStorageConfiguration) async {
|
||||||
await file.updateConfiguration(configuration)
|
await FileStorageHelper.shared.updateConfiguration(configuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates the global storage configuration (defaults).
|
/// Updates the global storage configuration (defaults).
|
||||||
@ -67,8 +48,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
_ provider: any KeyMaterialProviding,
|
_ provider: any KeyMaterialProviding,
|
||||||
for source: KeyMaterialSource
|
for source: KeyMaterialSource
|
||||||
) async {
|
) async {
|
||||||
await encryption.updateKeychainHelper(keychain)
|
await EncryptionHelper.shared.registerKeyMaterialProvider(provider, for: source)
|
||||||
await encryption.registerKeyMaterialProvider(provider, for: source)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registers a catalog of known storage keys for audit and validation.
|
/// Registers a catalog of known storage keys for audit and validation.
|
||||||
@ -208,18 +188,18 @@ public actor StorageRouter: StorageProviding {
|
|||||||
|
|
||||||
switch key.domain {
|
switch key.domain {
|
||||||
case .userDefaults(let suite):
|
case .userDefaults(let suite):
|
||||||
return try await defaults.exists(forKey: key.name, suite: suite)
|
return try await UserDefaultsHelper.shared.exists(forKey: key.name, suite: suite)
|
||||||
case .appGroupUserDefaults(let identifier):
|
case .appGroupUserDefaults(let identifier):
|
||||||
let resolvedId = try resolveIdentifier(identifier)
|
let resolvedId = try resolveIdentifier(identifier)
|
||||||
return try await defaults.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 keychain.exists(service: resolvedService, key: key.name)
|
return try await KeychainHelper.shared.exists(service: resolvedService, key: key.name)
|
||||||
case .fileSystem(let directory), .encryptedFileSystem(let directory):
|
case .fileSystem(let directory), .encryptedFileSystem(let directory):
|
||||||
return await file.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):
|
||||||
let resolvedId = try resolveIdentifier(identifier)
|
let resolvedId = try resolveIdentifier(identifier)
|
||||||
return await file.exists(
|
return await FileStorageHelper.shared.exists(
|
||||||
in: directory,
|
in: directory,
|
||||||
fileName: key.name,
|
fileName: key.name,
|
||||||
appGroupIdentifier: resolvedId
|
appGroupIdentifier: resolvedId
|
||||||
@ -245,24 +225,12 @@ public actor StorageRouter: StorageProviding {
|
|||||||
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
|
||||||
if !isRunningTests {
|
|
||||||
assertionFailure("StorageKey not registered in catalog: \(key.name)")
|
assertionFailure("StorageKey not registered in catalog: \(key.name)")
|
||||||
}
|
|
||||||
#endif
|
#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] = []
|
||||||
@ -327,15 +295,14 @@ public actor StorageRouter: StorageProviding {
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
case .encrypted(let encryptionPolicy):
|
case .encrypted(let encryptionPolicy):
|
||||||
await encryption.updateKeychainHelper(keychain)
|
|
||||||
if isEncrypt {
|
if isEncrypt {
|
||||||
return try await encryption.encrypt(
|
return try await EncryptionHelper.shared.encrypt(
|
||||||
data,
|
data,
|
||||||
keyName: descriptor.name,
|
keyName: descriptor.name,
|
||||||
policy: encryptionPolicy
|
policy: encryptionPolicy
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
return try await encryption.decrypt(
|
return try await EncryptionHelper.shared.decrypt(
|
||||||
data,
|
data,
|
||||||
keyName: descriptor.name,
|
keyName: descriptor.name,
|
||||||
policy: encryptionPolicy
|
policy: encryptionPolicy
|
||||||
@ -351,24 +318,21 @@ public actor StorageRouter: StorageProviding {
|
|||||||
// MARK: - Storage Operations
|
// MARK: - Storage Operations
|
||||||
|
|
||||||
private func store(_ data: Data, for key: any StorageKey) async throws {
|
private func store(_ data: Data, for key: any StorageKey) async throws {
|
||||||
try await store(data, for: .from(key))
|
let descriptor = StorageKeyDescriptor.from(key)
|
||||||
}
|
|
||||||
|
|
||||||
private func store(_ data: Data, for descriptor: StorageKeyDescriptor) async throws {
|
|
||||||
switch descriptor.domain {
|
switch descriptor.domain {
|
||||||
case .userDefaults(let suite):
|
case .userDefaults(let suite):
|
||||||
try await defaults.set(data, forKey: descriptor.name, suite: suite)
|
try await UserDefaultsHelper.shared.set(data, forKey: descriptor.name, suite: suite)
|
||||||
|
|
||||||
case .appGroupUserDefaults(let identifier):
|
case .appGroupUserDefaults(let identifier):
|
||||||
let resolvedId = try resolveIdentifier(identifier)
|
let resolvedId = try resolveIdentifier(identifier)
|
||||||
try await defaults.set(data, forKey: descriptor.name, appGroupIdentifier: resolvedId)
|
try await UserDefaultsHelper.shared.set(data, forKey: descriptor.name, appGroupIdentifier: resolvedId)
|
||||||
|
|
||||||
case .keychain(let service):
|
case .keychain(let service):
|
||||||
guard case let .keychain(accessibility, accessControl) = descriptor.security else {
|
guard case let .keychain(accessibility, accessControl) = descriptor.security else {
|
||||||
throw StorageError.securityApplicationFailed
|
throw StorageError.securityApplicationFailed
|
||||||
}
|
}
|
||||||
let resolvedService = try resolveService(service)
|
let resolvedService = try resolveService(service)
|
||||||
try await keychain.set(
|
try await KeychainHelper.shared.set(
|
||||||
data,
|
data,
|
||||||
service: resolvedService,
|
service: resolvedService,
|
||||||
key: descriptor.name,
|
key: descriptor.name,
|
||||||
@ -377,7 +341,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
)
|
)
|
||||||
|
|
||||||
case .fileSystem(let directory):
|
case .fileSystem(let directory):
|
||||||
try await file.write(
|
try await FileStorageHelper.shared.write(
|
||||||
data,
|
data,
|
||||||
to: directory,
|
to: directory,
|
||||||
fileName: descriptor.name,
|
fileName: descriptor.name,
|
||||||
@ -385,7 +349,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
)
|
)
|
||||||
|
|
||||||
case .encryptedFileSystem(let directory):
|
case .encryptedFileSystem(let directory):
|
||||||
try await file.write(
|
try await FileStorageHelper.shared.write(
|
||||||
data,
|
data,
|
||||||
to: directory,
|
to: directory,
|
||||||
fileName: descriptor.name,
|
fileName: descriptor.name,
|
||||||
@ -394,7 +358,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
|
|
||||||
case .appGroupFileSystem(let identifier, let directory):
|
case .appGroupFileSystem(let identifier, let directory):
|
||||||
let resolvedId = try resolveIdentifier(identifier)
|
let resolvedId = try resolveIdentifier(identifier)
|
||||||
try await file.write(
|
try await FileStorageHelper.shared.write(
|
||||||
data,
|
data,
|
||||||
to: directory,
|
to: directory,
|
||||||
fileName: descriptor.name,
|
fileName: descriptor.name,
|
||||||
@ -407,20 +371,20 @@ public actor StorageRouter: StorageProviding {
|
|||||||
private func retrieve(for descriptor: StorageKeyDescriptor) async throws -> Data? {
|
private func retrieve(for descriptor: StorageKeyDescriptor) async throws -> Data? {
|
||||||
switch descriptor.domain {
|
switch descriptor.domain {
|
||||||
case .userDefaults(let suite):
|
case .userDefaults(let suite):
|
||||||
return try await defaults.get(forKey: descriptor.name, suite: suite)
|
return try await UserDefaultsHelper.shared.get(forKey: descriptor.name, suite: suite)
|
||||||
case .appGroupUserDefaults(let identifier):
|
case .appGroupUserDefaults(let identifier):
|
||||||
let resolvedId = try resolveIdentifier(identifier)
|
let resolvedId = try resolveIdentifier(identifier)
|
||||||
return try await defaults.get(forKey: descriptor.name, appGroupIdentifier: resolvedId)
|
return try await UserDefaultsHelper.shared.get(forKey: descriptor.name, appGroupIdentifier: resolvedId)
|
||||||
|
|
||||||
case .keychain(let service):
|
case .keychain(let service):
|
||||||
let resolvedService = try resolveService(service)
|
let resolvedService = try resolveService(service)
|
||||||
return try await keychain.get(service: resolvedService, key: descriptor.name)
|
return try await KeychainHelper.shared.get(service: resolvedService, key: descriptor.name)
|
||||||
|
|
||||||
case .fileSystem(let directory), .encryptedFileSystem(let directory):
|
case .fileSystem(let directory), .encryptedFileSystem(let directory):
|
||||||
return try await file.read(from: directory, fileName: descriptor.name)
|
return try await FileStorageHelper.shared.read(from: directory, fileName: descriptor.name)
|
||||||
case .appGroupFileSystem(let identifier, let directory):
|
case .appGroupFileSystem(let identifier, let directory):
|
||||||
let resolvedId = try resolveIdentifier(identifier)
|
let resolvedId = try resolveIdentifier(identifier)
|
||||||
return try await file.read(
|
return try await FileStorageHelper.shared.read(
|
||||||
from: directory,
|
from: directory,
|
||||||
fileName: descriptor.name,
|
fileName: descriptor.name,
|
||||||
appGroupIdentifier: resolvedId
|
appGroupIdentifier: resolvedId
|
||||||
@ -431,20 +395,20 @@ public actor StorageRouter: StorageProviding {
|
|||||||
private func delete(for descriptor: StorageKeyDescriptor) async throws {
|
private func delete(for descriptor: StorageKeyDescriptor) async throws {
|
||||||
switch descriptor.domain {
|
switch descriptor.domain {
|
||||||
case .userDefaults(let suite):
|
case .userDefaults(let suite):
|
||||||
try await defaults.remove(forKey: descriptor.name, suite: suite)
|
try await UserDefaultsHelper.shared.remove(forKey: descriptor.name, suite: suite)
|
||||||
case .appGroupUserDefaults(let identifier):
|
case .appGroupUserDefaults(let identifier):
|
||||||
let resolvedId = try resolveIdentifier(identifier)
|
let resolvedId = try resolveIdentifier(identifier)
|
||||||
try await defaults.remove(forKey: descriptor.name, appGroupIdentifier: resolvedId)
|
try await UserDefaultsHelper.shared.remove(forKey: descriptor.name, appGroupIdentifier: resolvedId)
|
||||||
|
|
||||||
case .keychain(let service):
|
case .keychain(let service):
|
||||||
let resolvedService = try resolveService(service)
|
let resolvedService = try resolveService(service)
|
||||||
try await keychain.delete(service: resolvedService, key: descriptor.name)
|
try await KeychainHelper.shared.delete(service: resolvedService, key: descriptor.name)
|
||||||
|
|
||||||
case .fileSystem(let directory), .encryptedFileSystem(let directory):
|
case .fileSystem(let directory), .encryptedFileSystem(let directory):
|
||||||
try await file.delete(from: directory, fileName: descriptor.name)
|
try await FileStorageHelper.shared.delete(from: directory, fileName: descriptor.name)
|
||||||
case .appGroupFileSystem(let identifier, let directory):
|
case .appGroupFileSystem(let identifier, let directory):
|
||||||
let resolvedId = try resolveIdentifier(identifier)
|
let resolvedId = try resolveIdentifier(identifier)
|
||||||
try await file.delete(
|
try await FileStorageHelper.shared.delete(
|
||||||
from: directory,
|
from: directory,
|
||||||
fileName: descriptor.name,
|
fileName: descriptor.name,
|
||||||
appGroupIdentifier: resolvedId
|
appGroupIdentifier: resolvedId
|
||||||
@ -455,7 +419,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
// MARK: - Sync
|
// MARK: - Sync
|
||||||
|
|
||||||
private func handleSync(_ key: any StorageKey, data: Data) async throws {
|
private func handleSync(_ key: any StorageKey, data: Data) async throws {
|
||||||
try await sync.syncIfNeeded(
|
try await SyncHelper.shared.syncIfNeeded(
|
||||||
data: data,
|
data: data,
|
||||||
keyName: key.name,
|
keyName: key.name,
|
||||||
availability: key.availability,
|
availability: key.availability,
|
||||||
@ -463,23 +427,6 @@ public actor StorageRouter: StorageProviding {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Internal Sync Handling
|
|
||||||
|
|
||||||
/// Internal method to update storage from received sync data.
|
|
||||||
/// This is called by SyncHelper when the paired device sends new context.
|
|
||||||
func updateFromSync(keyName: String, data: Data) async throws {
|
|
||||||
// Find the registered entry for this key
|
|
||||||
guard let entry = registeredEntries.first(where: { $0.descriptor.name == keyName }) else {
|
|
||||||
Logger.debug("Received sync data for unregistered or uncatalogued key: \(keyName)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// The data received is already 'secured' (encrypted if necessary) by the sender.
|
|
||||||
// We can store it directly in our local domain.
|
|
||||||
try await store(data, for: entry.descriptor)
|
|
||||||
Logger.info("Successfully updated local storage from sync for key: \(keyName)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Resolution Helpers
|
// MARK: - Resolution Helpers
|
||||||
|
|
||||||
private func resolveService(_ service: String?) throws -> String {
|
private func resolveService(_ service: String?) throws -> String {
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
#if os(iOS) || os(watchOS)
|
||||||
import WatchConnectivity
|
import WatchConnectivity
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Actor that handles WatchConnectivity sync operations.
|
/// Actor that handles WatchConnectivity sync operations.
|
||||||
/// Manages data synchronization between iPhone and Apple Watch.
|
/// Manages data synchronization between iPhone and Apple Watch.
|
||||||
@ -9,7 +11,7 @@ actor SyncHelper {
|
|||||||
|
|
||||||
private var configuration: SyncConfiguration
|
private var configuration: SyncConfiguration
|
||||||
|
|
||||||
internal init(configuration: SyncConfiguration = .default) {
|
private init(configuration: SyncConfiguration = .default) {
|
||||||
self.configuration = configuration
|
self.configuration = configuration
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,6 +35,7 @@ actor SyncHelper {
|
|||||||
availability: PlatformAvailability,
|
availability: PlatformAvailability,
|
||||||
syncPolicy: SyncPolicy
|
syncPolicy: SyncPolicy
|
||||||
) throws {
|
) throws {
|
||||||
|
#if os(iOS) || os(watchOS)
|
||||||
// Only sync for appropriate availability settings
|
// Only sync for appropriate availability settings
|
||||||
guard availability == .all || availability == .phoneWithWatchSync else {
|
guard availability == .all || availability == .phoneWithWatchSync else {
|
||||||
return
|
return
|
||||||
@ -51,6 +54,7 @@ actor SyncHelper {
|
|||||||
case .manual:
|
case .manual:
|
||||||
try performSync(data: data, keyName: keyName)
|
try performSync(data: data, keyName: keyName)
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Manually triggers a sync for the given data.
|
/// Manually triggers a sync for the given data.
|
||||||
@ -59,12 +63,15 @@ actor SyncHelper {
|
|||||||
/// - keyName: The key name for the application context.
|
/// - keyName: The key name for the application context.
|
||||||
/// - Throws: Various errors if sync fails.
|
/// - Throws: Various errors if sync fails.
|
||||||
public func manualSync(data: Data, keyName: String) throws {
|
public func manualSync(data: Data, keyName: String) throws {
|
||||||
|
#if os(iOS) || os(watchOS)
|
||||||
try performSync(data: data, keyName: keyName)
|
try performSync(data: data, keyName: keyName)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks if sync is available.
|
/// Checks if sync is available.
|
||||||
/// - Returns: True if WatchConnectivity is supported and active.
|
/// - Returns: True if WatchConnectivity is supported and active.
|
||||||
public func isSyncAvailable() -> Bool {
|
public func isSyncAvailable() -> Bool {
|
||||||
|
#if os(iOS) || os(watchOS)
|
||||||
guard WCSession.isSupported() else { return false }
|
guard WCSession.isSupported() else { return false }
|
||||||
|
|
||||||
let session = WCSession.default
|
let session = WCSession.default
|
||||||
@ -75,25 +82,29 @@ actor SyncHelper {
|
|||||||
#else
|
#else
|
||||||
return true
|
return true
|
||||||
#endif
|
#endif
|
||||||
|
#else
|
||||||
|
return false
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gets the current application context.
|
/// Gets the current application context.
|
||||||
/// - Returns: The current application context dictionary.
|
/// - Returns: The current application context dictionary.
|
||||||
public func currentContext() -> [String: Any] {
|
public func currentContext() -> [String: Any] {
|
||||||
|
#if os(iOS) || os(watchOS)
|
||||||
guard WCSession.isSupported() else { return [:] }
|
guard WCSession.isSupported() else { return [:] }
|
||||||
return WCSession.default.applicationContext
|
return WCSession.default.applicationContext
|
||||||
|
#else
|
||||||
|
return [:]
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
#if os(iOS) || os(watchOS)
|
||||||
private func performSync(data: Data, keyName: String) throws {
|
private func performSync(data: Data, keyName: String) throws {
|
||||||
guard WCSession.isSupported() else { return }
|
guard WCSession.isSupported() else { return }
|
||||||
|
|
||||||
let session = WCSession.default
|
let session = WCSession.default
|
||||||
if session.delegate == nil {
|
|
||||||
setupSession()
|
|
||||||
}
|
|
||||||
|
|
||||||
guard session.activationState == .activated else { return }
|
guard session.activationState == .activated else { return }
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@ -102,54 +113,5 @@ actor SyncHelper {
|
|||||||
|
|
||||||
try session.updateApplicationContext([keyName: data])
|
try session.updateApplicationContext([keyName: data])
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupSession() {
|
|
||||||
let session = WCSession.default
|
|
||||||
session.delegate = SessionDelegateProxy.shared
|
|
||||||
session.activate()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handles received application context from the paired device.
|
|
||||||
/// This is called by the delegate proxy.
|
|
||||||
internal func handleReceivedContext(_ context: [String: Any]) async {
|
|
||||||
Logger.info(">>> [SYNC] Received application context with \(context.count) keys")
|
|
||||||
for (key, value) in context {
|
|
||||||
guard let data = value as? Data else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
Logger.debug(">>> [SYNC] Processing received data for key: \(key)")
|
|
||||||
|
|
||||||
do {
|
|
||||||
try await StorageRouter.shared.updateFromSync(keyName: key, data: data)
|
|
||||||
} catch {
|
|
||||||
Logger.error("Failed to update storage from sync for key: \(key)", error: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An internal proxy class to handle WCSessionDelegate callbacks and route them to the SyncHelper actor.
|
|
||||||
internal final class SessionDelegateProxy: NSObject, WCSessionDelegate {
|
|
||||||
static let shared = SessionDelegateProxy()
|
|
||||||
|
|
||||||
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
|
||||||
if let error = error {
|
|
||||||
Logger.error("WCSession activation failed: \(error.localizedDescription)")
|
|
||||||
} else {
|
|
||||||
Logger.info("WCSession activated with state: \(activationState.rawValue)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
|
||||||
Task {
|
|
||||||
await SyncHelper.shared.handleReceivedContext(applicationContext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
func sessionDidBecomeInactive(_ session: WCSession) {}
|
|
||||||
func sessionDidDeactivate(_ session: WCSession) {
|
|
||||||
session.activate()
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@ -6,11 +6,7 @@ actor UserDefaultsHelper {
|
|||||||
|
|
||||||
public static let shared = UserDefaultsHelper()
|
public static let shared = UserDefaultsHelper()
|
||||||
|
|
||||||
private let defaults: UserDefaults
|
private init() {}
|
||||||
|
|
||||||
internal init(defaults: UserDefaults = .standard) {
|
|
||||||
self.defaults = defaults
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public Interface
|
// MARK: - Public Interface
|
||||||
|
|
||||||
@ -121,12 +117,14 @@ actor UserDefaultsHelper {
|
|||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
private func userDefaults(for suite: String?) throws -> UserDefaults {
|
private func userDefaults(for suite: String?) throws -> UserDefaults {
|
||||||
if let suite {
|
guard let suite else {
|
||||||
guard let suiteDefaults = UserDefaults(suiteName: suite) else {
|
return .standard
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let defaults = UserDefaults(suiteName: suite) else {
|
||||||
throw StorageError.invalidUserDefaultsSuite(suite)
|
throw StorageError.invalidUserDefaultsSuite(suite)
|
||||||
}
|
}
|
||||||
return suiteDefaults
|
|
||||||
}
|
|
||||||
return defaults
|
return defaults
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import LocalData
|
|
||||||
|
|
||||||
@Suite struct AnyCodableTests {
|
|
||||||
|
|
||||||
@Test func encodeDecodePrimitives() throws {
|
|
||||||
let values: [Any] = [true, 42, 3.14, "hello"]
|
|
||||||
|
|
||||||
for value in values {
|
|
||||||
let anyCodable = AnyCodable(value)
|
|
||||||
let data = try JSONEncoder().encode(anyCodable)
|
|
||||||
let decoded = try JSONDecoder().decode(AnyCodable.self, from: data)
|
|
||||||
|
|
||||||
if let b = value as? Bool { #expect(decoded.value as? Bool == b) }
|
|
||||||
else if let i = value as? Int { #expect(decoded.value as? Int == i) }
|
|
||||||
else if let d = value as? Double { #expect(decoded.value as? Double == d) }
|
|
||||||
else if let s = value as? String { #expect(decoded.value as? String == s) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func encodeDecodeComplex() throws {
|
|
||||||
let dictionary: [String: Any] = [
|
|
||||||
"bool": true,
|
|
||||||
"int": 123,
|
|
||||||
"string": "test",
|
|
||||||
"array": [1, 2, 3],
|
|
||||||
"nested": ["key": "value"]
|
|
||||||
]
|
|
||||||
|
|
||||||
let anyCodable = AnyCodable(dictionary)
|
|
||||||
let data = try JSONEncoder().encode(anyCodable)
|
|
||||||
let decoded = try JSONDecoder().decode(AnyCodable.self, from: data)
|
|
||||||
|
|
||||||
guard let result = decoded.value as? [String: Any] else {
|
|
||||||
Issue.record("Decoded value is not a dictionary")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
#expect(result["bool"] as? Bool == true)
|
|
||||||
#expect(result["int"] as? Int == 123)
|
|
||||||
#expect(result["string"] as? String == "test")
|
|
||||||
#expect((result["array"] as? [Int]) == [1, 2, 3])
|
|
||||||
#expect((result["nested"] as? [String: String]) == ["key": "value"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func throwsOnInvalidValue() {
|
|
||||||
struct NonCodable {}
|
|
||||||
let anyCodable = AnyCodable(NonCodable())
|
|
||||||
|
|
||||||
#expect(throws: EncodingError.self) {
|
|
||||||
_ = try JSONEncoder().encode(anyCodable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import LocalData
|
|
||||||
|
|
||||||
@Suite struct AnyStorageKeyTests {
|
|
||||||
|
|
||||||
private struct StringKey: StorageKey {
|
|
||||||
typealias Value = String
|
|
||||||
let name: String
|
|
||||||
let domain: StorageDomain = .userDefaults(suite: nil)
|
|
||||||
let security: SecurityPolicy = .none
|
|
||||||
let serializer: Serializer<String> = .json
|
|
||||||
let owner: String = "Test"
|
|
||||||
let description: String = "Test"
|
|
||||||
let availability: PlatformAvailability = .all
|
|
||||||
let syncPolicy: SyncPolicy = .never
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func anyStorageKeyCapturesDescriptor() {
|
|
||||||
let key = StringKey(name: "test.key")
|
|
||||||
let anyKey = AnyStorageKey.key(key)
|
|
||||||
|
|
||||||
#expect(anyKey.descriptor.name == "test.key")
|
|
||||||
#expect(anyKey.descriptor.owner == "Test")
|
|
||||||
#expect(anyKey.descriptor.valueType == "String")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func anyStorageKeyTriggersMigration() async throws {
|
|
||||||
let router = StorageRouter(keychain: MockKeychainHelper())
|
|
||||||
let key = StringKey(name: "test.key")
|
|
||||||
let anyKey = AnyStorageKey.key(key)
|
|
||||||
|
|
||||||
// This will call router.migrate(for: key)
|
|
||||||
// Since there are no migration sources, it just returns
|
|
||||||
try await anyKey.migrate(on: router)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import LocalData
|
|
||||||
|
|
||||||
@Suite struct AppGroupTests {
|
|
||||||
private let userDefaultsHelper: UserDefaultsHelper
|
|
||||||
private let fileStorageHelper: FileStorageHelper
|
|
||||||
|
|
||||||
init() {
|
|
||||||
let suiteName = "AppGroupTests-\(UUID().uuidString)"
|
|
||||||
userDefaultsHelper = UserDefaultsHelper(defaults: UserDefaults(suiteName: suiteName)!)
|
|
||||||
|
|
||||||
let testBaseURL = FileManager.default.temporaryDirectory.appending(path: "AppGroupTests-\(UUID().uuidString)")
|
|
||||||
fileStorageHelper = FileStorageHelper(configuration: FileStorageConfiguration(baseURL: testBaseURL))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: These tests might fail to find real containers in a unit test environment,
|
|
||||||
// but they will exercise the routing logic.
|
|
||||||
|
|
||||||
@Test func userDefaultsAppGroupRoundTrip() async throws {
|
|
||||||
let identifier = "group.com.test.localdata"
|
|
||||||
let key = "test.appgroup.key"
|
|
||||||
let data = Data("appgroup-value".utf8)
|
|
||||||
|
|
||||||
// This usually works in simulators even without real entitlements
|
|
||||||
try await userDefaultsHelper.set(data, forKey: key, appGroupIdentifier: identifier)
|
|
||||||
let retrieved = try await userDefaultsHelper.get(forKey: key, appGroupIdentifier: identifier)
|
|
||||||
#expect(retrieved == data)
|
|
||||||
|
|
||||||
let exists = try await userDefaultsHelper.exists(forKey: key, appGroupIdentifier: identifier)
|
|
||||||
#expect(exists == true)
|
|
||||||
|
|
||||||
let allKeys = try await userDefaultsHelper.allKeys(appGroupIdentifier: identifier)
|
|
||||||
#expect(allKeys.contains(key))
|
|
||||||
|
|
||||||
try await userDefaultsHelper.remove(forKey: key, appGroupIdentifier: identifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func fileStorageAppGroupError() async throws {
|
|
||||||
let identifier = "invalid.group.id"
|
|
||||||
let data = Data("data".utf8)
|
|
||||||
|
|
||||||
// Simulators often return a URL even for invalid group IDs.
|
|
||||||
// On real devices/authorized environments, it returns nil if not matched.
|
|
||||||
if FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: identifier) == nil {
|
|
||||||
await #expect(throws: StorageError.invalidAppGroupIdentifier(identifier)) {
|
|
||||||
try await fileStorageHelper.write(
|
|
||||||
data,
|
|
||||||
to: .documents,
|
|
||||||
fileName: "test.txt",
|
|
||||||
appGroupIdentifier: identifier
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func fileStorageAppGroupRoundTrip() async throws {
|
|
||||||
// In simulator, this usually returns a placeholder path
|
|
||||||
let identifier = "group.com.test.localdata"
|
|
||||||
let fileName = "appgroup_file.txt"
|
|
||||||
let data = Data("appgroup-file-content".utf8)
|
|
||||||
|
|
||||||
// Only run if the simulator/environment gives us a container
|
|
||||||
if let _ = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: identifier) {
|
|
||||||
try await fileStorageHelper.write(data, to: .documents, fileName: fileName, appGroupIdentifier: identifier)
|
|
||||||
|
|
||||||
let exists = await fileStorageHelper.exists(in: .documents, fileName: fileName, appGroupIdentifier: identifier)
|
|
||||||
#expect(exists == true)
|
|
||||||
|
|
||||||
let retrieved = try await fileStorageHelper.read(from: .documents, fileName: fileName, appGroupIdentifier: identifier)
|
|
||||||
#expect(retrieved == data)
|
|
||||||
|
|
||||||
let list = try await fileStorageHelper.list(in: .documents, appGroupIdentifier: identifier)
|
|
||||||
#expect(list.contains(fileName))
|
|
||||||
|
|
||||||
let size = try await fileStorageHelper.size(of: .documents, fileName: fileName, appGroupIdentifier: identifier)
|
|
||||||
#expect(size == Int64(data.count))
|
|
||||||
|
|
||||||
try await fileStorageHelper.delete(from: .documents, fileName: fileName, appGroupIdentifier: identifier)
|
|
||||||
|
|
||||||
let afterDelete = await fileStorageHelper.exists(in: .documents, fileName: fileName, appGroupIdentifier: identifier)
|
|
||||||
#expect(afterDelete == false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import LocalData
|
|
||||||
|
|
||||||
@Suite struct AuditTests {
|
|
||||||
|
|
||||||
private struct AuditCatalog: StorageKeyCatalog {
|
|
||||||
static var allKeys: [AnyStorageKey] {
|
|
||||||
[
|
|
||||||
.key(TestKey(name: "k1", domain: .userDefaults(suite: nil))),
|
|
||||||
.key(TestKey(name: "k2", domain: .keychain(service: "s"), security: .keychain(accessibility: .afterFirstUnlock, accessControl: .userPresence))),
|
|
||||||
.key(TestKey(name: "k3", domain: .fileSystem(directory: .documents))),
|
|
||||||
.key(TestKey(name: "k4", domain: .encryptedFileSystem(directory: .caches))),
|
|
||||||
.key(TestKey(name: "k5", domain: .appGroupUserDefaults(identifier: "ig"), security: .encrypted(.recommended)))
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct TestKey: StorageKey {
|
|
||||||
typealias Value = String
|
|
||||||
let name: String
|
|
||||||
let domain: StorageDomain
|
|
||||||
let security: SecurityPolicy
|
|
||||||
let serializer: Serializer<String> = .json
|
|
||||||
let owner: String = "Audit"
|
|
||||||
let description: String = "Desc"
|
|
||||||
let availability: PlatformAvailability = .all
|
|
||||||
let syncPolicy: SyncPolicy = .never
|
|
||||||
|
|
||||||
init(name: String, domain: StorageDomain, security: SecurityPolicy = .none) {
|
|
||||||
self.name = name
|
|
||||||
self.domain = domain
|
|
||||||
self.security = security
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func renderCatalogText() {
|
|
||||||
let text = StorageAuditReport.renderText(for: AuditCatalog.self)
|
|
||||||
|
|
||||||
#expect(text.contains("name=k1"))
|
|
||||||
#expect(text.contains("domain=userDefaults(standard)"))
|
|
||||||
|
|
||||||
#expect(text.contains("name=k2"))
|
|
||||||
#expect(text.contains("keychain(After First Unlock, User Presence)"))
|
|
||||||
|
|
||||||
#expect(text.contains("name=k3"))
|
|
||||||
#expect(text.contains("fileSystem(documents)"))
|
|
||||||
|
|
||||||
#expect(text.contains("name=k4"))
|
|
||||||
#expect(text.contains("encryptedFileSystem(caches)"))
|
|
||||||
|
|
||||||
#expect(text.contains("name=k5"))
|
|
||||||
#expect(text.contains("appGroupUserDefaults(ig)"))
|
|
||||||
#expect(text.contains("security=encrypted(chacha20Poly1305(hkdf))"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,54 +2,53 @@ 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 encryption.encrypt(
|
let encrypted = try await EncryptionHelper.shared.encrypt(
|
||||||
payload,
|
payload,
|
||||||
keyName: keyName,
|
keyName: keyName,
|
||||||
policy: policy
|
policy: policy
|
||||||
)
|
)
|
||||||
let decrypted = try await encryption.decrypt(
|
let decrypted = try await EncryptionHelper.shared.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 encryption.encrypt(
|
let encrypted = try await EncryptionHelper.shared.encrypt(
|
||||||
payload,
|
payload,
|
||||||
keyName: keyName,
|
keyName: keyName,
|
||||||
policy: policy
|
policy: policy
|
||||||
)
|
)
|
||||||
let decrypted = try await encryption.decrypt(
|
let decrypted = try await EncryptionHelper.shared.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 {
|
||||||
@ -60,18 +59,18 @@ struct EncryptionHelperTests {
|
|||||||
masterKeyAccount: customAccount
|
masterKeyAccount: customAccount
|
||||||
)
|
)
|
||||||
|
|
||||||
await encryption.updateConfiguration(config)
|
await EncryptionHelper.shared.updateConfiguration(config)
|
||||||
|
|
||||||
let policy: SecurityPolicy.EncryptionPolicy = .chacha20Poly1305(
|
let policy: SecurityPolicy.EncryptionPolicy = .chacha20Poly1305(
|
||||||
keyDerivation: .hkdf()
|
keyDerivation: .hkdf()
|
||||||
)
|
)
|
||||||
|
|
||||||
let encrypted = try await encryption.encrypt(
|
let encrypted = try await EncryptionHelper.shared.encrypt(
|
||||||
payload,
|
payload,
|
||||||
keyName: keyName,
|
keyName: keyName,
|
||||||
policy: policy
|
policy: policy
|
||||||
)
|
)
|
||||||
let decrypted = try await encryption.decrypt(
|
let decrypted = try await EncryptionHelper.shared.decrypt(
|
||||||
encrypted,
|
encrypted,
|
||||||
keyName: keyName,
|
keyName: keyName,
|
||||||
policy: policy
|
policy: policy
|
||||||
@ -79,26 +78,29 @@ struct EncryptionHelperTests {
|
|||||||
|
|
||||||
#expect(decrypted == payload)
|
#expect(decrypted == payload)
|
||||||
|
|
||||||
// Cleanup mock keychain
|
// Cleanup keychain
|
||||||
try await keychain.deleteAll(service: customService)
|
try? await KeychainHelper.shared.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 encryption.registerKeyMaterialProvider(provider, for: source)
|
await EncryptionHelper.shared.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 encryption.encrypt(
|
let encrypted = try await EncryptionHelper.shared.encrypt(
|
||||||
payload,
|
payload,
|
||||||
keyName: keyName,
|
keyName: keyName,
|
||||||
policy: policy
|
policy: policy
|
||||||
)
|
)
|
||||||
let decrypted = try await encryption.decrypt(
|
let decrypted = try await EncryptionHelper.shared.decrypt(
|
||||||
encrypted,
|
encrypted,
|
||||||
keyName: keyName,
|
keyName: keyName,
|
||||||
policy: policy
|
policy: policy
|
||||||
@ -106,6 +108,10 @@ struct EncryptionHelperTests {
|
|||||||
|
|
||||||
#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 {
|
||||||
|
|||||||
@ -1,62 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
import CryptoKit
|
|
||||||
@testable import LocalData
|
|
||||||
|
|
||||||
@Suite struct EncryptionLogicTests {
|
|
||||||
private let encryption = EncryptionHelper(keychain: MockKeychainHelper())
|
|
||||||
private let payload = Data("secret".utf8)
|
|
||||||
private let keyName = "logic.test.key"
|
|
||||||
|
|
||||||
@Test func pbkdf2WithSingleIteration() async throws {
|
|
||||||
let policy: SecurityPolicy.EncryptionPolicy = .aes256(
|
|
||||||
keyDerivation: .pbkdf2(iterations: 1)
|
|
||||||
)
|
|
||||||
|
|
||||||
let encrypted = try await encryption.encrypt(payload, keyName: keyName, policy: policy)
|
|
||||||
let decrypted = try await encryption.decrypt(encrypted, keyName: keyName, policy: policy)
|
|
||||||
#expect(decrypted == payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func rawDataProviderIntegration() async throws {
|
|
||||||
struct RawProvider: KeyMaterialProviding {
|
|
||||||
let data: Data
|
|
||||||
func keyMaterial(for keyName: String) async throws -> Data { data }
|
|
||||||
}
|
|
||||||
|
|
||||||
let rawKey = Data(repeating: 1, count: 32)
|
|
||||||
let source = KeyMaterialSource(id: "raw.provider")
|
|
||||||
await encryption.registerKeyMaterialProvider(RawProvider(data: rawKey), for: source)
|
|
||||||
|
|
||||||
let policy = SecurityPolicy.EncryptionPolicy.external(source: source)
|
|
||||||
|
|
||||||
let encrypted = try await encryption.encrypt(payload, keyName: keyName, policy: policy)
|
|
||||||
let decrypted = try await encryption.decrypt(encrypted, keyName: keyName, policy: policy)
|
|
||||||
#expect(decrypted == payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func failedProviderThrows() async {
|
|
||||||
struct FailingProvider: KeyMaterialProviding {
|
|
||||||
func keyMaterial(for keyName: String) async throws -> Data {
|
|
||||||
throw StorageError.securityApplicationFailed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let source = KeyMaterialSource(id: "fail.provider")
|
|
||||||
await encryption.registerKeyMaterialProvider(FailingProvider(), for: source)
|
|
||||||
|
|
||||||
await #expect(throws: StorageError.securityApplicationFailed) {
|
|
||||||
try await encryption.encrypt(payload, keyName: keyName, policy: .external(source: source))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suite struct AccessControlLogicTests {
|
|
||||||
|
|
||||||
@Test func secAccessControlCreation() {
|
|
||||||
for control in KeychainAccessControl.allCases {
|
|
||||||
let result = control.accessControl(accessibility: .afterFirstUnlock)
|
|
||||||
#expect(result != nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import LocalData
|
|
||||||
|
|
||||||
@Suite struct FileStorageHelperExpansionTests {
|
|
||||||
private let helper: FileStorageHelper
|
|
||||||
|
|
||||||
init() {
|
|
||||||
let testBaseURL = FileManager.default.temporaryDirectory.appending(path: "FileStorageExpansionTests-\(UUID().uuidString)")
|
|
||||||
helper = FileStorageHelper(configuration: FileStorageConfiguration(baseURL: testBaseURL))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func subDirectoryLogic() async throws {
|
|
||||||
// 1. Update config with sub-directory
|
|
||||||
let subDir = "test-subdir"
|
|
||||||
await helper.updateConfiguration(FileStorageConfiguration(subDirectory: subDir))
|
|
||||||
|
|
||||||
defer {
|
|
||||||
Task {
|
|
||||||
await helper.updateConfiguration(.default)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let fileName = "subdir-file.txt"
|
|
||||||
let data = Data("subdir content".utf8)
|
|
||||||
|
|
||||||
try await helper.write(data, to: .caches, fileName: fileName)
|
|
||||||
|
|
||||||
// 2. Verify it exists
|
|
||||||
let exists = await helper.exists(in: .caches, fileName: fileName)
|
|
||||||
#expect(exists == true)
|
|
||||||
|
|
||||||
// 3. Verify it's actually in a sub-directory (internal check via list)
|
|
||||||
// This is a bit hard with the actor if we don't have the path,
|
|
||||||
// but it exercises the code.
|
|
||||||
|
|
||||||
try await helper.delete(from: .caches, fileName: fileName)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func fileProtectionLogic() async throws {
|
|
||||||
let fileName = "protected.txt"
|
|
||||||
let data = Data("secret".utf8)
|
|
||||||
|
|
||||||
// This exercises the 'useCompleteFileProtection' branch
|
|
||||||
try await helper.write(data, to: .documents, fileName: fileName, useCompleteFileProtection: true)
|
|
||||||
|
|
||||||
let retrieved = try await helper.read(from: .documents, fileName: fileName)
|
|
||||||
#expect(retrieved == data)
|
|
||||||
|
|
||||||
try await helper.delete(from: .documents, fileName: fileName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import LocalData
|
|
||||||
|
|
||||||
@Suite struct FileStorageHelperTests {
|
|
||||||
private let helper: FileStorageHelper
|
|
||||||
private let testBaseURL: URL
|
|
||||||
|
|
||||||
init() {
|
|
||||||
testBaseURL = FileManager.default.temporaryDirectory.appending(path: "LocalDataTests-\(UUID().uuidString)")
|
|
||||||
helper = FileStorageHelper(configuration: FileStorageConfiguration(baseURL: testBaseURL))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func documentsDirectoryRoundTrip() async throws {
|
|
||||||
let fileName = "test_file_\(UUID().uuidString).data"
|
|
||||||
let data = Data("file-content".utf8)
|
|
||||||
|
|
||||||
try await helper.write(data, to: .documents, fileName: fileName)
|
|
||||||
|
|
||||||
let exists = await helper.exists(in: .documents, fileName: fileName)
|
|
||||||
#expect(exists == true)
|
|
||||||
|
|
||||||
let retrieved = try await helper.read(from: .documents, fileName: fileName)
|
|
||||||
#expect(retrieved == data)
|
|
||||||
|
|
||||||
let size = try await helper.size(of: .documents, fileName: fileName)
|
|
||||||
#expect(size == Int64(data.count))
|
|
||||||
|
|
||||||
let list = try await helper.list(in: .documents)
|
|
||||||
#expect(list.contains(fileName))
|
|
||||||
|
|
||||||
try await helper.delete(from: .documents, fileName: fileName)
|
|
||||||
let afterDelete = await helper.exists(in: .documents, fileName: fileName)
|
|
||||||
#expect(afterDelete == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func customDirectoryCreation() async throws {
|
|
||||||
let tempDir = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
|
||||||
let fileName = "custom.txt"
|
|
||||||
let data = Data("custom".utf8)
|
|
||||||
|
|
||||||
try await helper.write(data, to: .custom(tempDir), fileName: fileName)
|
|
||||||
|
|
||||||
let retrieved = try await helper.read(from: .custom(tempDir), fileName: fileName)
|
|
||||||
#expect(retrieved == data)
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
try? FileManager.default.removeItem(at: tempDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func readNonExistentFileReturnsNil() async throws {
|
|
||||||
let result = try await helper.read(from: .caches, fileName: "nonexistent_\(UUID().uuidString)")
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,10 +2,8 @@ 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
|
||||||
|
|
||||||
@ -14,24 +12,22 @@ struct KeychainHelperTests {
|
|||||||
let data = Data("secret-password".utf8)
|
let data = Data("secret-password".utf8)
|
||||||
|
|
||||||
defer {
|
defer {
|
||||||
let k = keychain
|
try? KeychainHelper.shared.delete(service: testService, key: key)
|
||||||
let s = testService
|
|
||||||
Task { try? await k.delete(service: s, key: key) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try await keychain.set(
|
try await KeychainHelper.shared.set(
|
||||||
data,
|
data,
|
||||||
service: testService,
|
service: testService,
|
||||||
key: key,
|
key: key,
|
||||||
accessibility: .afterFirstUnlock
|
accessibility: .afterFirstUnlock
|
||||||
)
|
)
|
||||||
|
|
||||||
let retrieved = try await keychain.get(service: testService, key: key)
|
let retrieved = try await KeychainHelper.shared.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 keychain.get(
|
let result = try await KeychainHelper.shared.get(
|
||||||
service: testService,
|
service: testService,
|
||||||
key: "nonexistent.\(UUID().uuidString)"
|
key: "nonexistent.\(UUID().uuidString)"
|
||||||
)
|
)
|
||||||
@ -40,7 +36,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 keychain.delete(
|
try await KeychainHelper.shared.delete(
|
||||||
service: testService,
|
service: testService,
|
||||||
key: "nonexistent.\(UUID().uuidString)"
|
key: "nonexistent.\(UUID().uuidString)"
|
||||||
)
|
)
|
||||||
@ -51,22 +47,20 @@ struct KeychainHelperTests {
|
|||||||
let data = Data("test".utf8)
|
let data = Data("test".utf8)
|
||||||
|
|
||||||
defer {
|
defer {
|
||||||
let k = keychain
|
try? KeychainHelper.shared.delete(service: testService, key: key)
|
||||||
let s = testService
|
|
||||||
Task { try? await k.delete(service: s, key: key) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let beforeExists = try await keychain.exists(service: testService, key: key)
|
let beforeExists = try await KeychainHelper.shared.exists(service: testService, key: key)
|
||||||
#expect(beforeExists == false)
|
#expect(beforeExists == false)
|
||||||
|
|
||||||
try await keychain.set(
|
try await KeychainHelper.shared.set(
|
||||||
data,
|
data,
|
||||||
service: testService,
|
service: testService,
|
||||||
key: key,
|
key: key,
|
||||||
accessibility: .whenUnlocked
|
accessibility: .whenUnlocked
|
||||||
)
|
)
|
||||||
|
|
||||||
let afterExists = try await keychain.exists(service: testService, key: key)
|
let afterExists = try await KeychainHelper.shared.exists(service: testService, key: key)
|
||||||
#expect(afterExists == true)
|
#expect(afterExists == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,26 +70,24 @@ struct KeychainHelperTests {
|
|||||||
let updatedData = Data("updated".utf8)
|
let updatedData = Data("updated".utf8)
|
||||||
|
|
||||||
defer {
|
defer {
|
||||||
let k = keychain
|
try? KeychainHelper.shared.delete(service: testService, key: key)
|
||||||
let s = testService
|
|
||||||
Task { try? await k.delete(service: s, key: key) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try await keychain.set(
|
try await KeychainHelper.shared.set(
|
||||||
originalData,
|
originalData,
|
||||||
service: testService,
|
service: testService,
|
||||||
key: key,
|
key: key,
|
||||||
accessibility: .afterFirstUnlock
|
accessibility: .afterFirstUnlock
|
||||||
)
|
)
|
||||||
|
|
||||||
try await keychain.set(
|
try await KeychainHelper.shared.set(
|
||||||
updatedData,
|
updatedData,
|
||||||
service: testService,
|
service: testService,
|
||||||
key: key,
|
key: key,
|
||||||
accessibility: .afterFirstUnlock
|
accessibility: .afterFirstUnlock
|
||||||
)
|
)
|
||||||
|
|
||||||
let retrieved = try await keychain.get(service: testService, key: key)
|
let retrieved = try await KeychainHelper.shared.get(service: testService, key: key)
|
||||||
#expect(retrieved == updatedData)
|
#expect(retrieved == updatedData)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,7 +97,7 @@ struct KeychainHelperTests {
|
|||||||
|
|
||||||
// Create multiple items
|
// Create multiple items
|
||||||
for i in 0..<3 {
|
for i in 0..<3 {
|
||||||
try await keychain.set(
|
try await KeychainHelper.shared.set(
|
||||||
data,
|
data,
|
||||||
service: deleteAllService,
|
service: deleteAllService,
|
||||||
key: "key\(i)",
|
key: "key\(i)",
|
||||||
@ -114,15 +106,15 @@ struct KeychainHelperTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify they exist
|
// Verify they exist
|
||||||
let exists0 = try await keychain.exists(service: deleteAllService, key: "key0")
|
let exists0 = try await KeychainHelper.shared.exists(service: deleteAllService, key: "key0")
|
||||||
#expect(exists0 == true)
|
#expect(exists0 == true)
|
||||||
|
|
||||||
// Delete all
|
// Delete all
|
||||||
try await keychain.deleteAll(service: deleteAllService)
|
try await KeychainHelper.shared.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 keychain.exists(service: deleteAllService, key: "key\(i)")
|
let exists = try await KeychainHelper.shared.exists(service: deleteAllService, key: "key\(i)")
|
||||||
#expect(exists == false)
|
#expect(exists == false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -135,19 +127,17 @@ struct KeychainHelperTests {
|
|||||||
let data = Data("data-for-\(accessibility)".utf8)
|
let data = Data("data-for-\(accessibility)".utf8)
|
||||||
|
|
||||||
defer {
|
defer {
|
||||||
let k = keychain
|
try? KeychainHelper.shared.delete(service: testService, key: key)
|
||||||
let s = testService
|
|
||||||
Task { try? await k.delete(service: s, key: key) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try await keychain.set(
|
try await KeychainHelper.shared.set(
|
||||||
data,
|
data,
|
||||||
service: testService,
|
service: testService,
|
||||||
key: key,
|
key: key,
|
||||||
accessibility: accessibility
|
accessibility: accessibility
|
||||||
)
|
)
|
||||||
|
|
||||||
let retrieved = try await keychain.get(service: testService, key: key)
|
let retrieved = try await KeychainHelper.shared.get(service: testService, key: key)
|
||||||
#expect(retrieved == data)
|
#expect(retrieved == data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,10 +38,7 @@ 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 {
|
||||||
@ -53,14 +50,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 router.set(storedValue, for: key)
|
try await StorageRouter.shared.set(storedValue, for: key)
|
||||||
let fetched = try await router.get(key)
|
let fetched = try await StorageRouter.shared.get(key)
|
||||||
|
|
||||||
#expect(fetched == storedValue)
|
#expect(fetched == storedValue)
|
||||||
|
|
||||||
try await router.remove(key)
|
try await StorageRouter.shared.remove(key)
|
||||||
await #expect(throws: StorageError.notFound) {
|
await #expect(throws: StorageError.notFound) {
|
||||||
_ = try await router.get(key)
|
_ = try await StorageRouter.shared.get(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,14 +72,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 router.set(storedValue, for: key)
|
try await StorageRouter.shared.set(storedValue, for: key)
|
||||||
let fetched = try await router.get(key)
|
let fetched = try await StorageRouter.shared.get(key)
|
||||||
|
|
||||||
#expect(fetched == storedValue)
|
#expect(fetched == storedValue)
|
||||||
|
|
||||||
try await router.remove(key)
|
try await StorageRouter.shared.remove(key)
|
||||||
await #expect(throws: StorageError.notFound) {
|
await #expect(throws: StorageError.notFound) {
|
||||||
_ = try await router.get(key)
|
_ = try await StorageRouter.shared.get(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,120 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import LocalData
|
|
||||||
|
|
||||||
private struct LegacyKey: StorageKey {
|
|
||||||
typealias Value = String
|
|
||||||
let name: String
|
|
||||||
let domain: StorageDomain
|
|
||||||
let security: SecurityPolicy = .none
|
|
||||||
let serializer: Serializer<String> = .json
|
|
||||||
let owner: String = "Legacy"
|
|
||||||
let description: String = "Legacy key"
|
|
||||||
let availability: PlatformAvailability = .all
|
|
||||||
let syncPolicy: SyncPolicy = .never
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ModernKey: StorageKey {
|
|
||||||
typealias Value = String
|
|
||||||
let name: String
|
|
||||||
let domain: StorageDomain
|
|
||||||
let security: SecurityPolicy = .keychain(accessibility: .afterFirstUnlock, accessControl: .none)
|
|
||||||
let serializer: Serializer<String> = .json
|
|
||||||
let owner: String = "Modern"
|
|
||||||
let description: String = "Modern key"
|
|
||||||
let availability: PlatformAvailability = .all
|
|
||||||
let syncPolicy: SyncPolicy = .never
|
|
||||||
let migrationSources: [AnyStorageKey]
|
|
||||||
|
|
||||||
init(name: String, domain: StorageDomain, migrationSources: [AnyStorageKey]) {
|
|
||||||
self.name = name
|
|
||||||
self.domain = domain
|
|
||||||
self.migrationSources = migrationSources
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suite(.serialized)
|
|
||||||
struct MigrationTests {
|
|
||||||
private let router: StorageRouter
|
|
||||||
|
|
||||||
init() {
|
|
||||||
let testBaseURL = FileManager.default.temporaryDirectory.appending(path: "MigrationTests-\(UUID().uuidString)")
|
|
||||||
router = StorageRouter(
|
|
||||||
keychain: MockKeychainHelper(),
|
|
||||||
encryption: EncryptionHelper(keychain: MockKeychainHelper()),
|
|
||||||
file: FileStorageHelper(configuration: FileStorageConfiguration(baseURL: testBaseURL)),
|
|
||||||
defaults: UserDefaultsHelper(defaults: UserDefaults(suiteName: "MigrationTests-\(UUID().uuidString)")!)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func automaticMigrationFromUserDefaultsToKeychain() async throws {
|
|
||||||
let legacyName = "legacy.user.name"
|
|
||||||
let modernName = "user.name"
|
|
||||||
let suiteName = "MigrationTests.\(UUID().uuidString)"
|
|
||||||
let secretValue = "Matt Bruce"
|
|
||||||
|
|
||||||
defer {
|
|
||||||
UserDefaults().removePersistentDomain(forName: suiteName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Setup legacy data manually in UserDefaults
|
|
||||||
let legacyKey = LegacyKey(name: legacyName, domain: .userDefaults(suite: suiteName))
|
|
||||||
try await router.set(secretValue, for: legacyKey)
|
|
||||||
|
|
||||||
// Verify it exists in legacy location
|
|
||||||
let existsInLegacy = try await router.exists(legacyKey)
|
|
||||||
#expect(existsInLegacy == true)
|
|
||||||
|
|
||||||
// 2. Setup modern key with legacy source
|
|
||||||
let modernKey = ModernKey(
|
|
||||||
name: modernName,
|
|
||||||
domain: .keychain(service: "test.migration"),
|
|
||||||
migrationSources: [.key(legacyKey)]
|
|
||||||
)
|
|
||||||
|
|
||||||
// 3. Trigger automatic migration via GET
|
|
||||||
let migratedValue = try await router.get(modernKey)
|
|
||||||
#expect(migratedValue == secretValue)
|
|
||||||
|
|
||||||
// 4. Verify data moved
|
|
||||||
// Modern should now exist
|
|
||||||
let existsInModern = try await router.exists(modernKey)
|
|
||||||
#expect(existsInModern == true)
|
|
||||||
|
|
||||||
// Legacy should be gone
|
|
||||||
let existsInLegacyAfter = try await router.exists(legacyKey)
|
|
||||||
#expect(existsInLegacyAfter == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func manualMigrationSweep() async throws {
|
|
||||||
let legacyName = "legacy.manual.key"
|
|
||||||
let modernName = "modern.manual.key"
|
|
||||||
let suiteName = "MigrationTests.Manual.\(UUID().uuidString)"
|
|
||||||
let value = "Manual Data"
|
|
||||||
|
|
||||||
defer {
|
|
||||||
UserDefaults().removePersistentDomain(forName: suiteName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Setup legacy data
|
|
||||||
let legacyKey = LegacyKey(name: legacyName, domain: .userDefaults(suite: suiteName))
|
|
||||||
try await router.set(value, for: legacyKey)
|
|
||||||
|
|
||||||
// 2. Setup modern key
|
|
||||||
let modernKey = ModernKey(
|
|
||||||
name: modernName,
|
|
||||||
domain: .userDefaults(suite: suiteName),
|
|
||||||
migrationSources: [.key(legacyKey)]
|
|
||||||
)
|
|
||||||
|
|
||||||
// 3. Trigger manual migration
|
|
||||||
try await router.migrate(for: modernKey)
|
|
||||||
|
|
||||||
// 4. Verify
|
|
||||||
let hasModern = try await router.exists(modernKey)
|
|
||||||
#expect(hasModern == true)
|
|
||||||
|
|
||||||
let hasLegacy = try await router.exists(legacyKey)
|
|
||||||
#expect(hasLegacy == false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
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)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,116 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import LocalData
|
|
||||||
|
|
||||||
@Suite struct SerializerTests {
|
|
||||||
|
|
||||||
@Test func jsonSerializerRoundTrip() throws {
|
|
||||||
let serializer: Serializer<String> = .json
|
|
||||||
let value = "test-string"
|
|
||||||
let data = try serializer.encode(value)
|
|
||||||
let decoded = try serializer.decode(data)
|
|
||||||
#expect(decoded == value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func plistSerializerRoundTrip() throws {
|
|
||||||
let serializer: Serializer<[String: Int]> = .plist
|
|
||||||
let value = ["key": 42]
|
|
||||||
let data = try serializer.encode(value)
|
|
||||||
let decoded = try serializer.decode(data)
|
|
||||||
#expect(decoded == value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func dataSerializerPassThrough() throws {
|
|
||||||
let serializer: Serializer<Data> = .data
|
|
||||||
let value = Data("raw-data".utf8)
|
|
||||||
let data = try serializer.encode(value)
|
|
||||||
#expect(data == value)
|
|
||||||
let decoded = try serializer.decode(data)
|
|
||||||
#expect(decoded == value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func customSerializer() throws {
|
|
||||||
let serializer = Serializer<Int>(
|
|
||||||
encode: { Data("\($0)".utf8) },
|
|
||||||
decode: {
|
|
||||||
guard let s = String(data: $0, encoding: .utf8), let i = Int(s) else {
|
|
||||||
throw StorageError.deserializationFailed
|
|
||||||
}
|
|
||||||
return i
|
|
||||||
},
|
|
||||||
name: "int-string"
|
|
||||||
)
|
|
||||||
|
|
||||||
let value = 12345
|
|
||||||
let data = try serializer.encode(value)
|
|
||||||
#expect(String(data: data, encoding: .utf8) == "12345")
|
|
||||||
let decoded = try serializer.decode(data)
|
|
||||||
#expect(decoded == value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suite struct ConfigurationTests {
|
|
||||||
|
|
||||||
@Test func storageConfigurationDefaults() {
|
|
||||||
let config = StorageConfiguration.default
|
|
||||||
#expect(config.defaultKeychainService == nil)
|
|
||||||
#expect(config.defaultAppGroupIdentifier == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func encryptionConfigurationDefaults() {
|
|
||||||
let config = EncryptionConfiguration.default
|
|
||||||
#expect(config.masterKeyService == "LocalData")
|
|
||||||
#expect(config.masterKeyAccount == "MasterKey")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func syncConfigurationDefaults() {
|
|
||||||
let config = SyncConfiguration.default
|
|
||||||
#expect(config.maxAutoSyncSize == 100_000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suite struct EnumPropertyTests {
|
|
||||||
|
|
||||||
@Test func keychainAccessibilityProperties() {
|
|
||||||
for level in KeychainAccessibility.allCases {
|
|
||||||
// Verify cfString is accessible (critical for security framework)
|
|
||||||
let _ = level.cfString
|
|
||||||
#expect(!level.displayName.isEmpty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func keychainAccessControlProperties() {
|
|
||||||
for control in KeychainAccessControl.allCases {
|
|
||||||
#expect(!control.displayName.isEmpty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func platformAvailabilityExists() {
|
|
||||||
// Just verify all cases exist as defined
|
|
||||||
let cases: [PlatformAvailability] = [.all, .phoneOnly, .watchOnly, .phoneWithWatchSync]
|
|
||||||
#expect(cases.count == 4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suite struct ErrorLogicTests {
|
|
||||||
|
|
||||||
@Test func storageErrorEquality() {
|
|
||||||
#expect(StorageError.notFound == .notFound)
|
|
||||||
#expect(StorageError.serializationFailed != .deserializationFailed)
|
|
||||||
#expect(StorageError.keychainError(1) == .keychainError(1))
|
|
||||||
#expect(StorageError.keychainError(1) != .keychainError(2))
|
|
||||||
#expect(StorageError.unregisteredKey("a") == .unregisteredKey("a"))
|
|
||||||
#expect(StorageError.unregisteredKey("a") != .unregisteredKey("b"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suite struct DirectoryLogicTests {
|
|
||||||
|
|
||||||
@Test func fileDirectoryUrls() {
|
|
||||||
#expect(!FileDirectory.documents.url().path.isEmpty)
|
|
||||||
#expect(!FileDirectory.caches.url().path.isEmpty)
|
|
||||||
|
|
||||||
let customUrl = URL(fileURLWithPath: "/tmp/custom")
|
|
||||||
#expect(FileDirectory.custom(customUrl).url() == customUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import LocalData
|
|
||||||
|
|
||||||
@Suite struct RouterConfigurationTests {
|
|
||||||
private let router = StorageRouter(keychain: MockKeychainHelper())
|
|
||||||
|
|
||||||
@Test func updateConfigurations() async {
|
|
||||||
// Exercise the configuration update paths in StorageRouter
|
|
||||||
await router.updateStorageConfiguration(.default)
|
|
||||||
await router.updateEncryptionConfiguration(.default)
|
|
||||||
await router.updateSyncConfiguration(.default)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func registerKeyMaterialProvider() async throws {
|
|
||||||
struct Provider: KeyMaterialProviding {
|
|
||||||
func keyMaterial(for keyName: String) async throws -> Data { Data() }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exercise registration path
|
|
||||||
await router.registerKeyMaterialProvider(Provider(), for: KeyMaterialSource(id: "test.source"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,117 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import LocalData
|
|
||||||
|
|
||||||
@Suite struct RouterDomainTests {
|
|
||||||
private let router: StorageRouter
|
|
||||||
private let mockKeychain = MockKeychainHelper()
|
|
||||||
|
|
||||||
init() {
|
|
||||||
let testBaseURL = FileManager.default.temporaryDirectory.appending(path: "RouterDomainTests-\(UUID().uuidString)")
|
|
||||||
router = StorageRouter(
|
|
||||||
keychain: mockKeychain,
|
|
||||||
encryption: EncryptionHelper(keychain: mockKeychain),
|
|
||||||
file: FileStorageHelper(configuration: FileStorageConfiguration(baseURL: testBaseURL)),
|
|
||||||
defaults: UserDefaultsHelper(defaults: UserDefaults(suiteName: "RouterDomainTests-\(UUID().uuidString)")!)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct DomainKey: StorageKey {
|
|
||||||
typealias Value = String
|
|
||||||
let name: String
|
|
||||||
let domain: StorageDomain
|
|
||||||
let security: SecurityPolicy
|
|
||||||
let serializer: Serializer<String> = .json
|
|
||||||
let owner: String = "DomainTests"
|
|
||||||
let description: String = "Domain test key"
|
|
||||||
let availability: PlatformAvailability = .all
|
|
||||||
let syncPolicy: SyncPolicy = .never
|
|
||||||
|
|
||||||
init(name: String, domain: StorageDomain, security: SecurityPolicy = .none) {
|
|
||||||
self.name = name
|
|
||||||
self.domain = domain
|
|
||||||
self.security = security
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func domainUserDefaults() async throws {
|
|
||||||
let key = DomainKey(name: "defaults.key", domain: .userDefaults(suite: nil))
|
|
||||||
try await router.set("value", for: key)
|
|
||||||
#expect(try await router.get(key) == "value")
|
|
||||||
try await router.remove(key)
|
|
||||||
#expect(await (try? router.exists(key)) == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func domainAppGroupUserDefaults() async throws {
|
|
||||||
// We use a mock configuration to avoid requiring a real app group
|
|
||||||
await router.updateStorageConfiguration(StorageConfiguration(defaultAppGroupIdentifier: "group.test"))
|
|
||||||
|
|
||||||
let key = DomainKey(name: "appgroup.defaults.key", domain: .appGroupUserDefaults(identifier: "group.test"))
|
|
||||||
try await router.set("value", for: key)
|
|
||||||
#expect(try await router.get(key) == "value")
|
|
||||||
try await router.remove(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func domainKeychain() async throws {
|
|
||||||
let key = DomainKey(
|
|
||||||
name: "keychain.key",
|
|
||||||
domain: .keychain(service: "test"),
|
|
||||||
security: .keychain(accessibility: .afterFirstUnlock, accessControl: .none)
|
|
||||||
)
|
|
||||||
try await router.set("value", for: key)
|
|
||||||
#expect(try await router.get(key) == "value")
|
|
||||||
try await router.remove(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func domainFileSystem() async throws {
|
|
||||||
let key = DomainKey(name: "file.key", domain: .fileSystem(directory: .documents))
|
|
||||||
try await router.set("value", for: key)
|
|
||||||
#expect(try await router.get(key) == "value")
|
|
||||||
try await router.remove(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func domainEncryptedFileSystem() async throws {
|
|
||||||
let key = DomainKey(name: "encfile.key", domain: .encryptedFileSystem(directory: .documents))
|
|
||||||
try await router.set("value", for: key)
|
|
||||||
#expect(try await router.get(key) == "value")
|
|
||||||
try await router.remove(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func domainAppGroupFileSystem() async throws {
|
|
||||||
// App blocks usually fail or return nil in tests, but we exercise the path
|
|
||||||
await router.updateStorageConfiguration(StorageConfiguration(defaultAppGroupIdentifier: "group.test"))
|
|
||||||
let key = DomainKey(name: "appgroup.file.key", domain: .appGroupFileSystem(identifier: "group.test", directory: .documents))
|
|
||||||
|
|
||||||
do {
|
|
||||||
try await router.set("value", for: key)
|
|
||||||
#expect(try await router.get(key) == "value")
|
|
||||||
try await router.remove(key)
|
|
||||||
} catch StorageError.invalidAppGroupIdentifier {
|
|
||||||
// Path covered
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func resolutionFailureService() async throws {
|
|
||||||
// Clear default service
|
|
||||||
await router.updateStorageConfiguration(StorageConfiguration(defaultKeychainService: nil))
|
|
||||||
let key = DomainKey(
|
|
||||||
name: "bad.service.key",
|
|
||||||
domain: .keychain(service: nil),
|
|
||||||
security: .keychain(accessibility: .afterFirstUnlock, accessControl: .none)
|
|
||||||
)
|
|
||||||
|
|
||||||
await #expect(throws: StorageError.keychainError(errSecBadReq)) {
|
|
||||||
try await router.set("value", for: key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func resolutionFailureIdentifier() async throws {
|
|
||||||
// Clear default identifier
|
|
||||||
await router.updateStorageConfiguration(StorageConfiguration(defaultAppGroupIdentifier: nil))
|
|
||||||
let key = DomainKey(name: "bad.id.key", domain: .appGroupUserDefaults(identifier: nil))
|
|
||||||
|
|
||||||
await #expect(throws: StorageError.invalidAppGroupIdentifier("none")) {
|
|
||||||
try await router.set("value", for: key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import LocalData
|
|
||||||
|
|
||||||
private struct MockKey: StorageKey {
|
|
||||||
typealias Value = String
|
|
||||||
let name: String
|
|
||||||
let domain: StorageDomain
|
|
||||||
let security: SecurityPolicy = .none
|
|
||||||
let serializer: Serializer<String> = .json
|
|
||||||
let owner: String = "ErrorTests"
|
|
||||||
let description: String = "Test key"
|
|
||||||
let availability: PlatformAvailability = .all
|
|
||||||
let syncPolicy: SyncPolicy = .never
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct PartialCatalog: StorageKeyCatalog {
|
|
||||||
static var allKeys: [AnyStorageKey] {
|
|
||||||
[.key(MockKey(name: "registered.key", domain: .userDefaults(suite: nil)))]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suite(.serialized)
|
|
||||||
struct RouterErrorTests {
|
|
||||||
private let router: StorageRouter
|
|
||||||
|
|
||||||
init() {
|
|
||||||
let testBaseURL = FileManager.default.temporaryDirectory.appending(path: "RouterErrorTests-\(UUID().uuidString)")
|
|
||||||
router = StorageRouter(
|
|
||||||
keychain: MockKeychainHelper(),
|
|
||||||
encryption: EncryptionHelper(keychain: MockKeychainHelper()),
|
|
||||||
file: FileStorageHelper(configuration: FileStorageConfiguration(baseURL: testBaseURL)),
|
|
||||||
defaults: UserDefaultsHelper(defaults: UserDefaults(suiteName: "RouterErrorTests-\(UUID().uuidString)")!)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func unregisteredKeyThrows() async throws {
|
|
||||||
try await router.registerCatalog(PartialCatalog.self)
|
|
||||||
|
|
||||||
let badKey = MockKey(name: "unregistered.key", domain: .userDefaults(suite: nil))
|
|
||||||
|
|
||||||
await #expect(throws: StorageError.unregisteredKey("unregistered.key")) {
|
|
||||||
try await router.set("value", for: badKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func resolveIdentifierThrowsIfNoDefault() async {
|
|
||||||
// Clear default app group ID
|
|
||||||
await router.updateStorageConfiguration(StorageConfiguration(
|
|
||||||
defaultKeychainService: "test",
|
|
||||||
defaultAppGroupIdentifier: nil
|
|
||||||
))
|
|
||||||
|
|
||||||
let appGroupKey = MockKey(name: "appgroup.key", domain: .appGroupUserDefaults(identifier: nil))
|
|
||||||
|
|
||||||
await #expect(throws: StorageError.invalidAppGroupIdentifier("none")) {
|
|
||||||
try await router.set("value", for: appGroupKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func resolveServiceThrowsIfNoDefault() async {
|
|
||||||
// Clear default keychain service
|
|
||||||
await router.updateStorageConfiguration(StorageConfiguration(
|
|
||||||
defaultKeychainService: nil,
|
|
||||||
defaultAppGroupIdentifier: "test"
|
|
||||||
))
|
|
||||||
|
|
||||||
let _ = MockKey(name: "keychain.key", domain: .keychain(service: nil))
|
|
||||||
|
|
||||||
// Note: Keychain security policy must match keychain domain in descriptor
|
|
||||||
// but descriptor is usually created from key.
|
|
||||||
// MockKey by default has .none security, which might cause applySecurity to return early
|
|
||||||
// BUT the store() method for .keychain domain checks security.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,92 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
import Security
|
|
||||||
@testable import LocalData
|
|
||||||
|
|
||||||
@Suite struct RouterSecurityTests {
|
|
||||||
private let router: StorageRouter
|
|
||||||
private let mockKeychain = MockKeychainHelper()
|
|
||||||
|
|
||||||
init() {
|
|
||||||
let testBaseURL = FileManager.default.temporaryDirectory.appending(path: "RouterSecurityTests-\(UUID().uuidString)")
|
|
||||||
router = StorageRouter(
|
|
||||||
keychain: mockKeychain,
|
|
||||||
encryption: EncryptionHelper(keychain: mockKeychain),
|
|
||||||
file: FileStorageHelper(configuration: FileStorageConfiguration(baseURL: testBaseURL)),
|
|
||||||
defaults: UserDefaultsHelper(defaults: UserDefaults(suiteName: "RouterSecurityTests-\(UUID().uuidString)")!)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SecurityKey: StorageKey {
|
|
||||||
typealias Value = String
|
|
||||||
let name: String
|
|
||||||
let domain: StorageDomain
|
|
||||||
let security: SecurityPolicy
|
|
||||||
let serializer: Serializer<String> = .json
|
|
||||||
let owner: String = "SecurityTests"
|
|
||||||
let description: String = "Security test key"
|
|
||||||
let availability: PlatformAvailability = .all
|
|
||||||
let syncPolicy: SyncPolicy = .never
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func applySecurityNone() async throws {
|
|
||||||
let key = SecurityKey(name: "none.key", domain: .userDefaults(suite: nil), security: .none)
|
|
||||||
let value = "test-value"
|
|
||||||
|
|
||||||
try await router.set(value, for: key)
|
|
||||||
let retrieved: String = try await router.get(key)
|
|
||||||
#expect(retrieved == value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func applySecurityEncryptedAES() async throws {
|
|
||||||
let key = SecurityKey(
|
|
||||||
name: "aes.key",
|
|
||||||
domain: .userDefaults(suite: nil),
|
|
||||||
security: .encrypted(.aes256(keyDerivation: .hkdf()))
|
|
||||||
)
|
|
||||||
let value = "aes-secret"
|
|
||||||
|
|
||||||
try await router.set(value, for: key)
|
|
||||||
let retrieved: String = try await router.get(key)
|
|
||||||
#expect(retrieved == value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func applySecurityEncryptedChaCha() async throws {
|
|
||||||
let key = SecurityKey(
|
|
||||||
name: "chacha.key",
|
|
||||||
domain: .userDefaults(suite: nil),
|
|
||||||
security: .encrypted(.chacha20Poly1305(keyDerivation: .hkdf()))
|
|
||||||
)
|
|
||||||
let value = "chacha-secret"
|
|
||||||
|
|
||||||
try await router.set(value, for: key)
|
|
||||||
let retrieved: String = try await router.get(key)
|
|
||||||
#expect(retrieved == value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func applySecurityKeychain() async throws {
|
|
||||||
let key = SecurityKey(
|
|
||||||
name: "keychain.key",
|
|
||||||
domain: .keychain(service: "test-service"),
|
|
||||||
security: .keychain(accessibility: .afterFirstUnlock, accessControl: .none)
|
|
||||||
)
|
|
||||||
let value = "keychain-secret"
|
|
||||||
|
|
||||||
try await router.set(value, for: key)
|
|
||||||
let retrieved: String = try await router.get(key)
|
|
||||||
#expect(retrieved == value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func applySecurityPBKDF2() async throws {
|
|
||||||
let key = SecurityKey(
|
|
||||||
name: "pbkdf2.key",
|
|
||||||
domain: .userDefaults(suite: nil),
|
|
||||||
security: .encrypted(.aes256(keyDerivation: .pbkdf2()))
|
|
||||||
)
|
|
||||||
let value = "pbkdf2-secret"
|
|
||||||
|
|
||||||
try await router.set(value, for: key)
|
|
||||||
let retrieved: String = try await router.get(key)
|
|
||||||
#expect(retrieved == value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -46,19 +46,9 @@ private struct EmptyCatalog: StorageKeyCatalog {
|
|||||||
static var allKeys: [AnyStorageKey] { [] }
|
static var allKeys: [AnyStorageKey] { [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct MissingDescriptionCatalog: StorageKeyCatalog {
|
|
||||||
static var allKeys: [AnyStorageKey] {
|
|
||||||
[
|
|
||||||
.key(TestCatalogKey(name: "missing.desc", description: " "))
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
||||||
@ -99,22 +89,7 @@ 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 router.registerCatalog(DuplicateNameCatalog.self)
|
try await StorageRouter.shared.registerCatalog(DuplicateNameCatalog.self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func catalogRegistrationDetectsMissingDescriptions() async {
|
|
||||||
// Attempting to register a catalog with missing descriptions should throw
|
|
||||||
await #expect(throws: StorageError.self) {
|
|
||||||
try await router.registerCatalog(MissingDescriptionCatalog.self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func migrateAllRegisteredKeysInvokesMigrationOnKeys() async throws {
|
|
||||||
// This test verifies that migrateAllRegisteredKeys calling logic works.
|
|
||||||
try await router.registerCatalog(ValidCatalog.self)
|
|
||||||
|
|
||||||
// No error should occur
|
|
||||||
try await router.migrateAllRegisteredKeys()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import LocalData
|
|
||||||
|
|
||||||
@Suite struct StorageKeyDefaultsTests {
|
|
||||||
|
|
||||||
private struct MinimalKey: StorageKey {
|
|
||||||
typealias Value = Int
|
|
||||||
let name: String = "minimal.key"
|
|
||||||
let domain: StorageDomain = .userDefaults(suite: nil)
|
|
||||||
let serializer: Serializer<Int> = .json
|
|
||||||
let owner: String = "Test"
|
|
||||||
let description: String = "Test"
|
|
||||||
let availability: PlatformAvailability = .all
|
|
||||||
let syncPolicy: SyncPolicy = .never
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func defaultSecurityPolicyIsRecommended() {
|
|
||||||
let key = MinimalKey()
|
|
||||||
// This exercises the default implementation in StorageKey+Defaults.swift
|
|
||||||
#expect(key.security == .recommended)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
import WatchConnectivity
|
|
||||||
@testable import LocalData
|
|
||||||
|
|
||||||
@Suite struct SyncDelegateTests {
|
|
||||||
|
|
||||||
@Test func delegateProxyActivationCallbacks() {
|
|
||||||
let proxy = SessionDelegateProxy.shared
|
|
||||||
let session = WCSession.default
|
|
||||||
|
|
||||||
// Exercise activation completion (success)
|
|
||||||
proxy.session(session, activationDidCompleteWith: .activated, error: nil)
|
|
||||||
|
|
||||||
// Exercise activation completion (error)
|
|
||||||
let error = NSError(domain: "test", code: 1, userInfo: nil)
|
|
||||||
proxy.session(session, activationDidCompleteWith: .notActivated, error: error)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func delegateProxyContextReceived() async throws {
|
|
||||||
let proxy = SessionDelegateProxy.shared
|
|
||||||
let session = WCSession.default
|
|
||||||
let context: [String: Any] = [
|
|
||||||
"test.sync.key": Data("sync-data".utf8)
|
|
||||||
]
|
|
||||||
|
|
||||||
// This triggers SyncHelper.handleReceivedContext
|
|
||||||
proxy.session(session, didReceiveApplicationContext: context)
|
|
||||||
|
|
||||||
// Wait a bit for the Task to start/finish
|
|
||||||
try await Task.sleep(for: .milliseconds(100))
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
@Test func delegateProxyiOSCallbacks() {
|
|
||||||
let proxy = SessionDelegateProxy.shared
|
|
||||||
let session = WCSession.default
|
|
||||||
|
|
||||||
proxy.sessionDidBecomeInactive(session)
|
|
||||||
proxy.sessionDidDeactivate(session)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import LocalData
|
|
||||||
|
|
||||||
@Suite struct SyncHelperExpansionTests {
|
|
||||||
private let helper = SyncHelper()
|
|
||||||
|
|
||||||
@Test func handleReceivedContextProcessing() async throws {
|
|
||||||
let key = "received.key"
|
|
||||||
let value = Data("received.data".utf8)
|
|
||||||
let context: [String: Any] = [key: value, "invalid": "not-data"]
|
|
||||||
|
|
||||||
// This exercises the loop and the data casting
|
|
||||||
await helper.handleReceivedContext(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func manualSyncExercisesPerformSync() async throws {
|
|
||||||
let data = Data("manual-sync-data".utf8)
|
|
||||||
|
|
||||||
// This will likely return early in most test environments due to WCSession state,
|
|
||||||
// but it exercises the public entry point.
|
|
||||||
try await helper.manualSync(data: data, keyName: "manual.key")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func currentContextReturnsEmptyIfNotSupported() async throws {
|
|
||||||
// This exercises the guard in currentContext()
|
|
||||||
let context = await helper.currentContext()
|
|
||||||
// WCSession might or might not be supported, but this hits the line
|
|
||||||
let _ = context.count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import LocalData
|
|
||||||
|
|
||||||
@Suite struct SyncHelperTests {
|
|
||||||
private let helper = SyncHelper()
|
|
||||||
|
|
||||||
@Test func syncPolicyNeverDoesNothing() async throws {
|
|
||||||
// This should return early without doing anything
|
|
||||||
try await helper.syncIfNeeded(
|
|
||||||
data: Data("test".utf8),
|
|
||||||
keyName: "test.key",
|
|
||||||
availability: .all,
|
|
||||||
syncPolicy: .never
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func syncPolicyAutomaticSmallThrowsIfTooLarge() async throws {
|
|
||||||
let config = SyncConfiguration(maxAutoSyncSize: 10)
|
|
||||||
let localHelper = SyncHelper(configuration: config)
|
|
||||||
|
|
||||||
let largeData = Data(repeating: 0, count: 100)
|
|
||||||
|
|
||||||
await #expect(throws: StorageError.dataTooLargeForSync) {
|
|
||||||
try await localHelper.syncIfNeeded(
|
|
||||||
data: largeData,
|
|
||||||
keyName: "too.large",
|
|
||||||
availability: .all,
|
|
||||||
syncPolicy: .automaticSmall
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func syncPolicyAutomaticSmallPassesIfSmall() async throws {
|
|
||||||
let config = SyncConfiguration(maxAutoSyncSize: 1000)
|
|
||||||
await helper.updateConfiguration(config)
|
|
||||||
|
|
||||||
let smallData = Data(repeating: 0, count: 10)
|
|
||||||
|
|
||||||
// This should not throw StorageError.dataTooLargeForSync
|
|
||||||
// It might return early due to WCSession not being supported/active in tests,
|
|
||||||
// which is fine for covering the policy check logic.
|
|
||||||
try await helper.syncIfNeeded(
|
|
||||||
data: smallData,
|
|
||||||
keyName: "small.key",
|
|
||||||
availability: .all,
|
|
||||||
syncPolicy: .automaticSmall
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func syncAvailabilityChecksPlatform() async {
|
|
||||||
let available = await helper.isSyncAvailable()
|
|
||||||
// In a simulator environment without a paired watch, this is likely false
|
|
||||||
// But we are exercising the code path.
|
|
||||||
#expect(available == available)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import LocalData
|
|
||||||
|
|
||||||
@Suite(.serialized)
|
|
||||||
struct SyncIntegrationTests {
|
|
||||||
private let router: StorageRouter
|
|
||||||
|
|
||||||
init() {
|
|
||||||
let testBaseURL = FileManager.default.temporaryDirectory.appending(path: "SyncIntegrationTests-\(UUID().uuidString)")
|
|
||||||
router = StorageRouter(
|
|
||||||
keychain: MockKeychainHelper(),
|
|
||||||
encryption: EncryptionHelper(keychain: MockKeychainHelper()),
|
|
||||||
file: FileStorageHelper(configuration: FileStorageConfiguration(baseURL: testBaseURL)),
|
|
||||||
defaults: UserDefaultsHelper(defaults: UserDefaults(suiteName: "SyncIntegrationTests-\(UUID().uuidString)")!)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SyncKey: StorageKey {
|
|
||||||
typealias Value = String
|
|
||||||
let name: String
|
|
||||||
let domain: StorageDomain = .userDefaults(suite: nil)
|
|
||||||
let security: SecurityPolicy = .none
|
|
||||||
let serializer: Serializer<String> = .json
|
|
||||||
let owner: String = "SyncTests"
|
|
||||||
let description: String = "Sync key"
|
|
||||||
let availability: PlatformAvailability = .all
|
|
||||||
let syncPolicy: SyncPolicy = .automaticSmall
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SyncCatalog: StorageKeyCatalog {
|
|
||||||
static var allKeys: [AnyStorageKey] {
|
|
||||||
[.key(SyncKey(name: "sync.test.key"))]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func updateFromSyncStoresDataLocally() async throws {
|
|
||||||
// 1. Register catalog so router knows about the key
|
|
||||||
try await router.registerCatalog(SyncCatalog.self)
|
|
||||||
|
|
||||||
let keyName = "sync.test.key"
|
|
||||||
let expectedValue = "Updated from Watch"
|
|
||||||
let data = try JSONEncoder().encode(expectedValue)
|
|
||||||
|
|
||||||
// 2. Simulate incoming sync
|
|
||||||
try await router.updateFromSync(keyName: keyName, data: data)
|
|
||||||
|
|
||||||
// 3. Verify it was stored in the local domain
|
|
||||||
let retrieved: String? = try await router.get(SyncKey(name: keyName))
|
|
||||||
#expect(retrieved == expectedValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func updateFromSyncIgnoresUnregisteredKeys() async throws {
|
|
||||||
let keyName = "unregistered.sync.key"
|
|
||||||
let data = Data("some data".utf8)
|
|
||||||
|
|
||||||
// This should not throw, just log/ignore
|
|
||||||
try await router.updateFromSync(keyName: keyName, data: data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import LocalData
|
|
||||||
|
|
||||||
@Suite struct UserDefaultsHelperTests {
|
|
||||||
private let helper = UserDefaultsHelper.shared
|
|
||||||
|
|
||||||
@Test func standardUserDefaultsRoundTrip() async throws {
|
|
||||||
let key = "test.standard.key"
|
|
||||||
let data = Data("standard-value".utf8)
|
|
||||||
|
|
||||||
try await helper.set(data, forKey: key, suite: nil)
|
|
||||||
let retrieved = try await helper.get(forKey: key, suite: nil)
|
|
||||||
#expect(retrieved == data)
|
|
||||||
|
|
||||||
let exists = try await helper.exists(forKey: key, suite: nil)
|
|
||||||
#expect(exists == true)
|
|
||||||
|
|
||||||
try await helper.remove(forKey: key, suite: nil)
|
|
||||||
let afterDelete = try await helper.get(forKey: key, suite: nil)
|
|
||||||
#expect(afterDelete == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func suiteUserDefaultsRoundTrip() async throws {
|
|
||||||
let suiteName = "com.test.suite.\(UUID().uuidString)"
|
|
||||||
let key = "test.suite.key"
|
|
||||||
let data = Data("suite-value".utf8)
|
|
||||||
|
|
||||||
try await helper.set(data, forKey: key, suite: suiteName)
|
|
||||||
let retrieved = try await helper.get(forKey: key, suite: suiteName)
|
|
||||||
#expect(retrieved == data)
|
|
||||||
|
|
||||||
let keys = try await helper.allKeys(suite: suiteName)
|
|
||||||
#expect(keys.contains(key))
|
|
||||||
|
|
||||||
try await helper.remove(forKey: key, suite: suiteName)
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
UserDefaults().removePersistentDomain(forName: suiteName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user