Update Helpers + tests

Summary:
- Sources: Helpers
- Tests: AppGroupTests.swift, RouterDomainTests.swift, RouterSecurityTests.swift, SyncDelegateTests.swift
- Added symbols: class SessionDelegateProxy, struct DomainKey, typealias Value, struct SecurityKey
- Removed symbols: class SessionDelegateProxy

Stats:
- 5 files changed, 283 insertions(+), 2 deletions(-)
This commit is contained in:
Matt Bruce 2026-01-15 12:43:36 -06:00
parent 66001439e3
commit 4a361dd421
5 changed files with 283 additions and 2 deletions

View File

@ -128,8 +128,8 @@ actor SyncHelper {
} }
} }
/// A private proxy class to handle WCSessionDelegate callbacks and route them to the SyncHelper actor. /// An internal proxy class to handle WCSessionDelegate callbacks and route them to the SyncHelper actor.
private final class SessionDelegateProxy: NSObject, WCSessionDelegate { internal final class SessionDelegateProxy: NSObject, WCSessionDelegate {
static let shared = SessionDelegateProxy() static let shared = SessionDelegateProxy()
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {

View File

@ -53,4 +53,33 @@ import Testing
} }
} }
} }
@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)
}
}
} }

View File

@ -0,0 +1,117 @@
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)
}
}
}

View File

@ -0,0 +1,92 @@
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)
}
}

View File

@ -0,0 +1,43 @@
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
}