[PM-28855] Update credential identities store on cipher changes on iOS extensions (#2169)

This commit is contained in:
Federico Maccaroni 2025-12-03 12:27:23 -03:00 committed by GitHub
parent 446858c7fb
commit 8680221d0f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 712 additions and 2 deletions

View File

@ -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() {

View File

@ -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<Void, Never>?
/// 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.

View File

@ -9,15 +9,30 @@ class MockCredentialIdentityStore: CredentialIdentityStore {
var removeAllCredentialIdentitiesCalled = false
var removeAllCredentialIdentitiesResult = Result<Void, Error>.success(())
var removeCredentialIdentitiesCalled = false
var removeCredentialIdentitiesIdentities: [CredentialIdentity]?
var removeCredentialIdentitiesResult = Result<Void, Error>.success(())
var replaceCredentialIdentitiesCalled = false
var replaceCredentialIdentitiesIdentities: [CredentialIdentity]?
var replaceCredentialIdentitiesResult = Result<Void, Error>.success(())
var saveCredentialIdentitiesCalled = false
var saveCredentialIdentitiesIdentities: [CredentialIdentity]?
var saveCredentialIdentitiesResult = Result<Void, Error>.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

View File

@ -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<S>(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<SubscriberType>: 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<NSManagedObject> {
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<NSManagedObject> {
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<NSManagedObject> {
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))
}
}
}

View File

@ -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<CipherChange, Error>
/// 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<CipherChange, Error> {
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)

View File

@ -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 {

View File

@ -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.

View File

@ -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<CipherChange, Error>
/// Replaces a list of `Cipher` objects for a user.
///
/// - Parameters:
@ -116,6 +126,14 @@ extension DataStore: CipherDataStore {
.eraseToAnyPublisher()
}
func cipherChangesPublisher(userId: String) -> AnyPublisher<CipherChange, Error> {
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)

View File

@ -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")

View File

@ -20,6 +20,7 @@ class MockCipherDataStore: CipherDataStore {
var fetchCipherUserId: String?
var cipherSubjectByUserId: [String: CurrentValueSubject<[Cipher], Error>] = [:]
var cipherChangesSubjectByUserId: [String: CurrentValueSubject<CipherChange, Error>] = [:]
var replaceCiphersValue: [Cipher]?
var replaceCiphersUserId: String?
@ -52,6 +53,16 @@ class MockCipherDataStore: CipherDataStore {
return fetchCipherResult
}
func cipherChangesPublisher(userId: String) -> AnyPublisher<CipherChange, Error> {
if let subject = cipherChangesSubjectByUserId[userId] {
return subject.eraseToAnyPublisher()
} else {
let subject = CurrentValueSubject<CipherChange, Error>(.inserted(.fixture()))
cipherChangesSubjectByUserId[userId] = subject
return subject.dropFirst().eraseToAnyPublisher()
}
}
func cipherPublisher(userId: String) -> AnyPublisher<[Cipher], Error> {
if let subject = cipherSubjectByUserId[userId] {
return subject.eraseToAnyPublisher()

View File

@ -11,6 +11,10 @@ class MockCipherService: CipherService {
var cipherCountResult: Result<Int, Error> = .success(0)
var cipherChangesSubject = CurrentValueSubject<CipherChange, Error>(
.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<CipherChange, Error> {
cipherChangesSubject.dropFirst().eraseToAnyPublisher()
}
func ciphersPublisher() async throws -> AnyPublisher<[Cipher], Error> {
ciphersSubject.eraseToAnyPublisher()
}