Compare commits

...

10 Commits

17 changed files with 594 additions and 53 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

123
README.md
View File

@ -15,6 +15,42 @@ StorageRouter (main entry point)
└── SyncHelper
```
![LocalData Architecture](Assets/architecture_diagram.png)
```mermaid
graph TD
App[App / Feature] -->|StorageKey| SR[StorageRouter]
subgraph Config [Global Configuration]
SC[StorageConfiguration]
EC[EncryptionConfiguration]
FC[FileStorageConfiguration]
SYC[SyncConfiguration]
end
SR -.->|Resolves Defaults| Config
SR -->|Delegates| KH[KeychainHelper]
SR -->|Delegates| FH[FileStorageHelper]
SR -->|Delegates| UH[UserDefaultsHelper]
KH --- EH[EncryptionHelper]
FH --- EH
SR -->|Syncs| SH[SyncHelper]
subgraph Storage [Hardware Storage]
KC[(Keychain)]
FS[(File System)]
UD[(UserDefaults)]
end
KH --> KC
FH --> FS
UH --> UD
SH -->|WatchConnectivity| WatchOS[Apple Watch]
```
## What Ships in the Package
### Protocols
@ -34,7 +70,14 @@ These helpers are internal implementation details used by `StorageRouter`. They
- **UserDefaultsHelper** - Wraps UserDefaults and suites safely.
- **SyncHelper** - Manages WatchConnectivity sync.
### Models
### Global Configuration Models
These are used at app lifecycle start to tune library engine behaviors:
- **StorageConfiguration** - Default Keychain service and App Group IDs
- **EncryptionConfiguration** - Global encryption settings (Keychain identifiers, key length)
- **SyncConfiguration** - Global sync settings (Max automatic sync size)
- **FileStorageConfiguration** - Global file settings (Sub-directory scoping)
### Other Models
- **StorageDomain** - userDefaults, appGroupUserDefaults, keychain, fileSystem, encryptedFileSystem, appGroupFileSystem
- **SecurityPolicy** - none, keychain, encrypted (AES-256 or ChaCha20-Poly1305)
- **Serializer** - JSON, plist, Data, or custom
@ -97,9 +140,22 @@ try await StorageRouter.shared.remove(key)
| `userDefaults` | Preferences, small settings |
| `appGroupUserDefaults` | Shared settings across extensions via App Groups |
| `keychain` | Credentials, tokens, sensitive data |
| `fileSystem` | Documents, cached data, large files |
| `encryptedFileSystem` | Sensitive files with encryption policies |
| `appGroupFileSystem` | Shared files across extensions via App Groups |
| `fileSystem(directory:)` | Local storage in Documents or Caches |
| `encryptedFileSystem(directory:)` | Sensitive files with encryption policies |
| `appGroupFileSystem(id:directory:)` | Shared files across targets via App Groups |
### File Directories
The library supports two standard iOS locations via `FileDirectory`:
| Directory | Persistence | iCloud Backup | Recommended Use |
| :--- | :--- | :--- | :--- |
| `.documents` | Permanent | Yes | User data, critical settings |
| `.caches` | Purgeable* | No | Temporary files, downloaded assets |
*\*iOS may delete files in `.caches` if the device runs low on storage.*
By configuring a `subDirectory` in `FileStorageConfiguration`, you ensure that the library's data is isolated within its own folder in both locations (e.g., `Documents/MyData/` and `Caches/MyData/`).
## App Group Support
@ -136,6 +192,63 @@ For app-level configuration (App Group identifiers, keychain service identifiers
- Default security policy: `SecurityPolicy.recommended` (ChaCha20-Poly1305 + HKDF)
- External key material providers can be registered via `StorageRouter`
#### Global Encryption Configuration
You can customize the identifiers used for the master key in the Keychain:
```swift
let config = EncryptionConfiguration(
masterKeyService: "com.myapp.LocalData",
masterKeyAccount: "MasterKey",
pbkdf2Iterations: 50_000
)
await StorageRouter.shared.updateEncryptionConfiguration(config)
```
> [!WARNING]
> Changing the `masterKeyService`, `masterKeyAccount`, or `pbkdf2Iterations` in an existing app will cause the app to look for or derive keys differently. Previously encrypted data will be inaccessible.
#### Global Sync Configuration
You can customize the maximum size for automatic synchronization:
```swift
let syncConfig = SyncConfiguration(maxAutoSyncSize: 50_000) // 50KB limit
await StorageRouter.shared.updateSyncConfiguration(syncConfig)
```
#### Global File Storage Configuration
You can scope all library files into a specific sub-directory (e.g., to avoid cluttering the root Documents folder):
```swift
let fileConfig = FileStorageConfiguration(subDirectory: "MyAppStorage")
await StorageRouter.shared.updateFileStorageConfiguration(fileConfig)
```
This will result in paths like:
- `.../Documents/MyAppStorage/` (Main Sandbox)
- `.../SharedContainer/Documents/MyAppStorage/` (App Group)
> [!WARNING]
> Changing the `subDirectory` in an existing app will cause the library to look in the new location. Existing files in the old location will not be automatically moved.
#### Global Storage Defaults
To avoid repeating the same Keychain service or App Group identifier in every key, you can set library-wide defaults:
```swift
let storageConfig = StorageConfiguration(
defaultKeychainService: "com.myapp.keychain",
defaultAppGroupIdentifier: "group.com.myapp"
)
await StorageRouter.shared.updateStorageConfiguration(storageConfig)
```
When defaults are set, you can define keys using `nil` for these identifiers:
- `.keychain(service: nil)` -> Uses "com.myapp.keychain"
- `.appGroupUserDefaults(identifier: nil)` -> Uses "group.com.myapp"
```swift
struct RemoteKeyProvider: KeyMaterialProviding {
func keyMaterial(for keyName: String) async throws -> Data {
@ -212,4 +325,4 @@ Dynamic key names are intentionally not supported in the core API to keep storag
If you need this later, see `FutureEnhancements.md` for a proposed design.
## Sample App
See `SecureStorgageSample` for working examples of all storage domains and security options.
See `SecureStorageSample` for working examples of all storage domains and security options.

View File

@ -0,0 +1,26 @@
import Foundation
/// Configuration for the EncryptionHelper.
public struct EncryptionConfiguration: Sendable {
public let masterKeyService: String
public let masterKeyAccount: String
public let masterKeyLength: Int
public let defaultHKDFInfo: String
public let pbkdf2Iterations: Int
public init(
masterKeyService: String = "LocalData",
masterKeyAccount: String = "MasterKey",
masterKeyLength: Int = 32,
defaultHKDFInfo: String = "LocalData.Encryption",
pbkdf2Iterations: Int = 10_000
) {
self.masterKeyService = masterKeyService
self.masterKeyAccount = masterKeyAccount
self.masterKeyLength = masterKeyLength
self.defaultHKDFInfo = defaultHKDFInfo
self.pbkdf2Iterations = pbkdf2Iterations
}
public static let `default` = EncryptionConfiguration()
}

View File

@ -0,0 +1,14 @@
import Foundation
/// Configuration for the FileStorageHelper.
public struct FileStorageConfiguration: Sendable {
/// An optional sub-directory to scope all library files within.
/// If provided, files will be stored in `.../Documents/{subDirectory}/` instead of `.../Documents/`.
public let subDirectory: String?
public init(subDirectory: String? = nil) {
self.subDirectory = subDirectory
}
public static let `default` = FileStorageConfiguration()
}

View File

@ -0,0 +1,21 @@
import Foundation
/// Global configuration for the storage engine.
/// Allows setting default identifiers for Keychain services and App Groups.
public struct StorageConfiguration: Sendable {
/// The default Keychain service to use if none is specified in a StorageKey.
public let defaultKeychainService: String?
/// The default App Group identifier to use if none is specified in a StorageKey.
public let defaultAppGroupIdentifier: String?
public init(
defaultKeychainService: String? = nil,
defaultAppGroupIdentifier: String? = nil
) {
self.defaultKeychainService = defaultKeychainService
self.defaultAppGroupIdentifier = defaultAppGroupIdentifier
}
public static let `default` = StorageConfiguration()
}

View File

@ -0,0 +1,13 @@
import Foundation
/// Configuration for WatchConnectivity sync operations.
public struct SyncConfiguration: Sendable {
/// Maximum data size for automatic sync in bytes.
public let maxAutoSyncSize: Int
public init(maxAutoSyncSize: Int = 100_000) {
self.maxAutoSyncSize = maxAutoSyncSize
}
public static let `default` = SyncConfiguration()
}

View File

@ -21,7 +21,7 @@ public enum SecurityPolicy: Sendable {
}
public enum KeyDerivation: Sendable {
case pbkdf2(iterations: Int, salt: Data? = nil)
case pbkdf2(iterations: Int? = nil, salt: Data? = nil)
case hkdf(salt: Data? = nil, info: Data? = nil)
}
}

View File

@ -2,9 +2,9 @@ import Foundation
public enum StorageDomain: Sendable {
case userDefaults(suite: String?)
case appGroupUserDefaults(identifier: String)
case keychain(service: String)
case appGroupUserDefaults(identifier: String?)
case keychain(service: String?)
case fileSystem(directory: FileDirectory)
case encryptedFileSystem(directory: FileDirectory)
case appGroupFileSystem(identifier: String, directory: FileDirectory)
case appGroupFileSystem(identifier: String?, directory: FileDirectory)
}

View File

@ -14,7 +14,6 @@ public enum StorageError: Error {
case unregisteredKey(String)
case duplicateRegisteredKeys([String])
case missingDescription(String)
// ...
}
extension StorageError: @unchecked Sendable {}

View File

@ -2,21 +2,27 @@ import Foundation
import CryptoKit
/// Actor that handles all encryption and decryption operations.
/// Uses AES-GCM for symmetric encryption with PBKDF2 key derivation.
/// Uses AES-GCM or ChaChaPoly for symmetric encryption with derived keys.
actor EncryptionHelper {
public static let shared = EncryptionHelper()
private enum Constants {
static let masterKeyService = "LocalData.MasterKey"
static let masterKeyAccount = "LocalData.MasterKey"
static let masterKeyLength = 32
static let defaultHKDFInfo = "LocalData.Encryption"
}
private var configuration: EncryptionConfiguration
private var keyMaterialProviders: [KeyMaterialSource: any KeyMaterialProviding] = [:]
private init() {}
private init(configuration: EncryptionConfiguration = .default) {
self.configuration = configuration
}
// MARK: - Configuration
/// Updates the configuration for the actor.
/// > [!WARNING]
/// > Changing the configuration (specifically service or account) on an existing instance
/// > will cause it to look for the master key in a new location.
public func updateConfiguration(_ configuration: EncryptionConfiguration) {
self.configuration = configuration
}
// MARK: - Public Interface
@ -28,7 +34,7 @@ actor EncryptionHelper {
keyMaterialProviders[source] = provider
}
/// Encrypts data using AES-GCM.
/// Encrypts data using AES-GCM or ChaChaPoly.
/// - Parameters:
/// - data: The plaintext data to encrypt.
/// - keyName: A unique name used for key derivation salt.
@ -44,7 +50,7 @@ actor EncryptionHelper {
return try encryptWithKey(data, using: key, policy: policy)
}
/// Decrypts data using AES-GCM.
/// Decrypts data using AES-GCM or ChaChaPoly.
/// - Parameters:
/// - data: The encrypted data (nonce + ciphertext + tag combined).
/// - keyName: The same unique name used during encryption.
@ -98,22 +104,23 @@ actor EncryptionHelper {
switch derivation {
case .pbkdf2(let iterations, let customSalt):
let salt = customSalt ?? defaultSalt(for: keyName)
let actualIterations = iterations ?? configuration.pbkdf2Iterations
let derivedKeyData = try pbkdf2SHA256(
password: baseKeyMaterial,
salt: salt,
iterations: iterations,
keyLength: Constants.masterKeyLength
iterations: actualIterations,
keyLength: configuration.masterKeyLength
)
return SymmetricKey(data: derivedKeyData)
case .hkdf(let customSalt, let customInfo):
let salt = customSalt ?? defaultSalt(for: keyName)
let info = customInfo ?? Data(Constants.defaultHKDFInfo.utf8)
let info = customInfo ?? Data(configuration.defaultHKDFInfo.utf8)
let inputKey = SymmetricKey(data: baseKeyMaterial)
return HKDF<SHA256>.deriveKey(
inputKeyMaterial: inputKey,
salt: salt,
info: info,
outputByteCount: Constants.masterKeyLength
outputByteCount: configuration.masterKeyLength
)
}
}
@ -121,14 +128,14 @@ actor EncryptionHelper {
/// Gets or creates the master key stored in keychain.
private func getMasterKey() async throws -> Data {
if let existing = try await KeychainHelper.shared.get(
service: Constants.masterKeyService,
key: Constants.masterKeyAccount
service: configuration.masterKeyService,
key: configuration.masterKeyAccount
) {
return existing
}
// Generate new master key
var bytes = [UInt8](repeating: 0, count: Constants.masterKeyLength)
var bytes = [UInt8](repeating: 0, count: configuration.masterKeyLength)
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
guard status == errSecSuccess else {
throw StorageError.securityApplicationFailed
@ -139,8 +146,8 @@ actor EncryptionHelper {
// Store in keychain
try await KeychainHelper.shared.set(
masterKey,
service: Constants.masterKeyService,
key: Constants.masterKeyAccount,
service: configuration.masterKeyService,
key: configuration.masterKeyAccount,
accessibility: .afterFirstUnlock,
accessControl: nil
)

View File

@ -6,7 +6,16 @@ actor FileStorageHelper {
public static let shared = FileStorageHelper()
private init() {}
private var configuration: FileStorageConfiguration
private init(configuration: FileStorageConfiguration = .default) {
self.configuration = configuration
}
/// Updates the file storage configuration.
public func updateConfiguration(_ configuration: FileStorageConfiguration) {
self.configuration = configuration
}
// MARK: - Public Interface
@ -305,14 +314,21 @@ actor FileStorageHelper {
}
private func resolveDirectoryURL(baseURL: URL, directory: FileDirectory) throws -> URL {
let base: URL
switch directory {
case .documents:
return baseURL.appending(path: "Documents")
base = baseURL.appending(path: "Documents")
case .caches:
return baseURL.appending(path: "Library/Caches")
base = baseURL.appending(path: "Library/Caches")
case .custom(let url):
let relativePath = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return baseURL.appending(path: relativePath)
}
if let subDirectory = configuration.subDirectory {
return base.appending(path: subDirectory)
}
return base
}
}

View File

@ -31,15 +31,15 @@ public struct StorageAuditReport: Sendable {
case .userDefaults(let suite):
return "userDefaults(\(suite ?? "standard"))"
case .appGroupUserDefaults(let identifier):
return "appGroupUserDefaults(\(identifier))"
return "appGroupUserDefaults(\(identifier ?? "default"))"
case .keychain(let service):
return "keychain(\(service))"
return "keychain(\(service ?? "default"))"
case .fileSystem(let directory):
return "fileSystem(\(string(for: directory)))"
case .encryptedFileSystem(let directory):
return "encryptedFileSystem(\(string(for: directory)))"
case .appGroupFileSystem(let identifier, let directory):
return "appGroupFileSystem(\(identifier), \(string(for: directory)))"
return "appGroupFileSystem(\(identifier ?? "default"), \(string(for: directory)))"
}
}

View File

@ -10,9 +10,35 @@ public actor StorageRouter: StorageProviding {
public static let shared = StorageRouter()
private var registeredKeyNames: Set<String> = []
private var storageConfiguration: StorageConfiguration = .default
private init() {}
// MARK: - Configuration
/// Updates the encryption configuration.
/// > [!WARNING]
/// > 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.
public func updateEncryptionConfiguration(_ configuration: EncryptionConfiguration) async {
await EncryptionHelper.shared.updateConfiguration(configuration)
}
/// Updates the sync configuration.
public func updateSyncConfiguration(_ configuration: SyncConfiguration) async {
await SyncHelper.shared.updateConfiguration(configuration)
}
/// Updates the file storage configuration.
public func updateFileStorageConfiguration(_ configuration: FileStorageConfiguration) async {
await FileStorageHelper.shared.updateConfiguration(configuration)
}
/// Updates the global storage configuration (defaults).
public func updateStorageConfiguration(_ configuration: StorageConfiguration) {
self.storageConfiguration = configuration
}
// MARK: - Key Material Providers
/// Registers a key material provider for external encryption policies.
@ -86,16 +112,19 @@ public actor StorageRouter: StorageProviding {
case .userDefaults(let suite):
return try await UserDefaultsHelper.shared.exists(forKey: key.name, suite: suite)
case .appGroupUserDefaults(let identifier):
return try await UserDefaultsHelper.shared.exists(forKey: key.name, appGroupIdentifier: identifier)
let resolvedId = try resolveIdentifier(identifier)
return try await UserDefaultsHelper.shared.exists(forKey: key.name, appGroupIdentifier: resolvedId)
case .keychain(let service):
return try await KeychainHelper.shared.exists(service: service, key: key.name)
let resolvedService = try resolveService(service)
return try await KeychainHelper.shared.exists(service: resolvedService, key: key.name)
case .fileSystem(let directory), .encryptedFileSystem(let directory):
return await FileStorageHelper.shared.exists(in: directory, fileName: key.name)
case .appGroupFileSystem(let identifier, let directory):
let resolvedId = try resolveIdentifier(identifier)
return await FileStorageHelper.shared.exists(
in: directory,
fileName: key.name,
appGroupIdentifier: identifier
appGroupIdentifier: resolvedId
)
}
}
@ -216,15 +245,17 @@ public actor StorageRouter: StorageProviding {
try await UserDefaultsHelper.shared.set(data, forKey: key.name, suite: suite)
case .appGroupUserDefaults(let identifier):
try await UserDefaultsHelper.shared.set(data, forKey: key.name, appGroupIdentifier: identifier)
let resolvedId = try resolveIdentifier(identifier)
try await UserDefaultsHelper.shared.set(data, forKey: key.name, appGroupIdentifier: resolvedId)
case .keychain(let service):
guard case let .keychain(accessibility, accessControl) = key.security else {
throw StorageError.securityApplicationFailed
}
let resolvedService = try resolveService(service)
try await KeychainHelper.shared.set(
data,
service: service,
service: resolvedService,
key: key.name,
accessibility: accessibility,
accessControl: accessControl
@ -247,11 +278,12 @@ public actor StorageRouter: StorageProviding {
)
case .appGroupFileSystem(let identifier, let directory):
let resolvedId = try resolveIdentifier(identifier)
try await FileStorageHelper.shared.write(
data,
to: directory,
fileName: key.name,
appGroupIdentifier: identifier,
appGroupIdentifier: resolvedId,
useCompleteFileProtection: false
)
}
@ -262,18 +294,21 @@ public actor StorageRouter: StorageProviding {
case .userDefaults(let suite):
return try await UserDefaultsHelper.shared.get(forKey: key.name, suite: suite)
case .appGroupUserDefaults(let identifier):
return try await UserDefaultsHelper.shared.get(forKey: key.name, appGroupIdentifier: identifier)
let resolvedId = try resolveIdentifier(identifier)
return try await UserDefaultsHelper.shared.get(forKey: key.name, appGroupIdentifier: resolvedId)
case .keychain(let service):
return try await KeychainHelper.shared.get(service: service, key: key.name)
let resolvedService = try resolveService(service)
return try await KeychainHelper.shared.get(service: resolvedService, key: key.name)
case .fileSystem(let directory), .encryptedFileSystem(let directory):
return try await FileStorageHelper.shared.read(from: directory, fileName: key.name)
case .appGroupFileSystem(let identifier, let directory):
let resolvedId = try resolveIdentifier(identifier)
return try await FileStorageHelper.shared.read(
from: directory,
fileName: key.name,
appGroupIdentifier: identifier
appGroupIdentifier: resolvedId
)
}
}
@ -283,18 +318,21 @@ public actor StorageRouter: StorageProviding {
case .userDefaults(let suite):
try await UserDefaultsHelper.shared.remove(forKey: key.name, suite: suite)
case .appGroupUserDefaults(let identifier):
try await UserDefaultsHelper.shared.remove(forKey: key.name, appGroupIdentifier: identifier)
let resolvedId = try resolveIdentifier(identifier)
try await UserDefaultsHelper.shared.remove(forKey: key.name, appGroupIdentifier: resolvedId)
case .keychain(let service):
try await KeychainHelper.shared.delete(service: service, key: key.name)
let resolvedService = try resolveService(service)
try await KeychainHelper.shared.delete(service: resolvedService, key: key.name)
case .fileSystem(let directory), .encryptedFileSystem(let directory):
try await FileStorageHelper.shared.delete(from: directory, fileName: key.name)
case .appGroupFileSystem(let identifier, let directory):
let resolvedId = try resolveIdentifier(identifier)
try await FileStorageHelper.shared.delete(
from: directory,
fileName: key.name,
appGroupIdentifier: identifier
appGroupIdentifier: resolvedId
)
}
}
@ -309,4 +347,20 @@ public actor StorageRouter: StorageProviding {
syncPolicy: key.syncPolicy
)
}
// MARK: - Resolution Helpers
private func resolveService(_ service: String?) throws -> String {
guard let resolved = service ?? storageConfiguration.defaultKeychainService else {
throw StorageError.keychainError(errSecBadReq) // Or a more specific error
}
return resolved
}
private func resolveIdentifier(_ identifier: String?) throws -> String {
guard let resolved = identifier ?? storageConfiguration.defaultAppGroupIdentifier else {
throw StorageError.invalidAppGroupIdentifier("none")
}
return resolved
}
}

View File

@ -9,10 +9,16 @@ actor SyncHelper {
public static let shared = SyncHelper()
/// Maximum data size for automatic sync (100KB).
public static let maxAutoSyncSize = 100_000
private var configuration: SyncConfiguration
private init() {}
private init(configuration: SyncConfiguration = .default) {
self.configuration = configuration
}
/// Updates the sync configuration.
public func updateConfiguration(_ configuration: SyncConfiguration) {
self.configuration = configuration
}
// MARK: - Public Interface
@ -40,7 +46,7 @@ actor SyncHelper {
return
case .automaticSmall:
guard data.count <= Self.maxAutoSyncSize else {
guard data.count <= configuration.maxAutoSyncSize else {
throw StorageError.dataTooLargeForSync
}
try performSync(data: data, keyName: keyName)

View File

@ -3,7 +3,7 @@ import Testing
@testable import LocalData
struct EncryptionHelperTests {
private let masterKeyService = "LocalData.MasterKey"
private let masterKeyService = "LocalData"
private let keyName = "LocalDataTests.encryption"
private let payload = Data("payload".utf8)
@ -51,6 +51,40 @@ struct EncryptionHelperTests {
await clearMasterKey()
}
@Test func customConfigurationRoundTrip() async throws {
let customService = "Test.CustomService"
let customAccount = "Test.CustomAccount"
let config = EncryptionConfiguration(
masterKeyService: customService,
masterKeyAccount: customAccount
)
await EncryptionHelper.shared.updateConfiguration(config)
let policy: SecurityPolicy.EncryptionPolicy = .chacha20Poly1305(
keyDerivation: .hkdf()
)
let encrypted = try await EncryptionHelper.shared.encrypt(
payload,
keyName: keyName,
policy: policy
)
let decrypted = try await EncryptionHelper.shared.decrypt(
encrypted,
keyName: keyName,
policy: policy
)
#expect(decrypted == payload)
// Cleanup keychain
try? await KeychainHelper.shared.deleteAll(service: customService)
// Reset to default
await EncryptionHelper.shared.updateConfiguration(.default)
}
@Test func externalProviderWithHKDFRoundTrip() async throws {
let source = KeyMaterialSource(id: "test.external")
let provider = StaticKeyMaterialProvider(material: Data(repeating: 7, count: 32))

View File

@ -0,0 +1,143 @@
import Foundation
import Testing
@testable import LocalData
struct KeychainHelperTests {
private let testService = "LocalDataTests.Keychain.\(UUID().uuidString)"
// MARK: - Basic Round Trip
@Test func keychainRoundTrip() async throws {
let key = "test.credential"
let data = Data("secret-password".utf8)
defer {
try? KeychainHelper.shared.delete(service: testService, key: key)
}
try await KeychainHelper.shared.set(
data,
service: testService,
key: key,
accessibility: .afterFirstUnlock
)
let retrieved = try await KeychainHelper.shared.get(service: testService, key: key)
#expect(retrieved == data)
}
@Test func keychainNotFoundReturnsNil() async throws {
let result = try await KeychainHelper.shared.get(
service: testService,
key: "nonexistent.\(UUID().uuidString)"
)
#expect(result == nil)
}
@Test func keychainDeleteNonexistentDoesNotThrow() async throws {
// Should not throw even if item doesn't exist
try await KeychainHelper.shared.delete(
service: testService,
key: "nonexistent.\(UUID().uuidString)"
)
}
@Test func keychainExists() async throws {
let key = "test.exists.\(UUID().uuidString)"
let data = Data("test".utf8)
defer {
try? KeychainHelper.shared.delete(service: testService, key: key)
}
let beforeExists = try await KeychainHelper.shared.exists(service: testService, key: key)
#expect(beforeExists == false)
try await KeychainHelper.shared.set(
data,
service: testService,
key: key,
accessibility: .whenUnlocked
)
let afterExists = try await KeychainHelper.shared.exists(service: testService, key: key)
#expect(afterExists == true)
}
@Test func keychainUpdateReplacesData() async throws {
let key = "test.update.\(UUID().uuidString)"
let originalData = Data("original".utf8)
let updatedData = Data("updated".utf8)
defer {
try? KeychainHelper.shared.delete(service: testService, key: key)
}
try await KeychainHelper.shared.set(
originalData,
service: testService,
key: key,
accessibility: .afterFirstUnlock
)
try await KeychainHelper.shared.set(
updatedData,
service: testService,
key: key,
accessibility: .afterFirstUnlock
)
let retrieved = try await KeychainHelper.shared.get(service: testService, key: key)
#expect(retrieved == updatedData)
}
@Test func keychainDeleteAll() async throws {
let deleteAllService = "LocalDataTests.DeleteAll.\(UUID().uuidString)"
let data = Data("test".utf8)
// Create multiple items
for i in 0..<3 {
try await KeychainHelper.shared.set(
data,
service: deleteAllService,
key: "key\(i)",
accessibility: .afterFirstUnlock
)
}
// Verify they exist
let exists0 = try await KeychainHelper.shared.exists(service: deleteAllService, key: "key0")
#expect(exists0 == true)
// Delete all
try await KeychainHelper.shared.deleteAll(service: deleteAllService)
// Verify they're gone
for i in 0..<3 {
let exists = try await KeychainHelper.shared.exists(service: deleteAllService, key: "key\(i)")
#expect(exists == false)
}
}
// MARK: - Accessibility Options
@Test(arguments: KeychainAccessibility.allCases)
func keychainAccessibilityOptions(accessibility: KeychainAccessibility) async throws {
let key = "test.accessibility.\(accessibility).\(UUID().uuidString)"
let data = Data("data-for-\(accessibility)".utf8)
defer {
try? KeychainHelper.shared.delete(service: testService, key: key)
}
try await KeychainHelper.shared.set(
data,
service: testService,
key: key,
accessibility: accessibility
)
let retrieved = try await KeychainHelper.shared.get(service: testService, key: key)
#expect(retrieved == data)
}
}

View File

@ -0,0 +1,95 @@
import Foundation
import Testing
@testable import LocalData
// MARK: - Test Keys
private struct TestCatalogKey: 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 = "CatalogTests"
let description: String
let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never
init(name: String, description: String = "Test key") {
self.name = name
self.description = description
}
}
// MARK: - Test Catalogs
private struct ValidCatalog: StorageKeyCatalog {
static var allKeys: [AnyStorageKey] {
[
.key(TestCatalogKey(name: "valid.key1", description: "First test key")),
.key(TestCatalogKey(name: "valid.key2", description: "Second test key"))
]
}
}
private struct DuplicateNameCatalog: StorageKeyCatalog {
static var allKeys: [AnyStorageKey] {
[
.key(TestCatalogKey(name: "duplicate.name", description: "First instance")),
.key(TestCatalogKey(name: "duplicate.name", description: "Second instance"))
]
}
}
private struct EmptyCatalog: StorageKeyCatalog {
static var allKeys: [AnyStorageKey] { [] }
}
// MARK: - Tests
struct StorageCatalogTests {
@Test func auditReportContainsAllKeys() {
let items = StorageAuditReport.items(for: ValidCatalog.self)
#expect(items.count == 2)
#expect(items[0].name == "valid.key1")
#expect(items[1].name == "valid.key2")
}
@Test func auditReportRendersText() {
let report = StorageAuditReport.renderText(for: ValidCatalog.self)
#expect(report.contains("valid.key1"))
#expect(report.contains("valid.key2"))
#expect(report.contains("First test key"))
#expect(report.contains("Second test key"))
}
@Test func descriptorCapturesKeyMetadata() {
let key = TestCatalogKey(name: "metadata.test", description: "Metadata test key")
let anyKey = AnyStorageKey.key(key)
let descriptor = anyKey.descriptor
#expect(descriptor.name == "metadata.test")
#expect(descriptor.owner == "CatalogTests")
#expect(descriptor.description == "Metadata test key")
#expect(descriptor.valueType == "String")
}
@Test func emptyReportForEmptyCatalog() {
let items = StorageAuditReport.items(for: EmptyCatalog.self)
#expect(items.isEmpty)
let report = StorageAuditReport.renderText(for: EmptyCatalog.self)
#expect(report.isEmpty)
}
@Test func catalogRegistrationDetectsDuplicates() async {
// Attempting to register a catalog with duplicate key names should throw
await #expect(throws: StorageError.self) {
try await StorageRouter.shared.registerCatalog(DuplicateNameCatalog.self)
}
}
}