mirror of
https://github.com/bitwarden/ios.git
synced 2025-12-10 00:42:29 -06:00
[PM-28855] Update credential identities store on cipher changes on iOS extensions (#2169)
This commit is contained in:
parent
446858c7fb
commit
8680221d0f
@ -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() {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user