mirror of
https://github.com/bitwarden/ios.git
synced 2025-12-10 17:46:07 -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
|
/// 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.
|
/// having a new class test specifically for it.
|
||||||
@MainActor
|
@MainActor
|
||||||
class AutofillCredentialServiceAppExtensionTests: BitwardenTestCase {
|
class AutofillCredentialServiceAppExtensionTests: BitwardenTestCase { // swiftlint:disable:this type_body_length
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|
||||||
var appContextHelper: MockAppContextHelper!
|
var appContextHelper: MockAppContextHelper!
|
||||||
@ -101,6 +101,188 @@ class AutofillCredentialServiceAppExtensionTests: BitwardenTestCase {
|
|||||||
|
|
||||||
// MARK: Tests
|
// 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
|
/// `syncIdentities(vaultLockStatus:)` doesn't update the credential identity store with the identities
|
||||||
/// from the user's vault when the app context is `.appExtension`.
|
/// from the user's vault when the app context is `.appExtension`.
|
||||||
func test_syncIdentities_appExtensionContext() {
|
func test_syncIdentities_appExtensionContext() {
|
||||||
|
|||||||
@ -86,11 +86,21 @@ protocol AutofillCredentialService: AnyObject {
|
|||||||
/// A default implementation of an `AutofillCredentialService`.
|
/// A default implementation of an `AutofillCredentialService`.
|
||||||
///
|
///
|
||||||
class DefaultAutofillCredentialService {
|
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
|
// MARK: Private Properties
|
||||||
|
|
||||||
/// Helper to know about the app context.
|
/// Helper to know about the app context.
|
||||||
private let appContextHelper: AppContextHelper
|
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.
|
/// The service used to manage syncing and updates to the user's ciphers.
|
||||||
private let cipherService: CipherService
|
private let cipherService: CipherService
|
||||||
|
|
||||||
@ -191,6 +201,11 @@ class DefaultAutofillCredentialService {
|
|||||||
self.vaultTimeoutService = vaultTimeoutService
|
self.vaultTimeoutService = vaultTimeoutService
|
||||||
|
|
||||||
guard appContextHelper.appContext == .mainApp else {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -201,8 +216,38 @@ class DefaultAutofillCredentialService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deinitializes this service.
|
||||||
|
deinit {
|
||||||
|
cipherChangesSubscriptionTask?.cancel()
|
||||||
|
cipherChangesSubscriptionTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Private Methods
|
// 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.
|
/// 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.
|
/// - 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)
|
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.
|
/// Provides a Fido2 credential based for the given request.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - request: Request to get the assertion credential.
|
/// - request: Request to get the assertion credential.
|
||||||
@ -525,6 +594,48 @@ extension DefaultAutofillCredentialService: AutofillCredentialService {
|
|||||||
throw error
|
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
|
// MARK: - CredentialIdentityStore
|
||||||
@ -536,6 +647,13 @@ protocol CredentialIdentityStore {
|
|||||||
///
|
///
|
||||||
func removeAllCredentialIdentities() async throws
|
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.
|
/// Replaces existing credential identities with new credential identities.
|
||||||
///
|
///
|
||||||
/// - Parameter newCredentialIdentities: The new credential identities.
|
/// - Parameter newCredentialIdentities: The new credential identities.
|
||||||
@ -549,6 +667,13 @@ protocol CredentialIdentityStore {
|
|||||||
///
|
///
|
||||||
func replaceCredentialIdentities(with newCredentialIdentities: [ASPasswordCredentialIdentity]) async throws
|
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.
|
/// Gets the state of the credential identity store.
|
||||||
///
|
///
|
||||||
/// - Returns: 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 removeAllCredentialIdentitiesCalled = false
|
||||||
var removeAllCredentialIdentitiesResult = Result<Void, Error>.success(())
|
var removeAllCredentialIdentitiesResult = Result<Void, Error>.success(())
|
||||||
|
|
||||||
|
var removeCredentialIdentitiesCalled = false
|
||||||
|
var removeCredentialIdentitiesIdentities: [CredentialIdentity]?
|
||||||
|
var removeCredentialIdentitiesResult = Result<Void, Error>.success(())
|
||||||
|
|
||||||
var replaceCredentialIdentitiesCalled = false
|
var replaceCredentialIdentitiesCalled = false
|
||||||
var replaceCredentialIdentitiesIdentities: [CredentialIdentity]?
|
var replaceCredentialIdentitiesIdentities: [CredentialIdentity]?
|
||||||
var replaceCredentialIdentitiesResult = Result<Void, Error>.success(())
|
var replaceCredentialIdentitiesResult = Result<Void, Error>.success(())
|
||||||
|
|
||||||
|
var saveCredentialIdentitiesCalled = false
|
||||||
|
var saveCredentialIdentitiesIdentities: [CredentialIdentity]?
|
||||||
|
var saveCredentialIdentitiesResult = Result<Void, Error>.success(())
|
||||||
|
|
||||||
func removeAllCredentialIdentities() async throws {
|
func removeAllCredentialIdentities() async throws {
|
||||||
removeAllCredentialIdentitiesCalled = true
|
removeAllCredentialIdentitiesCalled = true
|
||||||
try removeAllCredentialIdentitiesResult.get()
|
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, *)
|
@available(iOS 17, *)
|
||||||
func replaceCredentialIdentities(_ identities: [ASCredentialIdentity]) async throws {
|
func replaceCredentialIdentities(_ identities: [ASCredentialIdentity]) async throws {
|
||||||
replaceCredentialIdentitiesCalled = true
|
replaceCredentialIdentitiesCalled = true
|
||||||
@ -31,6 +46,13 @@ class MockCredentialIdentityStore: CredentialIdentityStore {
|
|||||||
try replaceCredentialIdentitiesResult.get()
|
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 {
|
func state() async -> ASCredentialIdentityStoreState {
|
||||||
stateCalled = true
|
stateCalled = true
|
||||||
return state
|
return state
|
||||||
@ -41,10 +63,16 @@ class MockCredentialIdentityStore: CredentialIdentityStore {
|
|||||||
|
|
||||||
class MockCredentialIdentityStoreState: ASCredentialIdentityStoreState {
|
class MockCredentialIdentityStoreState: ASCredentialIdentityStoreState {
|
||||||
var mockIsEnabled = true
|
var mockIsEnabled = true
|
||||||
|
var mockSupportsIncrementalUpdates = true
|
||||||
|
|
||||||
override var isEnabled: Bool {
|
override var isEnabled: Bool {
|
||||||
mockIsEnabled
|
mockIsEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@available(iOS 12.0, *)
|
||||||
|
override var supportsIncrementalUpdates: Bool {
|
||||||
|
mockSupportsIncrementalUpdates
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - CredentialIdentity
|
// 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
|
// 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.
|
/// A publisher for the list of ciphers for the current user.
|
||||||
///
|
///
|
||||||
/// - Returns: The list of encrypted ciphers.
|
/// - Returns: The list of encrypted ciphers.
|
||||||
@ -372,6 +381,11 @@ extension DefaultCipherService {
|
|||||||
|
|
||||||
// MARK: Publishers
|
// 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> {
|
func ciphersPublisher() async throws -> AnyPublisher<[Cipher], Error> {
|
||||||
let userId = try await stateService.getActiveAccountId()
|
let userId = try await stateService.getActiveAccountId()
|
||||||
return cipherDataStore.cipherPublisher(userId: userId)
|
return cipherDataStore.cipherPublisher(userId: userId)
|
||||||
|
|||||||
@ -105,6 +105,34 @@ class CipherServiceTests: BitwardenTestCase { // swiftlint:disable:this type_bod
|
|||||||
try XCTAssertEqual(XCTUnwrap(publisherValue), [cipher])
|
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
|
/// `deleteAttachmentWithServer(attachmentId:cipherId:)` deletes the cipher's attachment from backend
|
||||||
/// and local storage.
|
/// and local storage.
|
||||||
func test_deleteAttachmentWithServer() async throws {
|
func test_deleteAttachmentWithServer() async throws {
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import Foundation
|
|||||||
|
|
||||||
/// The Fido2 credential store implementation that the SDK needs
|
/// The Fido2 credential store implementation that the SDK needs
|
||||||
/// which handles getting/saving credentials for Fido2 flows.
|
/// which handles getting/saving credentials for Fido2 flows.
|
||||||
class Fido2CredentialStoreService: Fido2CredentialStore {
|
final class Fido2CredentialStoreService: Fido2CredentialStore {
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|
||||||
/// The service used to manage syncing and updates to the user's ciphers.
|
/// 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>
|
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.
|
/// Replaces a list of `Cipher` objects for a user.
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
@ -116,6 +126,14 @@ extension DataStore: CipherDataStore {
|
|||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cipherChangesPublisher(userId: String) -> AnyPublisher<CipherChange, Error> {
|
||||||
|
CipherChangePublisher(
|
||||||
|
context: backgroundContext,
|
||||||
|
userId: userId,
|
||||||
|
)
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
func replaceCiphers(_ ciphers: [Cipher], userId: String) async throws {
|
func replaceCiphers(_ ciphers: [Cipher], userId: String) async throws {
|
||||||
let deleteRequest = CipherData.deleteByUserIdRequest(userId: userId)
|
let deleteRequest = CipherData.deleteByUserIdRequest(userId: userId)
|
||||||
let insertRequest = try CipherData.batchInsertRequest(objects: ciphers, userId: userId)
|
let insertRequest = try CipherData.batchInsertRequest(objects: ciphers, userId: userId)
|
||||||
|
|||||||
@ -63,6 +63,125 @@ class CipherDataStoreTests: BitwardenTestCase {
|
|||||||
XCTAssertEqual(publishedValues[1], ciphers)
|
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.
|
/// `deleteAllCiphers(user:)` removes all objects for the user.
|
||||||
func test_deleteAllCiphers() async throws {
|
func test_deleteAllCiphers() async throws {
|
||||||
try await insertCiphers(ciphers, userId: "1")
|
try await insertCiphers(ciphers, userId: "1")
|
||||||
|
|||||||
@ -20,6 +20,7 @@ class MockCipherDataStore: CipherDataStore {
|
|||||||
var fetchCipherUserId: String?
|
var fetchCipherUserId: String?
|
||||||
|
|
||||||
var cipherSubjectByUserId: [String: CurrentValueSubject<[Cipher], Error>] = [:]
|
var cipherSubjectByUserId: [String: CurrentValueSubject<[Cipher], Error>] = [:]
|
||||||
|
var cipherChangesSubjectByUserId: [String: CurrentValueSubject<CipherChange, Error>] = [:]
|
||||||
|
|
||||||
var replaceCiphersValue: [Cipher]?
|
var replaceCiphersValue: [Cipher]?
|
||||||
var replaceCiphersUserId: String?
|
var replaceCiphersUserId: String?
|
||||||
@ -52,6 +53,16 @@ class MockCipherDataStore: CipherDataStore {
|
|||||||
return fetchCipherResult
|
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> {
|
func cipherPublisher(userId: String) -> AnyPublisher<[Cipher], Error> {
|
||||||
if let subject = cipherSubjectByUserId[userId] {
|
if let subject = cipherSubjectByUserId[userId] {
|
||||||
return subject.eraseToAnyPublisher()
|
return subject.eraseToAnyPublisher()
|
||||||
|
|||||||
@ -11,6 +11,10 @@ class MockCipherService: CipherService {
|
|||||||
|
|
||||||
var cipherCountResult: Result<Int, Error> = .success(0)
|
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 ciphersSubject = CurrentValueSubject<[Cipher], Error>([])
|
||||||
|
|
||||||
var deleteAttachmentWithServerAttachmentId: String?
|
var deleteAttachmentWithServerAttachmentId: String?
|
||||||
@ -159,6 +163,10 @@ class MockCipherService: CipherService {
|
|||||||
try updateCipherCollectionsWithServerResult.get()
|
try updateCipherCollectionsWithServerResult.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cipherChangesPublisher() async throws -> AnyPublisher<CipherChange, Error> {
|
||||||
|
cipherChangesSubject.dropFirst().eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
func ciphersPublisher() async throws -> AnyPublisher<[Cipher], Error> {
|
func ciphersPublisher() async throws -> AnyPublisher<[Cipher], Error> {
|
||||||
ciphersSubject.eraseToAnyPublisher()
|
ciphersSubject.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user