From 20baab78c05bbc5e5aedcb50298171ceebfa722a Mon Sep 17 00:00:00 2001 From: psmattas Date: Fri, 21 Aug 2026 01:15:15 +0100 Subject: [PATCH] feat(outlinekit): encrypt the offline cache at rest Both CachedPayload and PendingOperation only ever stored their payload as plain JSON on disk (SwiftData/SQLite, no encryption of its own) - readable by anyone with access to the logged-in session, per the earlier discussion on where this cache lives. Adds AES-GCM encryption at the one place raw bytes cross into/out of OfflineCacheStore (CachingOutlineAPIClient, which already owns encode/decode) - OfflineCacheStore itself stays a dumb opaque-blob store, since its key/id/kind columns can't be encrypted without breaking the #Predicate queries built against them. Key management (KeychainCacheEncryptionKeyStore, mirrors KeychainTokenStore exactly): a random 256-bit key, generated once and Keychain-stored, not derived from anything guessable. It doesn't need deriving to survive an uninstall/reinstall either - Keychain items are scoped to the app's code signature, not its on-disk presence, so a reinstall of the same app regains access to the same key automatically (same reason a saved API token already survives a reinstall today). If the on-disk cache also happens to survive (dragging the .app to the Trash doesn't clean ~/Library/Containers), a reinstall can still read it. A row written before this shipped (still plaintext) or encrypted under a since-cleared key just fails to decrypt and is treated as a cache miss - same as any other decode failure, so it silently refetches and re-caches encrypted rather than crashing. No explicit migration needed. Also: OfflineCacheStore.clearEverything() wipes both the cache AND the pending write queue (clearAll(), used by Settings' "Clear All Cache", still only touches the cache - it shouldn't silently discard someone's unsynced edits). Exposed as CachingOutlineAPIClient.clearEverythingForSignOut(), for sign-out to use alongside clearing the key. CacheEncryptionKeyStoring is a protocol (like TokenStoring) so tests never touch the real Keychain - existing CachingOutlineAPIClientTests now inject an in-memory StaticCacheEncryptionKeyStore. 8 new tests (retry/failure-log tests from the previous commit plus 3 new ones here: ciphertext isn't plaintext JSON, a cleared key makes old rows unreadable, clearEverythingForSignOut wipes both tables). 95/95 passing. --- .../Caching/CacheEncryptionKeyStoring.swift | 16 +++ .../Caching/CachePayloadCryptor.swift | 24 ++++ .../Caching/CachingOutlineAPIClient.swift | 101 ++++++++++--- .../KeychainCacheEncryptionKeyStore.swift | 76 ++++++++++ .../Caching/OfflineCacheStore.swift | 16 +++ .../CachingOutlineAPIClientTests.swift | 134 ++++++++++++++---- 6 files changed, 320 insertions(+), 47 deletions(-) create mode 100644 OutlineKit/Sources/OutlineKit/Caching/CacheEncryptionKeyStoring.swift create mode 100644 OutlineKit/Sources/OutlineKit/Caching/CachePayloadCryptor.swift create mode 100644 OutlineKit/Sources/OutlineKit/Caching/KeychainCacheEncryptionKeyStore.swift diff --git a/OutlineKit/Sources/OutlineKit/Caching/CacheEncryptionKeyStoring.swift b/OutlineKit/Sources/OutlineKit/Caching/CacheEncryptionKeyStoring.swift new file mode 100644 index 0000000..c1fdf4f --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Caching/CacheEncryptionKeyStoring.swift @@ -0,0 +1,16 @@ +import Foundation +import CryptoKit + +/// Boundary over wherever the offline cache's symmetric encryption key +/// lives. Mirrors `TokenStoring` — same reasoning, different secret. +public protocol CacheEncryptionKeyStoring: Sendable { + /// Returns the existing key, generating and persisting a new random one + /// on first use if none exists yet. + func key() throws -> SymmetricKey + /// Called on sign-out. Anything still encrypted with the cleared key + /// becomes permanently unreadable — that's the point, not a bug: the + /// next person signed in on this machine shouldn't be able to read a + /// previous account's cached content just because the on-disk rows + /// happen to still be there. + func clear() throws +} diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachePayloadCryptor.swift b/OutlineKit/Sources/OutlineKit/Caching/CachePayloadCryptor.swift new file mode 100644 index 0000000..81336ed --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Caching/CachePayloadCryptor.swift @@ -0,0 +1,24 @@ +import Foundation +import CryptoKit + +/// AES-GCM at the boundary where `CachingOutlineAPIClient` writes to/reads +/// from `OfflineCacheStore`. Encryption lives at this layer rather than +/// inside `OfflineCacheStore` itself — that stays a dumb opaque-blob store; +/// its `key`/`id`/`kind` columns can't be encrypted without breaking the +/// `#Predicate` queries built directly against them. +enum CachePayloadCryptor { + enum CryptoError: Error { + case sealingFailed + } + + static func encrypt(_ data: Data, key: SymmetricKey) throws -> Data { + let sealedBox = try AES.GCM.seal(data, using: key) + guard let combined = sealedBox.combined else { throw CryptoError.sealingFailed } + return combined + } + + static func decrypt(_ data: Data, key: SymmetricKey) throws -> Data { + let sealedBox = try AES.GCM.SealedBox(combined: data) + return try AES.GCM.open(sealedBox, using: key) + } +} diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift index 0342b49..eaadd97 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift @@ -1,4 +1,5 @@ import Foundation +import CryptoKit /// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with offline /// support at the existing protocol boundary, so no view model needs to know @@ -36,6 +37,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { private let encoder: JSONEncoder private let decoder: JSONDecoder private let keyEncoder: JSONEncoder + private let encryptionKeyStore: CacheEncryptionKeyStoring + /// Resolved lazily and kept for this instance's lifetime — a fresh + /// instance is created on every sign-in/sign-out anyway (see + /// `SessionStore.makeAPIClient`), so there's no staleness risk, just + /// one fewer Keychain round-trip per cache read/write. + private var cachedEncryptionKey: SymmetricKey? /// Recent failure timestamps per category — see `recordFailure` / /// `repeatedFailureSummaries()`. A category only shows up there once it's @@ -47,10 +54,16 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { private let failureWindow: TimeInterval = 300 private let failureThreshold: Int = 3 - public init(live: OutlineAPIClient, cache: OfflineCacheStore, defaults: UserDefaults = .standard) { + public init( + live: OutlineAPIClient, + cache: OfflineCacheStore, + defaults: UserDefaults = .standard, + encryptionKeyStore: CacheEncryptionKeyStoring = KeychainCacheEncryptionKeyStore() + ) { self.live = live self.cache = cache self.defaults = defaults + self.encryptionKeyStore = encryptionKeyStore let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 @@ -495,6 +508,15 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { await cache.clearAll() } + /// Sign-out only — see `OfflineCacheStore.clearEverything()` and + /// `CacheEncryptionKeyStoring.clear()`. Callers must clear the + /// encryption key too (this actor doesn't own that decision); wiping + /// the storage here without it would leave the key to be reused by + /// whoever signs in next. + public func clearEverythingForSignOut() async { + await cache.clearEverything() + } + /// Replays every queued operation against `live`, in the order they were /// queued. Each is independent — one failing doesn't block the rest. public func flushPendingOperations() async -> SyncFlushSummary { @@ -603,13 +625,13 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { /// SwiftData read, no network involved. public func cachedDocumentsIndex() async -> [OutlineDocument] { let payloads = await cache.loadAll(keyPrefix: "document:") - return payloads.compactMap { try? decoder.decode(OutlineDocument.self, from: $0) } + return payloads.compactMap { decryptedDecode(OutlineDocument.self, from: $0) } } /// Every individually cached collection from the last Full Local Sync. public func cachedCollectionsIndex() async -> [OutlineCollection] { let payloads = await cache.loadAll(keyPrefix: "collection:") - return payloads.compactMap { try? decoder.decode(OutlineCollection.self, from: $0) } + return payloads.compactMap { decryptedDecode(OutlineCollection.self, from: $0) } } // MARK: - Helpers @@ -621,7 +643,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { // the device actually had a connection, defeating the point of // deliberately testing/working as if offline. if isManualOfflineModeEnabled { - if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) { + if let data = await cache.load(forKey: key), let cached = decryptedDecode(T.self, from: data) { return cached } throw OutlineAPIError.transport(URLError(.notConnectedToInternet)) @@ -629,7 +651,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { do { let result = try await RetryPolicy.withRetry { try await fetch() } recordSuccess(category: category) - if let data = try? encoder.encode(result) { + if let data = encryptedEncode(result) { await cache.save(data, forKey: key) } return result @@ -642,13 +664,48 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { if !RetryPolicy.isRetryable(error) { recordFailure(category: category, message: errorDescription(error)) } - if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) { + if let data = await cache.load(forKey: key), let cached = decryptedDecode(T.self, from: data) { return cached } throw error } } + private func resolvedEncryptionKey() throws -> SymmetricKey { + if let cachedEncryptionKey { return cachedEncryptionKey } + let key = try encryptionKeyStore.key() + cachedEncryptionKey = key + return key + } + + /// Encrypts before it ever reaches SwiftData. `nil` on any failure + /// (matches the shape of the plain `try? encoder.encode(...)` this + /// replaces) — a Keychain hiccup here should behave exactly like an + /// encode failure already did: skip caching this one value, not crash. + private func encryptedEncode(_ value: some Encodable) -> Data? { + guard let plain = try? encoder.encode(value), let key = try? resolvedEncryptionKey() else { return nil } + return try? CachePayloadCryptor.encrypt(plain, key: key) + } + + /// Decrypts + decodes a value previously written by `encryptedEncode`. + /// A row written before this feature shipped (still plaintext JSON, or + /// anything encrypted under a key that's since been cleared by + /// sign-out) fails to decrypt and returns `nil` here — same as any + /// other decode failure, so `cachedFetch` treats it as a cache miss and + /// refetches, not a crash. + private func decryptedDecode(_ type: T.Type, from data: Data) -> T? { + guard let key = try? resolvedEncryptionKey(), let plain = try? CachePayloadCryptor.decrypt(data, key: key) else { return nil } + return try? decoder.decode(type, from: plain) + } + + /// Throwing counterpart for `replay(_:)`, where a genuine decode + /// failure needs to propagate (so `flushPendingOperations` records it + /// as a failed sync attempt) instead of silently vanishing. + private func decryptedDecodeThrowing(_ type: T.Type, from data: Data) throws -> T { + let plain = try CachePayloadCryptor.decrypt(data, key: try resolvedEncryptionKey()) + return try decoder.decode(type, from: plain) + } + private func requestKey(_ prefix: String, _ request: some Encodable) -> String { guard let data = try? keyEncoder.encode(request), let json = String(data: data, encoding: .utf8) else { return prefix @@ -657,19 +714,19 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { } private func cacheDocument(_ document: OutlineDocument) async { - if let data = try? encoder.encode(document) { + if let data = encryptedEncode(document) { await cache.save(data, forKey: "document:\(document.id)") } } private func cacheCollection(_ collection: OutlineCollection) async { - if let data = try? encoder.encode(collection) { + if let data = encryptedEncode(collection) { await cache.save(data, forKey: "collection:\(collection.id)") } } private func enqueue(_ kind: PendingOperationKind, payload: some Encodable, id: String) async { - guard let data = try? encoder.encode(payload) else { return } + guard let data = encryptedEncode(payload) else { return } await cache.enqueueOperation(id: id, kind: kind.rawValue, payload: data) } @@ -708,7 +765,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument { guard let baseData = await cache.load(forKey: "document:\(request.id)"), - let base = try? decoder.decode(OutlineDocument.self, from: baseData) else { + let base = decryptedDecode(OutlineDocument.self, from: baseData) else { throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet)) } let mergedText = request.append == true ? base.text + (request.text ?? "") : (request.text ?? base.text) @@ -737,7 +794,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { // of it and the update would just fail every retry. if merged.id.hasPrefix("pending-"), let createOp = await cache.pendingOperations().first(where: { $0.id == merged.id && $0.kind == PendingOperationKind.createDocument.rawValue }), - let createRequest = try? decoder.decode(CreateDocumentRequest.self, from: createOp.payload) { + let createRequest = decryptedDecode(CreateDocumentRequest.self, from: createOp.payload) { let resolvedCreate = CreateDocumentRequest( title: merged.title, text: merged.text, @@ -759,7 +816,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { private func queueCollectionUpdate(_ request: UpdateCollectionRequest, dueTo error: Error?) async throws -> OutlineCollection { guard let baseData = await cache.load(forKey: "collection:\(request.id)"), - let base = try? decoder.decode(OutlineCollection.self, from: baseData) else { + let base = decryptedDecode(OutlineCollection.self, from: baseData) else { throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet)) } let merged = OutlineCollection( @@ -809,40 +866,40 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { } switch kind { case .createDocument: - let request = try decoder.decode(CreateDocumentRequest.self, from: operation.payload) + let request = try decryptedDecodeThrowing(CreateDocumentRequest.self, from: operation.payload) let result = try await live.createDocument(request) await cacheDocument(result) // The placeholder id (== operation.id) is now a dead orphan — // nothing server-side will ever answer to it again. await cache.removeCacheEntry(forKey: "document:\(operation.id)") case .updateDocument: - let request = try decoder.decode(UpdateDocumentRequest.self, from: operation.payload) + let request = try decryptedDecodeThrowing(UpdateDocumentRequest.self, from: operation.payload) let result = try await live.updateDocument(request) await cacheDocument(result) case .updateCollection: - let request = try decoder.decode(UpdateCollectionRequest.self, from: operation.payload) + let request = try decryptedDecodeThrowing(UpdateCollectionRequest.self, from: operation.payload) let result = try await live.updateCollection(request) await cacheCollection(result) case .createPin: - let request = try decoder.decode(CreatePinRequest.self, from: operation.payload) + let request = try decryptedDecodeThrowing(CreatePinRequest.self, from: operation.payload) _ = try await live.createPin(request) case .deletePin: - let request = try decoder.decode(IDPayload.self, from: operation.payload) + let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload) try await live.deletePin(id: request.id) case .createSubscription: - let request = try decoder.decode(CreateSubscriptionRequest.self, from: operation.payload) + let request = try decryptedDecodeThrowing(CreateSubscriptionRequest.self, from: operation.payload) _ = try await live.createSubscription(request) case .deleteSubscription: - let request = try decoder.decode(IDPayload.self, from: operation.payload) + let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload) try await live.deleteSubscription(id: request.id) case .starDocument: - let request = try decoder.decode(StarDocumentRequest.self, from: operation.payload) + let request = try decryptedDecodeThrowing(StarDocumentRequest.self, from: operation.payload) _ = try await live.starDocument(request) case .deleteStar: - let request = try decoder.decode(IDPayload.self, from: operation.payload) + let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload) try await live.deleteStar(id: request.id) case .starCollection: - let request = try decoder.decode(StarCollectionRequest.self, from: operation.payload) + let request = try decryptedDecodeThrowing(StarCollectionRequest.self, from: operation.payload) _ = try await live.starCollection(request) } } diff --git a/OutlineKit/Sources/OutlineKit/Caching/KeychainCacheEncryptionKeyStore.swift b/OutlineKit/Sources/OutlineKit/Caching/KeychainCacheEncryptionKeyStore.swift new file mode 100644 index 0000000..029329a --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Caching/KeychainCacheEncryptionKeyStore.swift @@ -0,0 +1,76 @@ +import Foundation +import CryptoKit +import Security + +/// Real Keychain-backed `CacheEncryptionKeyStoring`. The key is a plain +/// random 256-bit value — deliberately NOT derived from anything guessable +/// (bundle id, device id, etc.). It doesn't need deriving to survive an +/// uninstall/reinstall of the app either: Keychain items are scoped to the +/// requesting app's code signature (bundle id + team id), not its on-disk +/// presence, so reinstalling the same app regains access to the same +/// Keychain item automatically — exactly how `KeychainTokenStore`'s saved +/// token already survives a reinstall today. If the on-disk cache also +/// happens to survive (macOS doesn't clean out `~/Library/Containers` just +/// because the .app bundle was dragged to the Trash), the reinstalled app +/// can still decrypt it; a different app, or the same app after an explicit +/// sign-out (`clear()`), can't. +public final class KeychainCacheEncryptionKeyStore: CacheEncryptionKeyStoring, @unchecked Sendable { + private let service: String + private let account: String + + public init(service: String = "com.outpost.outlinekit", account: String = "offline-cache-encryption-key") { + self.service = service + self.account = account + } + + public func key() throws -> SymmetricKey { + if let existing = try? readKey() { return existing } + let generated = SymmetricKey(size: .bits256) + try store(generated) + return generated + } + + public func clear() throws { + let status = SecItemDelete(baseQuery() as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw TokenStoreError.deleteFailed(status) + } + } + + private func readKey() throws -> SymmetricKey { + var query = baseQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { + throw TokenStoreError.notFound + } + return SymmetricKey(data: data) + } + + private func store(_ key: SymmetricKey) throws { + let data = key.withUnsafeBytes { Data($0) } + let query = baseQuery() + + let existsStatus = SecItemCopyMatching(query as CFDictionary, nil) + if existsStatus == errSecSuccess { + let updateStatus = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary) + guard updateStatus == errSecSuccess else { throw TokenStoreError.storeFailed(updateStatus) } + } else { + var addQuery = query + addQuery[kSecValueData as String] = data + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + guard addStatus == errSecSuccess else { throw TokenStoreError.storeFailed(addStatus) } + } + } + + private func baseQuery() -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + } +} diff --git a/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift b/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift index 71ec96c..0823045 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift @@ -67,6 +67,22 @@ public actor OfflineCacheStore { try? modelContext.save() } + /// Wipes both the read-through cache AND the pending write queue — + /// used only on sign-out (see `SessionStore.signOut()`), since the + /// encryption key backing every row here is about to be cleared too. + /// Anything left un-wiped would just become permanently undecryptable + /// garbage instead of readable by the next account signed in on this + /// machine — deliberately more thorough than `clearAll()` (Settings' + /// "Clear All Cache", which never touches pending writes; that button + /// shouldn't silently discard someone's unsynced edits). + public func clearEverything() { + let cachedDescriptor = FetchDescriptor() + (try? modelContext.fetch(cachedDescriptor))?.forEach { modelContext.delete($0) } + let pendingDescriptor = FetchDescriptor() + (try? modelContext.fetch(pendingDescriptor))?.forEach { modelContext.delete($0) } + try? modelContext.save() + } + // MARK: - Offline write queue /// Upserts by `id` — a second call with the same id (an edit coalescing diff --git a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift index 8df1d93..f6b9471 100644 --- a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift @@ -1,4 +1,5 @@ import XCTest +import CryptoKit @testable import OutlineKit private struct NotStubbed: Error {} @@ -116,6 +117,22 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable private struct StubTransportError: Error {} +/// In-memory `CacheEncryptionKeyStoring` — tests must never touch the real +/// Keychain (would pollute the developer's machine and can hang/fail in a +/// sandboxed CI runner with no Keychain access). +private final class StaticCacheEncryptionKeyStore: CacheEncryptionKeyStoring, @unchecked Sendable { + private var stored: SymmetricKey? + + func key() throws -> SymmetricKey { + if let stored { return stored } + let generated = SymmetricKey(size: .bits256) + stored = generated + return generated + } + + func clear() throws { stored = nil } +} + final class CachingOutlineAPIClientTests: XCTestCase { private func makeCache() throws -> OfflineCacheStore { OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true)) @@ -145,7 +162,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { let stub = StubOutlineAPIClient() let document = makeDocument() stub.documentInfoHandler = { _ in document } - let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore()) let result = try await sut.documentInfo(id: "doc-1") @@ -161,7 +178,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { if callCount == 1 { return document } throw StubTransportError() } - let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore()) // First call succeeds and populates the cache. _ = try await sut.documentInfo(id: "doc-1") @@ -175,7 +192,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { func testDocumentInfoRethrowsWhenLiveFailsAndCacheIsEmpty() async throws { let stub = StubOutlineAPIClient() stub.documentInfoHandler = { _ in throw StubTransportError() } - let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore()) do { _ = try await sut.documentInfo(id: "doc-1") @@ -194,7 +211,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { if callCount == 1 { return collections } throw StubTransportError() } - let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore()) _ = try await sut.listCollections(offset: 0, limit: 25) let result = try await sut.listCollections(offset: 0, limit: 25) @@ -207,7 +224,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { let docOne = makeDocument(id: "doc-1", title: "One") let docTwo = makeDocument(id: "doc-2", title: "Two") stub.documentInfoHandler = { id in id == "doc-1" ? docOne : docTwo } - let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore()) _ = try await sut.documentInfo(id: "doc-1") _ = try await sut.documentInfo(id: "doc-2") @@ -227,7 +244,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { let original = makeDocument(id: "doc-1", title: "Original") stub.documentInfoHandler = { _ in original } stub.updateDocumentHandler = { _ in throw StubTransportError() } - let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore()) // Populate the cache with the base document first (as a real open would). _ = try await sut.documentInfo(id: "doc-1") @@ -248,7 +265,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { func testUpdateDocumentRethrowsWhenDocumentWasNeverCached() async throws { let stub = StubOutlineAPIClient() stub.updateDocumentHandler = { _ in throw StubTransportError() } - let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore()) do { _ = try await sut.updateDocument(UpdateDocumentRequest(id: "never-seen", title: "x")) @@ -263,7 +280,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { let original = makeDocument(id: "doc-1", title: "Original") stub.documentInfoHandler = { _ in original } stub.updateDocumentHandler = { _ in throw StubTransportError() } - let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore()) _ = try await sut.documentInfo(id: "doc-1") _ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "First Edit")) @@ -276,7 +293,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { func testPinThenUnpinBeforeSyncCancelsOutWithoutQueuingADelete() async throws { let stub = StubOutlineAPIClient() stub.createPinHandler = { _ in throw StubTransportError() } - let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore()) let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1")) XCTAssertTrue(pin.id.hasPrefix("pending-")) @@ -294,7 +311,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { let original = makeDocument(id: "doc-1", title: "Original") stub.documentInfoHandler = { _ in original } stub.updateDocumentHandler = { _ in throw StubTransportError() } - let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore()) _ = try await sut.documentInfo(id: "doc-1") _ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline")) @@ -318,7 +335,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { let original = makeDocument(id: "doc-1", title: "Original") stub.documentInfoHandler = { _ in original } stub.updateDocumentHandler = { _ in throw StubTransportError() } - let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore()) _ = try await sut.documentInfo(id: "doc-1") _ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline")) @@ -345,7 +362,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { liveCallCount += 1 return OutlinePin(id: "real-id", documentId: "doc-1", collectionId: nil, index: nil) } - let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), defaults: defaults) + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), defaults: defaults, encryptionKeyStore: StaticCacheEncryptionKeyStore()) let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1")) @@ -370,7 +387,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { return [cachedCollection] } let cache = try makeCache() - let sut = CachingOutlineAPIClient(live: stub, cache: cache, defaults: defaults) + let sut = CachingOutlineAPIClient(live: stub, cache: cache, defaults: defaults, encryptionKeyStore: StaticCacheEncryptionKeyStore()) // Online first — populates the cache normally. let firstResult = try await sut.listCollections(offset: 0, limit: 25) @@ -389,7 +406,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { func testCacheStorageSummaryReflectsCachedItems() async throws { let stub = StubOutlineAPIClient() stub.documentInfoHandler = { _ in self.makeDocument() } - let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore()) _ = try await sut.documentInfo(id: "doc-1") let summary = await sut.cacheStorageSummary() @@ -418,7 +435,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { return (0..