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.
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
/// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with offline
|
/// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with offline
|
||||||
/// support at the existing protocol boundary, so no view model needs to know
|
/// 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 encoder: JSONEncoder
|
||||||
private let decoder: JSONDecoder
|
private let decoder: JSONDecoder
|
||||||
private let keyEncoder: JSONEncoder
|
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` /
|
/// Recent failure timestamps per category — see `recordFailure` /
|
||||||
/// `repeatedFailureSummaries()`. A category only shows up there once it's
|
/// `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 failureWindow: TimeInterval = 300
|
||||||
private let failureThreshold: Int = 3
|
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.live = live
|
||||||
self.cache = cache
|
self.cache = cache
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
|
self.encryptionKeyStore = encryptionKeyStore
|
||||||
|
|
||||||
let encoder = JSONEncoder()
|
let encoder = JSONEncoder()
|
||||||
encoder.dateEncodingStrategy = .iso8601
|
encoder.dateEncodingStrategy = .iso8601
|
||||||
@@ -495,6 +508,15 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
await cache.clearAll()
|
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
|
/// Replays every queued operation against `live`, in the order they were
|
||||||
/// queued. Each is independent — one failing doesn't block the rest.
|
/// queued. Each is independent — one failing doesn't block the rest.
|
||||||
public func flushPendingOperations() async -> SyncFlushSummary {
|
public func flushPendingOperations() async -> SyncFlushSummary {
|
||||||
@@ -603,13 +625,13 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
/// SwiftData read, no network involved.
|
/// SwiftData read, no network involved.
|
||||||
public func cachedDocumentsIndex() async -> [OutlineDocument] {
|
public func cachedDocumentsIndex() async -> [OutlineDocument] {
|
||||||
let payloads = await cache.loadAll(keyPrefix: "document:")
|
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.
|
/// Every individually cached collection from the last Full Local Sync.
|
||||||
public func cachedCollectionsIndex() async -> [OutlineCollection] {
|
public func cachedCollectionsIndex() async -> [OutlineCollection] {
|
||||||
let payloads = await cache.loadAll(keyPrefix: "collection:")
|
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
|
// MARK: - Helpers
|
||||||
@@ -621,7 +643,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
// the device actually had a connection, defeating the point of
|
// the device actually had a connection, defeating the point of
|
||||||
// deliberately testing/working as if offline.
|
// deliberately testing/working as if offline.
|
||||||
if isManualOfflineModeEnabled {
|
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
|
return cached
|
||||||
}
|
}
|
||||||
throw OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
throw OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
||||||
@@ -629,7 +651,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
do {
|
do {
|
||||||
let result = try await RetryPolicy.withRetry { try await fetch() }
|
let result = try await RetryPolicy.withRetry { try await fetch() }
|
||||||
recordSuccess(category: category)
|
recordSuccess(category: category)
|
||||||
if let data = try? encoder.encode(result) {
|
if let data = encryptedEncode(result) {
|
||||||
await cache.save(data, forKey: key)
|
await cache.save(data, forKey: key)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -642,13 +664,48 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
if !RetryPolicy.isRetryable(error) {
|
if !RetryPolicy.isRetryable(error) {
|
||||||
recordFailure(category: category, message: errorDescription(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
|
return cached
|
||||||
}
|
}
|
||||||
throw error
|
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<T: Decodable>(_ 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<T: Decodable>(_ 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 {
|
private func requestKey(_ prefix: String, _ request: some Encodable) -> String {
|
||||||
guard let data = try? keyEncoder.encode(request), let json = String(data: data, encoding: .utf8) else {
|
guard let data = try? keyEncoder.encode(request), let json = String(data: data, encoding: .utf8) else {
|
||||||
return prefix
|
return prefix
|
||||||
@@ -657,19 +714,19 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func cacheDocument(_ document: OutlineDocument) async {
|
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)")
|
await cache.save(data, forKey: "document:\(document.id)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cacheCollection(_ collection: OutlineCollection) async {
|
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)")
|
await cache.save(data, forKey: "collection:\(collection.id)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func enqueue(_ kind: PendingOperationKind, payload: some Encodable, id: String) async {
|
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)
|
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 {
|
private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument {
|
||||||
guard let baseData = await cache.load(forKey: "document:\(request.id)"),
|
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))
|
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
||||||
}
|
}
|
||||||
let mergedText = request.append == true ? base.text + (request.text ?? "") : (request.text ?? base.text)
|
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.
|
// of it and the update would just fail every retry.
|
||||||
if merged.id.hasPrefix("pending-"),
|
if merged.id.hasPrefix("pending-"),
|
||||||
let createOp = await cache.pendingOperations().first(where: { $0.id == merged.id && $0.kind == PendingOperationKind.createDocument.rawValue }),
|
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(
|
let resolvedCreate = CreateDocumentRequest(
|
||||||
title: merged.title,
|
title: merged.title,
|
||||||
text: merged.text,
|
text: merged.text,
|
||||||
@@ -759,7 +816,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
|
|
||||||
private func queueCollectionUpdate(_ request: UpdateCollectionRequest, dueTo error: Error?) async throws -> OutlineCollection {
|
private func queueCollectionUpdate(_ request: UpdateCollectionRequest, dueTo error: Error?) async throws -> OutlineCollection {
|
||||||
guard let baseData = await cache.load(forKey: "collection:\(request.id)"),
|
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))
|
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
||||||
}
|
}
|
||||||
let merged = OutlineCollection(
|
let merged = OutlineCollection(
|
||||||
@@ -809,40 +866,40 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
}
|
}
|
||||||
switch kind {
|
switch kind {
|
||||||
case .createDocument:
|
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)
|
let result = try await live.createDocument(request)
|
||||||
await cacheDocument(result)
|
await cacheDocument(result)
|
||||||
// The placeholder id (== operation.id) is now a dead orphan —
|
// The placeholder id (== operation.id) is now a dead orphan —
|
||||||
// nothing server-side will ever answer to it again.
|
// nothing server-side will ever answer to it again.
|
||||||
await cache.removeCacheEntry(forKey: "document:\(operation.id)")
|
await cache.removeCacheEntry(forKey: "document:\(operation.id)")
|
||||||
case .updateDocument:
|
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)
|
let result = try await live.updateDocument(request)
|
||||||
await cacheDocument(result)
|
await cacheDocument(result)
|
||||||
case .updateCollection:
|
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)
|
let result = try await live.updateCollection(request)
|
||||||
await cacheCollection(result)
|
await cacheCollection(result)
|
||||||
case .createPin:
|
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)
|
_ = try await live.createPin(request)
|
||||||
case .deletePin:
|
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)
|
try await live.deletePin(id: request.id)
|
||||||
case .createSubscription:
|
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)
|
_ = try await live.createSubscription(request)
|
||||||
case .deleteSubscription:
|
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)
|
try await live.deleteSubscription(id: request.id)
|
||||||
case .starDocument:
|
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)
|
_ = try await live.starDocument(request)
|
||||||
case .deleteStar:
|
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)
|
try await live.deleteStar(id: request.id)
|
||||||
case .starCollection:
|
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)
|
_ = try await live.starCollection(request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,22 @@ public actor OfflineCacheStore {
|
|||||||
try? modelContext.save()
|
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<CachedPayload>()
|
||||||
|
(try? modelContext.fetch(cachedDescriptor))?.forEach { modelContext.delete($0) }
|
||||||
|
let pendingDescriptor = FetchDescriptor<PendingOperation>()
|
||||||
|
(try? modelContext.fetch(pendingDescriptor))?.forEach { modelContext.delete($0) }
|
||||||
|
try? modelContext.save()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Offline write queue
|
// MARK: - Offline write queue
|
||||||
|
|
||||||
/// Upserts by `id` — a second call with the same id (an edit coalescing
|
/// Upserts by `id` — a second call with the same id (an edit coalescing
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
import CryptoKit
|
||||||
@testable import OutlineKit
|
@testable import OutlineKit
|
||||||
|
|
||||||
private struct NotStubbed: Error {}
|
private struct NotStubbed: Error {}
|
||||||
@@ -116,6 +117,22 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
|
|||||||
|
|
||||||
private struct StubTransportError: Error {}
|
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 {
|
final class CachingOutlineAPIClientTests: XCTestCase {
|
||||||
private func makeCache() throws -> OfflineCacheStore {
|
private func makeCache() throws -> OfflineCacheStore {
|
||||||
OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true))
|
OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true))
|
||||||
@@ -145,7 +162,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
let document = makeDocument()
|
let document = makeDocument()
|
||||||
stub.documentInfoHandler = { _ in document }
|
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")
|
let result = try await sut.documentInfo(id: "doc-1")
|
||||||
|
|
||||||
@@ -161,7 +178,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
if callCount == 1 { return document }
|
if callCount == 1 { return document }
|
||||||
throw StubTransportError()
|
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.
|
// First call succeeds and populates the cache.
|
||||||
_ = try await sut.documentInfo(id: "doc-1")
|
_ = try await sut.documentInfo(id: "doc-1")
|
||||||
@@ -175,7 +192,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
func testDocumentInfoRethrowsWhenLiveFailsAndCacheIsEmpty() async throws {
|
func testDocumentInfoRethrowsWhenLiveFailsAndCacheIsEmpty() async throws {
|
||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try await sut.documentInfo(id: "doc-1")
|
_ = try await sut.documentInfo(id: "doc-1")
|
||||||
@@ -194,7 +211,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
if callCount == 1 { return collections }
|
if callCount == 1 { return collections }
|
||||||
throw StubTransportError()
|
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)
|
_ = try await sut.listCollections(offset: 0, limit: 25)
|
||||||
let result = 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 docOne = makeDocument(id: "doc-1", title: "One")
|
||||||
let docTwo = makeDocument(id: "doc-2", title: "Two")
|
let docTwo = makeDocument(id: "doc-2", title: "Two")
|
||||||
stub.documentInfoHandler = { id in id == "doc-1" ? docOne : docTwo }
|
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-1")
|
||||||
_ = try await sut.documentInfo(id: "doc-2")
|
_ = try await sut.documentInfo(id: "doc-2")
|
||||||
@@ -227,7 +244,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
let original = makeDocument(id: "doc-1", title: "Original")
|
let original = makeDocument(id: "doc-1", title: "Original")
|
||||||
stub.documentInfoHandler = { _ in original }
|
stub.documentInfoHandler = { _ in original }
|
||||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
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).
|
// Populate the cache with the base document first (as a real open would).
|
||||||
_ = try await sut.documentInfo(id: "doc-1")
|
_ = try await sut.documentInfo(id: "doc-1")
|
||||||
@@ -248,7 +265,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
func testUpdateDocumentRethrowsWhenDocumentWasNeverCached() async throws {
|
func testUpdateDocumentRethrowsWhenDocumentWasNeverCached() async throws {
|
||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "never-seen", title: "x"))
|
_ = 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")
|
let original = makeDocument(id: "doc-1", title: "Original")
|
||||||
stub.documentInfoHandler = { _ in original }
|
stub.documentInfoHandler = { _ in original }
|
||||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
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.documentInfo(id: "doc-1")
|
||||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "First Edit"))
|
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "First Edit"))
|
||||||
@@ -276,7 +293,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
func testPinThenUnpinBeforeSyncCancelsOutWithoutQueuingADelete() async throws {
|
func testPinThenUnpinBeforeSyncCancelsOutWithoutQueuingADelete() async throws {
|
||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
stub.createPinHandler = { _ in throw StubTransportError() }
|
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"))
|
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
|
||||||
XCTAssertTrue(pin.id.hasPrefix("pending-"))
|
XCTAssertTrue(pin.id.hasPrefix("pending-"))
|
||||||
@@ -294,7 +311,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
let original = makeDocument(id: "doc-1", title: "Original")
|
let original = makeDocument(id: "doc-1", title: "Original")
|
||||||
stub.documentInfoHandler = { _ in original }
|
stub.documentInfoHandler = { _ in original }
|
||||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
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.documentInfo(id: "doc-1")
|
||||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
|
_ = 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")
|
let original = makeDocument(id: "doc-1", title: "Original")
|
||||||
stub.documentInfoHandler = { _ in original }
|
stub.documentInfoHandler = { _ in original }
|
||||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
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.documentInfo(id: "doc-1")
|
||||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
|
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
|
||||||
@@ -345,7 +362,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
liveCallCount += 1
|
liveCallCount += 1
|
||||||
return OutlinePin(id: "real-id", documentId: "doc-1", collectionId: nil, index: nil)
|
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"))
|
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
|
||||||
|
|
||||||
@@ -370,7 +387,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
return [cachedCollection]
|
return [cachedCollection]
|
||||||
}
|
}
|
||||||
let cache = try makeCache()
|
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.
|
// Online first — populates the cache normally.
|
||||||
let firstResult = try await sut.listCollections(offset: 0, limit: 25)
|
let firstResult = try await sut.listCollections(offset: 0, limit: 25)
|
||||||
@@ -389,7 +406,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
func testCacheStorageSummaryReflectsCachedItems() async throws {
|
func testCacheStorageSummaryReflectsCachedItems() async throws {
|
||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
stub.documentInfoHandler = { _ in self.makeDocument() }
|
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")
|
_ = try await sut.documentInfo(id: "doc-1")
|
||||||
let summary = await sut.cacheStorageSummary()
|
let summary = await sut.cacheStorageSummary()
|
||||||
@@ -418,7 +435,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
return (0..<count).map { self.makeCollection(id: "col-\(offset + $0)") }
|
return (0..<count).map { self.makeCollection(id: "col-\(offset + $0)") }
|
||||||
}
|
}
|
||||||
stub.listDocumentsHandler = { _, _, _, _ in [] }
|
stub.listDocumentsHandler = { _, _, _, _ in [] }
|
||||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
let summary = await sut.performFullSync()
|
let summary = await sut.performFullSync()
|
||||||
|
|
||||||
@@ -439,7 +456,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
return offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : []
|
return offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : []
|
||||||
}
|
}
|
||||||
let cache = try makeCache()
|
let cache = try makeCache()
|
||||||
let sut = CachingOutlineAPIClient(live: stub, cache: cache)
|
let sut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
let summary = await sut.performFullSync()
|
let summary = await sut.performFullSync()
|
||||||
XCTAssertEqual(summary.documentsCount, 2)
|
XCTAssertEqual(summary.documentsCount, 2)
|
||||||
@@ -469,7 +486,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let cache = try makeCache()
|
let cache = try makeCache()
|
||||||
let sut = CachingOutlineAPIClient(live: stub, cache: cache)
|
let sut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
let summary = await sut.performFullSync()
|
let summary = await sut.performFullSync()
|
||||||
|
|
||||||
@@ -484,7 +501,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
|
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
|
||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
||||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
let created = try await sut.createDocument(
|
let created = try await sut.createDocument(
|
||||||
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
|
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
|
||||||
@@ -508,7 +525,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
||||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
let created = try await sut.createDocument(
|
let created = try await sut.createDocument(
|
||||||
CreateDocumentRequest(title: "Untitled", text: "", collectionId: "col-1")
|
CreateDocumentRequest(title: "Untitled", text: "", collectionId: "col-1")
|
||||||
@@ -528,7 +545,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
func testFlushingAPendingCreateReconcilesThePlaceholderIdToTheRealOne() async throws {
|
func testFlushingAPendingCreateReconcilesThePlaceholderIdToTheRealOne() async throws {
|
||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
||||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
let created = try await sut.createDocument(
|
let created = try await sut.createDocument(
|
||||||
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
|
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
|
||||||
@@ -563,7 +580,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
func testRepeatedDecodingFailuresSurfaceAfterThreshold() async throws {
|
func testRepeatedDecodingFailuresSurfaceAfterThreshold() async throws {
|
||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
|
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
|
||||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
for _ in 0..<3 {
|
for _ in 0..<3 {
|
||||||
_ = try? await sut.documentInfo(id: "doc-1")
|
_ = try? await sut.documentInfo(id: "doc-1")
|
||||||
@@ -580,7 +597,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
func testFewerThanThresholdFailuresDoNotSurface() async throws {
|
func testFewerThanThresholdFailuresDoNotSurface() async throws {
|
||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
|
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
|
||||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
for _ in 0..<2 {
|
for _ in 0..<2 {
|
||||||
_ = try? await sut.documentInfo(id: "doc-1")
|
_ = try? await sut.documentInfo(id: "doc-1")
|
||||||
@@ -601,7 +618,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
if callCount <= 3 { throw OutlineAPIError.decoding(NotStubbed()) }
|
if callCount <= 3 { throw OutlineAPIError.decoding(NotStubbed()) }
|
||||||
return document
|
return document
|
||||||
}
|
}
|
||||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
for _ in 0..<3 {
|
for _ in 0..<3 {
|
||||||
_ = try? await sut.documentInfo(id: "doc-1")
|
_ = try? await sut.documentInfo(id: "doc-1")
|
||||||
@@ -621,7 +638,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
func testTransportFailuresDoNotCountTowardTheRepeatedFailureLog() async throws {
|
func testTransportFailuresDoNotCountTowardTheRepeatedFailureLog() async throws {
|
||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
stub.documentInfoHandler = { _ in throw OutlineAPIError.transport(URLError(.notConnectedToInternet)) }
|
stub.documentInfoHandler = { _ in throw OutlineAPIError.transport(URLError(.notConnectedToInternet)) }
|
||||||
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-1")
|
||||||
|
|
||||||
@@ -637,7 +654,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
|
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
|
||||||
stub.createPinHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
|
stub.createPinHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
|
||||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
// Document reads cross the threshold...
|
// Document reads cross the threshold...
|
||||||
for _ in 0..<3 {
|
for _ in 0..<3 {
|
||||||
@@ -651,4 +668,71 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
let summaries = await sut.repeatedFailureSummaries()
|
let summaries = await sut.repeatedFailureSummaries()
|
||||||
XCTAssertEqual(summaries.map(\.category), ["document"])
|
XCTAssertEqual(summaries.map(\.category), ["document"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - At-rest encryption
|
||||||
|
|
||||||
|
func testCachedPayloadIsNotStoredAsPlaintextJSON() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
let document = makeDocument(title: "Secret Title")
|
||||||
|
stub.documentInfoHandler = { _ in document }
|
||||||
|
let cache = try makeCache()
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
_ = try await sut.documentInfo(id: "doc-1")
|
||||||
|
|
||||||
|
let raw = await cache.load(forKey: "document:doc-1")
|
||||||
|
XCTAssertNotNil(raw)
|
||||||
|
// A plain JSON encode would contain the literal title text in the
|
||||||
|
// clear - ciphertext shouldn't, and shouldn't even parse as JSON.
|
||||||
|
XCTAssertNil(String(data: raw!, encoding: .utf8)?.range(of: "Secret Title"))
|
||||||
|
XCTAssertThrowsError(try JSONDecoder().decode(OutlineDocument.self, from: raw!))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simulates sign-out (key cleared) followed by a fresh sign-in (a new
|
||||||
|
/// `CachingOutlineAPIClient` instance, same underlying on-disk cache,
|
||||||
|
/// same shape as `SessionStore.makeAPIClient` always creating a new
|
||||||
|
/// instance) — anything still on disk from before is unreadable under
|
||||||
|
/// the new key, which is the entire point of clearing it on sign-out.
|
||||||
|
func testCacheIsUnreadableAfterTheEncryptionKeyIsCleared() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
stub.documentInfoHandler = { _ in self.makeDocument() }
|
||||||
|
let cache = try makeCache()
|
||||||
|
let keyStore = StaticCacheEncryptionKeyStore()
|
||||||
|
let beforeSignOut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: keyStore)
|
||||||
|
_ = try await beforeSignOut.documentInfo(id: "doc-1")
|
||||||
|
|
||||||
|
try keyStore.clear()
|
||||||
|
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||||
|
let afterSignIn = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: keyStore)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await afterSignIn.documentInfo(id: "doc-1")
|
||||||
|
XCTFail("Expected the now-undecryptable cache entry to be unusable")
|
||||||
|
} catch is StubTransportError {
|
||||||
|
// expected — live fails, and the leftover cache entry can't be
|
||||||
|
// decrypted under the new key either, so there's no fallback.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClearEverythingForSignOutWipesBothCacheAndPendingQueue() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
stub.documentInfoHandler = { _ in self.makeDocument() }
|
||||||
|
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||||
|
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: "Offline edit"))
|
||||||
|
|
||||||
|
let summaryBefore = await sut.cacheStorageSummary()
|
||||||
|
let pendingBefore = await sut.pendingOperations()
|
||||||
|
XCTAssertGreaterThan(summaryBefore.itemCount, 0)
|
||||||
|
XCTAssertFalse(pendingBefore.isEmpty)
|
||||||
|
|
||||||
|
await sut.clearEverythingForSignOut()
|
||||||
|
|
||||||
|
let summaryAfter = await sut.cacheStorageSummary()
|
||||||
|
let pendingAfter = await sut.pendingOperations()
|
||||||
|
XCTAssertEqual(summaryAfter.itemCount, 0)
|
||||||
|
XCTAssertTrue(pendingAfter.isEmpty)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user