Update Audit, Helpers, Services and tests, docs
Summary: - Sources: update Audit, Helpers, Services - Tests: update tests for StorageCatalogTests.swift - Docs: add docs for Design, Migration, Testing Stats: - 10 files changed, 181 insertions(+), 1 deletion(-)
This commit is contained in:
parent
27e47ae254
commit
905cabc542
38
Documentation/Design.md
Normal file
38
Documentation/Design.md
Normal file
@ -0,0 +1,38 @@
|
||||
# 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.
|
||||
35
Documentation/Migration.md
Normal file
35
Documentation/Migration.md
Normal file
@ -0,0 +1,35 @@
|
||||
# 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)))
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
33
Documentation/Testing.md
Normal file
33
Documentation/Testing.md
Normal file
@ -0,0 +1,33 @@
|
||||
# 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
|
||||
```
|
||||
@ -104,7 +104,7 @@ public struct StorageAuditReport: Sendable {
|
||||
private static func string(for derivation: SecurityPolicy.KeyDerivation) -> String {
|
||||
switch derivation {
|
||||
case .pbkdf2(let iterations, _):
|
||||
return "pbkdf2(\(iterations))"
|
||||
return "pbkdf2(\(iterations ?? 0))"
|
||||
case .hkdf:
|
||||
return "hkdf"
|
||||
}
|
||||
@ -105,6 +105,10 @@ actor SyncHelper {
|
||||
guard WCSession.isSupported() else { return }
|
||||
|
||||
let session = WCSession.default
|
||||
if session.delegate == nil {
|
||||
setupSession()
|
||||
}
|
||||
|
||||
guard session.activationState == .activated else { return }
|
||||
|
||||
#if os(iOS)
|
||||
@ -113,5 +117,51 @@ actor SyncHelper {
|
||||
|
||||
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.
|
||||
fileprivate 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)")
|
||||
// Future implementation: Route this back to StorageRouter to update local storage
|
||||
// For now, we just log it as a skeleton implementation
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS) || os(watchOS)
|
||||
/// A private proxy class to handle WCSessionDelegate callbacks and route them to the SyncHelper actor.
|
||||
private 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
|
||||
@ -46,6 +46,14 @@ private struct EmptyCatalog: StorageKeyCatalog {
|
||||
static var allKeys: [AnyStorageKey] { [] }
|
||||
}
|
||||
|
||||
private struct MissingDescriptionCatalog: StorageKeyCatalog {
|
||||
static var allKeys: [AnyStorageKey] {
|
||||
[
|
||||
.key(TestCatalogKey(name: "missing.desc", description: " "))
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
struct StorageCatalogTests {
|
||||
@ -92,4 +100,20 @@ struct StorageCatalogTests {
|
||||
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 StorageRouter.shared.registerCatalog(MissingDescriptionCatalog.self)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func migrateAllRegisteredKeysInvokesMigrationOnKeys() async throws {
|
||||
// This test verifies that migrateAllRegisteredKeys calling logic works.
|
||||
// We'll use the shared StorageRouter and register a clean catalog first.
|
||||
try await StorageRouter.shared.registerCatalog(ValidCatalog.self)
|
||||
|
||||
// No error should occur
|
||||
try await StorageRouter.shared.migrateAllRegisteredKeys()
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user