diff --git a/BitwardenShared/Core/Autofill/Services/AutofillCredentialService+AppExtensionTests.swift b/BitwardenShared/Core/Autofill/Services/AutofillCredentialService+AppExtensionTests.swift index 5e6728ab14..cd6056bd42 100644 --- a/BitwardenShared/Core/Autofill/Services/AutofillCredentialService+AppExtensionTests.swift +++ b/BitwardenShared/Core/Autofill/Services/AutofillCredentialService+AppExtensionTests.swift @@ -14,7 +14,7 @@ import XCTest /// initialization to see if the subscription to the `VaultTimeoutService` is necessary. So it's easier to test /// having a new class test specifically for it. @MainActor -class AutofillCredentialServiceAppExtensionTests: BitwardenTestCase { +class AutofillCredentialServiceAppExtensionTests: BitwardenTestCase { // swiftlint:disable:this type_body_length // MARK: Properties var appContextHelper: MockAppContextHelper! @@ -101,6 +101,188 @@ class AutofillCredentialServiceAppExtensionTests: BitwardenTestCase { // MARK: Tests + /// `subscribeToCipherChanges()` inserts credentials in the store when a cipher is inserted. + func test_subscribeToCipherChanges_insert() async throws { + prepareDataForIdentitiesReplacement() + stateService.activeAccount = .fixture(profile: .fixture(userId: "1")) + + try await waitForAsync { [weak self] in + guard let self else { return false } + return subject.hasCipherChangesSubscription + } + + // Send an inserted cipher + cipherService.cipherChangesSubject.send( + .inserted(.fixture( + id: "1", + login: .fixture( + password: "password123", + uris: [.fixture(uri: "bitwarden.com")], + username: "user@bitwarden.com", + ), + )), + ) + + try await waitForAsync { [weak self] in + guard let self else { return false } + return identityStore.saveCredentialIdentitiesCalled + } + + XCTAssertTrue(identityStore.saveCredentialIdentitiesCalled) + XCTAssertEqual( + identityStore.saveCredentialIdentitiesIdentities, + [ + .password(PasswordCredentialIdentity(id: "1", uri: "bitwarden.com", username: "user@bitwarden.com")), + ], + ) + } + + /// `subscribeToCipherChanges()` updates credentials in the store when a cipher is updated. + func test_subscribeToCipherChanges_update() async throws { + prepareDataForIdentitiesReplacement() + stateService.activeAccount = .fixture(profile: .fixture(userId: "1")) + + try await waitForAsync { [weak self] in + guard let self else { return false } + return subject.hasCipherChangesSubscription + } + credentialIdentityFactory.createCredentialIdentitiesMocker + .withResult { cipher in + if cipher.id == "3" { + [ + .password( + PasswordCredentialIdentity( + id: "3", + uri: "example.com", + username: "updated@example.com", + ), + ), + ] + } else { + [] + } + } + + // Send an updated cipher + cipherService.cipherChangesSubject.send( + .updated(.fixture( + id: "3", + login: .fixture( + password: "newpassword", + uris: [.fixture(uri: "example.com")], + username: "updated@example.com", + ), + )), + ) + + try await waitForAsync { [weak self] in + guard let self else { return false } + return identityStore.saveCredentialIdentitiesCalled + } + + XCTAssertTrue(identityStore.saveCredentialIdentitiesCalled) + XCTAssertEqual( + identityStore.saveCredentialIdentitiesIdentities, + [ + .password(PasswordCredentialIdentity(id: "3", uri: "example.com", username: "updated@example.com")), + ], + ) + } + + /// `subscribeToCipherChanges()` removes credentials from the store when a cipher is deleted. + func test_subscribeToCipherChanges_delete() async throws { + prepareDataForIdentitiesReplacement() + stateService.activeAccount = .fixture(profile: .fixture(userId: "1")) + + try await waitForAsync { [weak self] in + guard let self else { return false } + return subject.hasCipherChangesSubscription + } + + // Send a deleted cipher + cipherService.cipherChangesSubject.send( + .deleted(.fixture( + id: "1", + login: .fixture( + password: "password123", + uris: [.fixture(uri: "bitwarden.com")], + username: "user@bitwarden.com", + ), + )), + ) + + try await waitForAsync { [weak self] in + guard let self else { return false } + return identityStore.removeCredentialIdentitiesCalled + } + + XCTAssertTrue(identityStore.removeCredentialIdentitiesCalled) + XCTAssertEqual( + identityStore.removeCredentialIdentitiesIdentities, + [ + .password(PasswordCredentialIdentity(id: "1", uri: "bitwarden.com", username: "user@bitwarden.com")), + ], + ) + } + + /// `subscribeToCipherChanges()` does not update the store when identity store is disabled. + func test_subscribeToCipherChanges_storeDisabled() async throws { + prepareDataForIdentitiesReplacement() + stateService.activeAccount = .fixture(profile: .fixture(userId: "1")) + identityStore.state.mockIsEnabled = false + + try await waitForAsync { [weak self] in + guard let self else { return false } + return subject.hasCipherChangesSubscription + } + + // Send an inserted cipher + cipherService.cipherChangesSubject.send( + .inserted(.fixture( + id: "1", + login: .fixture( + password: "password123", + uris: [.fixture(uri: "bitwarden.com")], + username: "user@bitwarden.com", + ), + )), + ) + + // Wait a bit to ensure no changes are processed + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + XCTAssertFalse(identityStore.saveCredentialIdentitiesCalled) + } + + /// `subscribeToCipherChanges()` does not update the store when incremental updates are not supported. + func test_subscribeToCipherChanges_incrementalUpdatesNotSupported() async throws { + prepareDataForIdentitiesReplacement() + stateService.activeAccount = .fixture(profile: .fixture(userId: "1")) + identityStore.state.mockSupportsIncrementalUpdates = false + + try await waitForAsync { [weak self] in + guard let self else { return false } + return subject.hasCipherChangesSubscription + } + + // Send an inserted cipher + cipherService.cipherChangesSubject.send( + .inserted(.fixture( + id: "1", + login: .fixture( + password: "password123", + uris: [.fixture(uri: "bitwarden.com")], + username: "user@bitwarden.com", + ), + )), + ) + + // Wait a bit to ensure no changes are processed + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + XCTAssertFalse(identityStore.saveCredentialIdentitiesCalled) + } + /// `syncIdentities(vaultLockStatus:)` doesn't update the credential identity store with the identities /// from the user's vault when the app context is `.appExtension`. func test_syncIdentities_appExtensionContext() { diff --git a/BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift b/BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift index 15db90bf4e..5886fc52ec 100644 --- a/BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift +++ b/BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift @@ -86,11 +86,21 @@ protocol AutofillCredentialService: AnyObject { /// A default implementation of an `AutofillCredentialService`. /// class DefaultAutofillCredentialService { + // MARK: Computed properties + + /// Whether the cipher changes publisher has been subscribed to. This is useful for tests. + var hasCipherChangesSubscription: Bool { + cipherChangesSubscriptionTask != nil && !(cipherChangesSubscriptionTask?.isCancelled ?? true) + } + // MARK: Private Properties /// Helper to know about the app context. private let appContextHelper: AppContextHelper + /// A reference to the task used to track cipher changes. + private var cipherChangesSubscriptionTask: Task? + /// The service used to manage syncing and updates to the user's ciphers. private let cipherService: CipherService @@ -191,6 +201,11 @@ class DefaultAutofillCredentialService { self.vaultTimeoutService = vaultTimeoutService guard appContextHelper.appContext == .mainApp else { + // NOTE: [PM-28855] when in the context of iOS extensions + // subscribe to individual cipher changes to update the local OS store + // to improve memory performance and avoid crashes by not loading + // nor potentially decrypting the whole vault. + subscribeToCipherChanges() return } @@ -201,8 +216,38 @@ class DefaultAutofillCredentialService { } } + /// Deinitializes this service. + deinit { + cipherChangesSubscriptionTask?.cancel() + cipherChangesSubscriptionTask = nil + } + // MARK: Private Methods + /// Subscribes to cipher changes to update the internal `ASCredentialIdentityStore`. + private func subscribeToCipherChanges() { + cipherChangesSubscriptionTask?.cancel() + cipherChangesSubscriptionTask = Task { [weak self] in + guard let self, #available(iOS 17.0, *) else { + return + } + + do { + for try await cipherChange in try await cipherService.cipherChangesPublisher().values { + switch cipherChange { + case let .deleted(cipher): + await removeCredentialsInStore(for: cipher) + case let .inserted(cipher), + let .updated(cipher): + await upsertCredentialsInStore(for: cipher) + } + } + } catch { + errorReporter.log(error: error) + } + } + } + /// Synchronizes the identities in the identity store for the user with the specified lock status. /// /// - If the user's vault is unlocked, identities in the store will be replaced by the user's identities. @@ -467,6 +512,30 @@ extension DefaultAutofillCredentialService: AutofillCredentialService { return try await clientService.vault().ciphers().decrypt(cipher: encryptedCipher) } + /// Gets the credential identities for a given cipher. + /// - Parameter cipher: The cipher to get the credential identities from. + /// - Returns: A list of credential identities for the cipher. + @available(iOS 17.0, *) + private func getCredentialIdentities(from cipher: Cipher) async throws -> [ASCredentialIdentity] { + var identities = [ASCredentialIdentity]() + let decryptedCipher = try await clientService.vault().ciphers().decrypt(cipher: cipher) + + let newIdentities = await credentialIdentityFactory.createCredentialIdentities(from: decryptedCipher) + identities.append(contentsOf: newIdentities) + + let fido2Identities = try await clientService.platform().fido2() + .authenticator( + userInterface: fido2UserInterfaceHelper, + credentialStore: fido2CredentialStore, + ) + .credentialsForAutofill() + .filter { $0.cipherId == cipher.id } + .compactMap { $0.toFido2CredentialIdentity() } + identities.append(contentsOf: fido2Identities) + + return identities + } + /// Provides a Fido2 credential based for the given request. /// - Parameters: /// - request: Request to get the assertion credential. @@ -525,6 +594,48 @@ extension DefaultAutofillCredentialService: AutofillCredentialService { throw error } } + + /// Removes the credential identities associated with the cipher on the store. + /// - Parameter cipher: The cipher to get the credential identities from. + @available(iOS 17.0, *) + private func removeCredentialsInStore(for cipher: Cipher) async { + guard await identityStore.state().isEnabled, + await identityStore.state().supportsIncrementalUpdates else { + return + } + + do { + let identities = try await getCredentialIdentities(from: cipher) + try await identityStore.removeCredentialIdentities(identities) + + Logger.application.debug( + "[AutofillCredentialService] Removed \(identities.count) identities from \(cipher.id ?? "nil")", + ) + } catch { + errorReporter.log(error: error) + } + } + + /// Adds/Updates the credential identities associated with the cipher on the store. + /// - Parameter cipher: The cipher to get the credential identities from. + @available(iOS 17.0, *) + private func upsertCredentialsInStore(for cipher: Cipher) async { + guard await identityStore.state().isEnabled, + await identityStore.state().supportsIncrementalUpdates else { + return + } + + do { + let identities = try await getCredentialIdentities(from: cipher) + try await identityStore.saveCredentialIdentities(identities) + + Logger.application.debug( + "[AutofillCredentialService] Upserted \(identities.count) identities from \(cipher.id ?? "nil")", + ) + } catch { + errorReporter.log(error: error) + } + } } // MARK: - CredentialIdentityStore @@ -536,6 +647,13 @@ protocol CredentialIdentityStore { /// func removeAllCredentialIdentities() async throws + /// Remove the given credential identities from the store. + /// + /// - Parameter credentialIdentities: A list of credential identities to remove. + /// + @available(iOS 17.0, *) + func removeCredentialIdentities(_ credentialIdentities: [any ASCredentialIdentity]) async throws + /// Replaces existing credential identities with new credential identities. /// /// - Parameter newCredentialIdentities: The new credential identities. @@ -549,6 +667,13 @@ protocol CredentialIdentityStore { /// func replaceCredentialIdentities(with newCredentialIdentities: [ASPasswordCredentialIdentity]) async throws + /// Save the supplied credential identities to the store. + /// + /// - Parameter credentialIdentities: A list of credential identities to save. + /// + @available(iOS 17.0, *) + func saveCredentialIdentities(_ credentialIdentities: [any ASCredentialIdentity]) async throws + /// Gets the state of the credential identity store. /// /// - Returns: The state of the credential identity store. diff --git a/BitwardenShared/Core/Autofill/Services/TestHelpers/MockCredentialIdentityStore.swift b/BitwardenShared/Core/Autofill/Services/TestHelpers/MockCredentialIdentityStore.swift index 29d276476c..2869c7388d 100644 --- a/BitwardenShared/Core/Autofill/Services/TestHelpers/MockCredentialIdentityStore.swift +++ b/BitwardenShared/Core/Autofill/Services/TestHelpers/MockCredentialIdentityStore.swift @@ -9,15 +9,30 @@ class MockCredentialIdentityStore: CredentialIdentityStore { var removeAllCredentialIdentitiesCalled = false var removeAllCredentialIdentitiesResult = Result.success(()) + var removeCredentialIdentitiesCalled = false + var removeCredentialIdentitiesIdentities: [CredentialIdentity]? + var removeCredentialIdentitiesResult = Result.success(()) + var replaceCredentialIdentitiesCalled = false var replaceCredentialIdentitiesIdentities: [CredentialIdentity]? var replaceCredentialIdentitiesResult = Result.success(()) + var saveCredentialIdentitiesCalled = false + var saveCredentialIdentitiesIdentities: [CredentialIdentity]? + var saveCredentialIdentitiesResult = Result.success(()) + func removeAllCredentialIdentities() async throws { removeAllCredentialIdentitiesCalled = true try removeAllCredentialIdentitiesResult.get() } + @available(iOS 17.0, *) + func removeCredentialIdentities(_ identities: [any ASCredentialIdentity]) async throws { + removeCredentialIdentitiesCalled = true + removeCredentialIdentitiesIdentities = identities.compactMap(CredentialIdentity.init) + try removeCredentialIdentitiesResult.get() + } + @available(iOS 17, *) func replaceCredentialIdentities(_ identities: [ASCredentialIdentity]) async throws { replaceCredentialIdentitiesCalled = true @@ -31,6 +46,13 @@ class MockCredentialIdentityStore: CredentialIdentityStore { try replaceCredentialIdentitiesResult.get() } + @available(iOS 17.0, *) + func saveCredentialIdentities(_ identities: [any ASCredentialIdentity]) async throws { + saveCredentialIdentitiesCalled = true + saveCredentialIdentitiesIdentities = identities.compactMap(CredentialIdentity.init) + try saveCredentialIdentitiesResult.get() + } + func state() async -> ASCredentialIdentityStoreState { stateCalled = true return state @@ -41,10 +63,16 @@ class MockCredentialIdentityStore: CredentialIdentityStore { class MockCredentialIdentityStoreState: ASCredentialIdentityStoreState { var mockIsEnabled = true + var mockSupportsIncrementalUpdates = true override var isEnabled: Bool { mockIsEnabled } + + @available(iOS 12.0, *) + override var supportsIncrementalUpdates: Bool { + mockSupportsIncrementalUpdates + } } // MARK: - CredentialIdentity diff --git a/BitwardenShared/Core/Platform/Utilities/CipherChangePublisher.swift b/BitwardenShared/Core/Platform/Utilities/CipherChangePublisher.swift new file mode 100644 index 0000000000..cb0a97d0e2 --- /dev/null +++ b/BitwardenShared/Core/Platform/Utilities/CipherChangePublisher.swift @@ -0,0 +1,177 @@ +import BitwardenSdk +import Combine +import CoreData + +// MARK: - CipherChange + +/// Represents a change to a cipher in the data store. +/// +public enum CipherChange { + /// A cipher was inserted. + case inserted(Cipher) + + /// A cipher was updated. + case updated(Cipher) + + /// A cipher was deleted. + case deleted(Cipher) +} + +// MARK: - CipherChangePublisher + +/// A Combine publisher that publishes individual cipher changes (insert, update, delete) as they occur. +/// +/// This publisher monitors Core Data's `NSManagedObjectContextDidSave` notifications and emits +/// changes for individual cipher operations. Batch operations like `replaceCiphers` do not trigger +/// these notifications and therefore won't emit changes. +/// +public class CipherChangePublisher: Publisher { + // MARK: Types + + public typealias Output = CipherChange + + public typealias Failure = Error + + // MARK: Properties + + /// The managed object context to observe for cipher changes. + let context: NSManagedObjectContext + + /// The user ID to filter cipher changes. + let userId: String + + // MARK: Initialization + + /// Initialize a `CipherChangePublisher`. + /// + /// - Parameters: + /// - context: The managed object context to observe for cipher changes. + /// - userId: The user ID to filter cipher changes. + /// + public init(context: NSManagedObjectContext, userId: String) { + self.context = context + self.userId = userId + } + + // MARK: Publisher + + public func receive(subscriber: S) where S: Subscriber, S.Failure == Failure, S.Input == Output { + subscriber.receive(subscription: CipherChangeSubscription( + context: context, + userId: userId, + subscriber: subscriber, + )) + } +} + +// MARK: - CipherChangeSubscription + +/// A `Subscription` to a `CipherChangePublisher` which observes Core Data save notifications +/// and notifies the subscriber of individual cipher changes. +/// +private final class CipherChangeSubscription: NSObject, Subscription + where SubscriberType: Subscriber, + SubscriberType.Input == CipherChange, + SubscriberType.Failure == Error { + // MARK: Properties + + /// The subscriber to notify of cipher changes. + private var subscriber: SubscriberType? + + /// The cancellable for the notification observation. + private var cancellable: AnyCancellable? + + /// The user ID to filter cipher changes. + private let userId: String + + // MARK: Initialization + + /// Initialize a `CipherChangeSubscription`. + /// + /// - Parameters: + /// - context: The managed object context to observe for cipher changes. + /// - userId: The user ID to filter cipher changes. + /// - subscriber: The subscriber to notify of cipher changes. + /// + init( + context: NSManagedObjectContext, + userId: String, + subscriber: SubscriberType, + ) { + self.userId = userId + self.subscriber = subscriber + super.init() + + cancellable = NotificationCenter.default.publisher( + for: .NSManagedObjectContextDidSave, + object: context, + ) + .sink { [weak self] notification in + self?.handleContextSave(notification) + } + } + + // MARK: Subscription + + func request(_ demand: Subscribers.Demand) { + // Unlimited demand - we emit all changes + } + + // MARK: Cancellable + + func cancel() { + cancellable?.cancel() + cancellable = nil + subscriber = nil + } + + // MARK: Private Methods + + /// Handles Core Data context save notifications and emits cipher changes. + /// + /// - Parameter notification: The notification containing the saved changes. + /// + private func handleContextSave(_ notification: Notification) { + guard let subscriber, + let userInfo = notification.userInfo else { + return + } + + do { + // Check inserted objects + if let inserts = userInfo[NSInsertedObjectsKey] as? Set { + for object in inserts where object is CipherData { + guard let cipherData = object as? CipherData, + cipherData.userId == userId else { + continue + } + _ = subscriber.receive(.inserted(try Cipher(cipherData: cipherData))) + } + } + + // Check updated objects + if let updates = userInfo[NSUpdatedObjectsKey] as? Set { + for object in updates where object is CipherData { + guard let cipherData = object as? CipherData, + cipherData.userId == userId else { + continue + } + _ = subscriber.receive(.updated(try Cipher(cipherData: cipherData))) + } + } + + // Check deleted objects + if let deletes = userInfo[NSDeletedObjectsKey] as? Set { + for object in deletes where object is CipherData { + guard let cipherData = object as? CipherData, + cipherData.userId == userId else { + continue + } + _ = subscriber.receive(.deleted(try Cipher(cipherData: cipherData))) + } + } + } catch { + subscriber.receive(completion: .failure(error)) + } + } +} diff --git a/BitwardenShared/Core/Vault/Services/CipherService.swift b/BitwardenShared/Core/Vault/Services/CipherService.swift index 467a96f9be..dac0458411 100644 --- a/BitwardenShared/Core/Vault/Services/CipherService.swift +++ b/BitwardenShared/Core/Vault/Services/CipherService.swift @@ -132,6 +132,15 @@ protocol CipherService { // MARK: Publishers + /// A publisher that emits individual cipher changes (insert, update, delete) as they occur for the current user. + /// + /// This publisher only emits for individual cipher operations + /// Batch operations like `replaceCiphers` do not trigger emissions from this publisher. + /// + /// - Returns: A publisher that emits cipher changes. + /// + func cipherChangesPublisher() async throws -> AnyPublisher + /// A publisher for the list of ciphers for the current user. /// /// - Returns: The list of encrypted ciphers. @@ -372,6 +381,11 @@ extension DefaultCipherService { // MARK: Publishers + func cipherChangesPublisher() async throws -> AnyPublisher { + let userId = try await stateService.getActiveAccountId() + return cipherDataStore.cipherChangesPublisher(userId: userId) + } + func ciphersPublisher() async throws -> AnyPublisher<[Cipher], Error> { let userId = try await stateService.getActiveAccountId() return cipherDataStore.cipherPublisher(userId: userId) diff --git a/BitwardenShared/Core/Vault/Services/CipherServiceTests.swift b/BitwardenShared/Core/Vault/Services/CipherServiceTests.swift index a6428f4ea8..191e69630a 100644 --- a/BitwardenShared/Core/Vault/Services/CipherServiceTests.swift +++ b/BitwardenShared/Core/Vault/Services/CipherServiceTests.swift @@ -105,6 +105,34 @@ class CipherServiceTests: BitwardenTestCase { // swiftlint:disable:this type_bod try XCTAssertEqual(XCTUnwrap(publisherValue), [cipher]) } + /// `cipherChangesPublisher()` returns a publisher that emits individual cipher changes from the data store. + func test_cipherChangesPublisher_success() async throws { + stateService.activeAccount = .fixtureAccountLogin() + + var iterator = try await subject.cipherChangesPublisher().values.makeAsyncIterator() + + let cipher = Cipher.fixture(id: "1", name: "Test Cipher") + let userId = stateService.activeAccount?.profile.userId ?? "" + cipherDataStore.cipherChangesSubjectByUserId[userId]?.send(.inserted(cipher)) + + let change = try await iterator.next() + guard case let .inserted(insertedCipher) = change else { + XCTFail("Expected inserted change") + return + } + XCTAssertEqual(insertedCipher.id, cipher.id) + XCTAssertEqual(insertedCipher.name, cipher.name) + } + + /// `cipherChangesPublisher()` throws an error when there's no active account. + func test_cipherChangesPublisher_noActiveAccount() async { + stateService.activeAccount = nil + + await assertAsyncThrows(error: StateServiceError.noActiveAccount) { + _ = try await subject.cipherChangesPublisher() + } + } + /// `deleteAttachmentWithServer(attachmentId:cipherId:)` deletes the cipher's attachment from backend /// and local storage. func test_deleteAttachmentWithServer() async throws { diff --git a/BitwardenShared/Core/Vault/Services/Fido2CredentialStoreService.swift b/BitwardenShared/Core/Vault/Services/Fido2CredentialStoreService.swift index e28b47d96d..7280efe1a6 100644 --- a/BitwardenShared/Core/Vault/Services/Fido2CredentialStoreService.swift +++ b/BitwardenShared/Core/Vault/Services/Fido2CredentialStoreService.swift @@ -4,7 +4,7 @@ import Foundation /// The Fido2 credential store implementation that the SDK needs /// which handles getting/saving credentials for Fido2 flows. -class Fido2CredentialStoreService: Fido2CredentialStore { +final class Fido2CredentialStoreService: Fido2CredentialStore { // MARK: Properties /// The service used to manage syncing and updates to the user's ciphers. diff --git a/BitwardenShared/Core/Vault/Services/Stores/CipherDataStore.swift b/BitwardenShared/Core/Vault/Services/Stores/CipherDataStore.swift index 5dcb1c6c13..96ab7b6c35 100644 --- a/BitwardenShared/Core/Vault/Services/Stores/CipherDataStore.swift +++ b/BitwardenShared/Core/Vault/Services/Stores/CipherDataStore.swift @@ -51,6 +51,16 @@ protocol CipherDataStore: AnyObject { /// func cipherPublisher(userId: String) -> AnyPublisher<[Cipher], Error> + /// A publisher that emits individual cipher changes (insert, update, delete) as they occur. + /// + /// This publisher only emits for individual cipher operations (`upsertCipher`, `deleteCipher`). + /// Batch operations like `replaceCiphers` do not trigger emissions from this publisher. + /// + /// - Parameter userId: The user ID of the user associated with the ciphers. + /// - Returns: A publisher that emits cipher changes. + /// + func cipherChangesPublisher(userId: String) -> AnyPublisher + /// Replaces a list of `Cipher` objects for a user. /// /// - Parameters: @@ -116,6 +126,14 @@ extension DataStore: CipherDataStore { .eraseToAnyPublisher() } + func cipherChangesPublisher(userId: String) -> AnyPublisher { + CipherChangePublisher( + context: backgroundContext, + userId: userId, + ) + .eraseToAnyPublisher() + } + func replaceCiphers(_ ciphers: [Cipher], userId: String) async throws { let deleteRequest = CipherData.deleteByUserIdRequest(userId: userId) let insertRequest = try CipherData.batchInsertRequest(objects: ciphers, userId: userId) diff --git a/BitwardenShared/Core/Vault/Services/Stores/CipherDataStoreTests.swift b/BitwardenShared/Core/Vault/Services/Stores/CipherDataStoreTests.swift index 7da84bb67b..14b337804c 100644 --- a/BitwardenShared/Core/Vault/Services/Stores/CipherDataStoreTests.swift +++ b/BitwardenShared/Core/Vault/Services/Stores/CipherDataStoreTests.swift @@ -63,6 +63,125 @@ class CipherDataStoreTests: BitwardenTestCase { XCTAssertEqual(publishedValues[1], ciphers) } + /// `cipherChangesPublisher(userId:)` emits inserted ciphers for the user. + func test_cipherChangesPublisher_insert() async throws { + var publishedChanges = [CipherChange]() + let publisher = subject.cipherChangesPublisher(userId: "1") + .sink( + receiveCompletion: { _ in }, + receiveValue: { change in + publishedChanges.append(change) + }, + ) + defer { publisher.cancel() } + + let cipher = Cipher.fixture(id: "1", name: "CIPHER1") + try await subject.upsertCipher(cipher, userId: "1") + + waitFor { publishedChanges.count == 1 } + guard case let .inserted(insertedCipher) = publishedChanges[0] else { + XCTFail("Expected inserted change") + return + } + XCTAssertEqual(insertedCipher.id, cipher.id) + XCTAssertEqual(insertedCipher.name, cipher.name) + } + + /// `cipherChangesPublisher(userId:)` emits updated ciphers for the user. + func test_cipherChangesPublisher_update() async throws { + // Insert initial cipher + try await insertCiphers([ciphers[0]], userId: "1") + + var publishedChanges = [CipherChange]() + let publisher = subject.cipherChangesPublisher(userId: "1") + .sink( + receiveCompletion: { _ in }, + receiveValue: { change in + publishedChanges.append(change) + }, + ) + defer { publisher.cancel() } + + let updatedCipher = Cipher.fixture(id: "1", name: "UPDATED CIPHER1") + try await subject.upsertCipher(updatedCipher, userId: "1") + + waitFor { publishedChanges.count == 1 } + guard case let .updated(updated) = publishedChanges[0] else { + XCTFail("Expected updated change") + return + } + XCTAssertEqual(updated.id, updatedCipher.id) + XCTAssertEqual(updated.name, updatedCipher.name) + } + + /// `cipherChangesPublisher(userId:)` emits deleted cipher IDs for the user. + func test_cipherChangesPublisher_delete() async throws { + // Insert initial ciphers + try await insertCiphers(ciphers, userId: "1") + + var publishedChanges = [CipherChange]() + let publisher = subject.cipherChangesPublisher(userId: "1") + .sink( + receiveCompletion: { _ in }, + receiveValue: { change in + publishedChanges.append(change) + }, + ) + defer { publisher.cancel() } + + try await subject.deleteCipher(id: "2", userId: "1") + + waitFor { publishedChanges.count == 1 } + guard case let .deleted(deletedCipher) = publishedChanges[0] else { + XCTFail("Expected deleted change") + return + } + XCTAssertEqual(deletedCipher.id, "2") + } + + /// `cipherChangesPublisher(userId:)` does not emit changes for other users. + func test_cipherChangesPublisher_doesNotEmitForOtherUsers() async throws { + var publishedChanges = [CipherChange]() + let publisher = subject.cipherChangesPublisher(userId: "1") + .sink( + receiveCompletion: { _ in }, + receiveValue: { change in + publishedChanges.append(change) + }, + ) + defer { publisher.cancel() } + + // Insert cipher for a different user + let cipher = Cipher.fixture(id: "1", name: "CIPHER1") + try await subject.upsertCipher(cipher, userId: "2") + + // Wait a bit to ensure no changes are emitted + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + XCTAssertTrue(publishedChanges.isEmpty) + } + + /// `cipherChangesPublisher(userId:)` does not emit changes for batch operations. + func test_cipherChangesPublisher_doesNotEmitForBatchOperations() async throws { + var publishedChanges = [CipherChange]() + let publisher = subject.cipherChangesPublisher(userId: "1") + .sink( + receiveCompletion: { _ in }, + receiveValue: { change in + publishedChanges.append(change) + }, + ) + defer { publisher.cancel() } + + // Replace ciphers (batch operation) + try await subject.replaceCiphers(ciphers, userId: "1") + + // Wait a bit to ensure no changes are emitted + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + XCTAssertTrue(publishedChanges.isEmpty) + } + /// `deleteAllCiphers(user:)` removes all objects for the user. func test_deleteAllCiphers() async throws { try await insertCiphers(ciphers, userId: "1") diff --git a/BitwardenShared/Core/Vault/Services/Stores/TestHelpers/MockCipherDataStore.swift b/BitwardenShared/Core/Vault/Services/Stores/TestHelpers/MockCipherDataStore.swift index 2970d36f8f..f0c2a698cf 100644 --- a/BitwardenShared/Core/Vault/Services/Stores/TestHelpers/MockCipherDataStore.swift +++ b/BitwardenShared/Core/Vault/Services/Stores/TestHelpers/MockCipherDataStore.swift @@ -20,6 +20,7 @@ class MockCipherDataStore: CipherDataStore { var fetchCipherUserId: String? var cipherSubjectByUserId: [String: CurrentValueSubject<[Cipher], Error>] = [:] + var cipherChangesSubjectByUserId: [String: CurrentValueSubject] = [:] var replaceCiphersValue: [Cipher]? var replaceCiphersUserId: String? @@ -52,6 +53,16 @@ class MockCipherDataStore: CipherDataStore { return fetchCipherResult } + func cipherChangesPublisher(userId: String) -> AnyPublisher { + if let subject = cipherChangesSubjectByUserId[userId] { + return subject.eraseToAnyPublisher() + } else { + let subject = CurrentValueSubject(.inserted(.fixture())) + cipherChangesSubjectByUserId[userId] = subject + return subject.dropFirst().eraseToAnyPublisher() + } + } + func cipherPublisher(userId: String) -> AnyPublisher<[Cipher], Error> { if let subject = cipherSubjectByUserId[userId] { return subject.eraseToAnyPublisher() diff --git a/BitwardenShared/Core/Vault/Services/TestHelpers/MockCipherService.swift b/BitwardenShared/Core/Vault/Services/TestHelpers/MockCipherService.swift index 3ceb9698cb..a5303daa99 100644 --- a/BitwardenShared/Core/Vault/Services/TestHelpers/MockCipherService.swift +++ b/BitwardenShared/Core/Vault/Services/TestHelpers/MockCipherService.swift @@ -11,6 +11,10 @@ class MockCipherService: CipherService { var cipherCountResult: Result = .success(0) + var cipherChangesSubject = CurrentValueSubject( + .inserted(.fixture()), // stub data that will be dropped and not published. + ) + var ciphersSubject = CurrentValueSubject<[Cipher], Error>([]) var deleteAttachmentWithServerAttachmentId: String? @@ -159,6 +163,10 @@ class MockCipherService: CipherService { try updateCipherCollectionsWithServerResult.get() } + func cipherChangesPublisher() async throws -> AnyPublisher { + cipherChangesSubject.dropFirst().eraseToAnyPublisher() + } + func ciphersPublisher() async throws -> AnyPublisher<[Cipher], Error> { ciphersSubject.eraseToAnyPublisher() }