diff --git a/Sources/LocalData/Helpers/SyncHelper.swift b/Sources/LocalData/Helpers/SyncHelper.swift index c35b0d2..90bd629 100644 --- a/Sources/LocalData/Helpers/SyncHelper.swift +++ b/Sources/LocalData/Helpers/SyncHelper.swift @@ -128,8 +128,8 @@ actor SyncHelper { } } -/// A private proxy class to handle WCSessionDelegate callbacks and route them to the SyncHelper actor. -private final class SessionDelegateProxy: NSObject, WCSessionDelegate { +/// 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?) { diff --git a/Tests/LocalDataTests/AppGroupTests.swift b/Tests/LocalDataTests/AppGroupTests.swift index c9e241d..60d2935 100644 --- a/Tests/LocalDataTests/AppGroupTests.swift +++ b/Tests/LocalDataTests/AppGroupTests.swift @@ -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) + } + } } diff --git a/Tests/LocalDataTests/RouterDomainTests.swift b/Tests/LocalDataTests/RouterDomainTests.swift new file mode 100644 index 0000000..bdf54c2 --- /dev/null +++ b/Tests/LocalDataTests/RouterDomainTests.swift @@ -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 = .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) + } + } +} diff --git a/Tests/LocalDataTests/RouterSecurityTests.swift b/Tests/LocalDataTests/RouterSecurityTests.swift new file mode 100644 index 0000000..c892bdb --- /dev/null +++ b/Tests/LocalDataTests/RouterSecurityTests.swift @@ -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 = .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) + } +} diff --git a/Tests/LocalDataTests/SyncDelegateTests.swift b/Tests/LocalDataTests/SyncDelegateTests.swift new file mode 100644 index 0000000..9b2e56e --- /dev/null +++ b/Tests/LocalDataTests/SyncDelegateTests.swift @@ -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 +}