diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift index 7c7085c19c..fa78463fe7 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift @@ -25,10 +25,10 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { private let authBridgeItemService: AuthenticatorBridgeItemService /// The Tasks listening for Cipher updates (one for each user, indexed by the userId). - private var cipherPublisherTasks = [String: Task?]() + private var cipherPublisherTasks = [String: Task]() /// The service used to manage syncing and updates to the user's ciphers. - private let cipherService: CipherService + private let cipherDataStore: CipherDataStore /// The service that handles common client functionality such as encryption and decryption. private let clientService: ClientService @@ -51,10 +51,6 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// The service used by the application to manage account state. private let stateService: StateService - /// a Task that subscribes to the sync setting publisher for accounts. This allows us to take action once - /// a user opts-in to Authenticator sync. - private var syncSettingSubscriberTask: Task? - /// The service used by the application to manage vault access. private let vaultTimeoutService: VaultTimeoutService @@ -64,7 +60,7 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// /// - Parameters: /// - authBridgeItemService: The service for managing sharing items to/from the Authenticator app. - /// - cipherService: The service used to manage syncing and updates to the user's ciphers. + /// - cipherDataStore: The service used to manage syncing and updates to the user's ciphers. /// - clientService: The service that handles common client functionality such as encryption and decryption. /// - configService: The service to get server-specified configuration. /// - errorReporter: The service used by the application to report non-fatal errors.\ organizations. @@ -76,7 +72,7 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// init( authBridgeItemService: AuthenticatorBridgeItemService, - cipherService: CipherService, + cipherDataStore: CipherDataStore, clientService: ClientService, configService: ConfigService, errorReporter: ErrorReporter, @@ -86,7 +82,7 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { vaultTimeoutService: VaultTimeoutService ) { self.authBridgeItemService = authBridgeItemService - self.cipherService = cipherService + self.cipherDataStore = cipherDataStore self.clientService = clientService self.configService = configService self.errorReporter = errorReporter @@ -102,9 +98,33 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { public func start() async { guard !started else { return } started = true - if await configService.getFeatureFlag(FeatureFlag.enableAuthenticatorSync, - defaultValue: false) { - subscribeToAppState() + + guard await configService.getFeatureFlag(FeatureFlag.enableAuthenticatorSync, + defaultValue: false) else { + return + } + + Task { + for await (userId, _) in await self.stateService.syncToAuthenticatorPublisher().values { + guard let userId else { continue } + + do { + try await determineSyncForUserId(userId) + } catch { + errorReporter.log(error: error) + } + } + } + Task { + for await vaultStatus in await self.vaultTimeoutService.vaultLockStatusPublisher().values { + guard let vaultStatus else { continue } + + do { + try await determineSyncForUserId(vaultStatus.userId) + } catch { + errorReporter.log(error: error) + } + } } } @@ -138,10 +158,10 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { && cipher.login?.totp != nil } let decryptedCiphers = try await totpCiphers.asyncMap { cipher in - try await self.clientService.vault().ciphers().decrypt(cipher: cipher) + try await self.clientService.vault(for: userId).ciphers().decrypt(cipher: cipher) } let account = try await stateService.getActiveAccount() - let username = account.profile.name ?? account.profile.email + let username = account.profile.email return decryptedCiphers.map { cipher in AuthenticatorBridgeItemDataView( @@ -154,43 +174,24 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { } } - /// This function handles the initial syncing with the Authenticator app as well as listening for updates - /// when the user adds new items. This is called when the sync is turned on. + /// Determine if the given userId has sync turned on and an unlocked vault. This method serves as the + /// integration point of both the sync settings subscriber and the vault subscriber. When the user has sync turned + /// on and the vault unlocked, we can proceed with the sync. /// - /// - Parameter userId: The userId of the user who has turned on sync. + /// - Parameter userId: The userId of the user whose sync status is being determined. /// - private func handleSyncOnForUserId(_ userId: String) async { - guard !vaultTimeoutService.isLocked(userId: userId) else { + private func determineSyncForUserId(_ userId: String) async throws { + guard try await stateService.getSyncToAuthenticator(userId: userId), + !vaultTimeoutService.isLocked(userId: userId) else { + cipherPublisherTasks[userId]?.cancel() + cipherPublisherTasks.removeValue(forKey: userId) return } - do { - try await createAuthenticatorKeyIfNeeded() - } catch { - errorReporter.log(error: error) - } + try await createAuthenticatorKeyIfNeeded() subscribeToCipherUpdates(userId: userId) } - /// This function handles stopping sync and cleaning up all sync-related items when a user has turned sync Off. - /// - /// - Parameter userId: The userId of the user who has turned off sync. - /// - private func handleSyncOffForUserId(_ userId: String) { - cipherPublisherTasks[userId]??.cancel() - cipherPublisherTasks[userId] = nil - } - - /// Subscribe to NotificationCenter updates about if the app is in the foreground vs. background. - /// - private func subscribeToAppState() { - Task { - for await _ in notificationCenterService.willEnterForegroundPublisher() { - subscribeToSyncToAuthenticatorSetting() - } - } - } - /// Create a task for the given userId to listen for Cipher updates and sync to the Authenticator store. /// /// - Parameter userId: The userId of the account to listen for. @@ -200,7 +201,7 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { cipherPublisherTasks[userId] = Task { do { - for try await ciphers in try await self.cipherService.ciphersPublisher().values { + for try await ciphers in self.cipherDataStore.cipherPublisher(userId: userId).values { try await writeCiphers(ciphers: ciphers, userId: userId) } } catch { @@ -209,24 +210,6 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { } } - /// Subscribe to the Sync to Authenticator setting to handle when the user grants (or revokes) - /// permission to sync items to the Authenticator app. - /// - private func subscribeToSyncToAuthenticatorSetting() { - syncSettingSubscriberTask?.cancel() - syncSettingSubscriberTask = Task { - for await (userId, shouldSync) in await self.stateService.syncToAuthenticatorPublisher().values { - guard let userId else { continue } - - if shouldSync { - await handleSyncOnForUserId(userId) - } else { - handleSyncOffForUserId(userId) - } - } - } - } - /// Takes in a list of encrypted Ciphers, decrypts them, and writes ones with TOTP codes to the shared store. /// /// - Parameters: @@ -234,6 +217,8 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// - userId: The userId of the account to which the Ciphers belong. /// private func writeCiphers(ciphers: [Cipher], userId: String) async throws { + guard !vaultTimeoutService.isLocked(userId: userId) else { return } + let items = try await decryptTOTPs(ciphers, userId: userId) try await authBridgeItemService.replaceAllItems(with: items, forUserId: userId) } diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift index 96b1bb73d4..b57b7ebfaf 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift @@ -1,12 +1,13 @@ import AuthenticatorBridgeKit +import BitwardenSdk import Combine import XCTest @testable import BitwardenShared -final class AuthenticatorSyncServiceTests: BitwardenTestCase { +final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_length var authBridgeItemService: MockAuthenticatorBridgeItemService! - var cipherService: MockCipherService! + var cipherDataStore: MockCipherDataStore! var clientService: MockClientService! var configService: MockConfigService! var errorReporter: MockErrorReporter! @@ -22,7 +23,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { super.setUp() authBridgeItemService = MockAuthenticatorBridgeItemService() - cipherService = MockCipherService() + cipherDataStore = MockCipherDataStore() configService = MockConfigService() clientService = MockClientService() errorReporter = MockErrorReporter() @@ -33,7 +34,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { subject = DefaultAuthenticatorSyncService( authBridgeItemService: authBridgeItemService, - cipherService: cipherService, + cipherDataStore: cipherDataStore, clientService: clientService, configService: configService, errorReporter: errorReporter, @@ -48,7 +49,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { super.tearDown() authBridgeItemService = nil - cipherService = nil + cipherDataStore = nil configService = nil clientService = nil errorReporter = nil @@ -67,12 +68,10 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// @MainActor func test_createAuthenticatorKeyIfNeeded_createsKeyWhenNeeded() async throws { - configService.featureFlagsBool[.enableAuthenticatorSync] = true + setupInitialState() await subject.start() try sharedKeychainRepository.deleteAuthenticatorKey() - stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) - notificationCenterService.willEnterForegroundSubject.send() waitFor(sharedKeychainRepository.authenticatorKey != nil) } @@ -83,14 +82,12 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// @MainActor func test_createAuthenticatorKeyIfNeeded_keyAlreadyExists() async throws { - configService.featureFlagsBool[.enableAuthenticatorSync] = true + setupInitialState() await subject.start() let key = sharedKeychainRepository.generateKeyData() try await sharedKeychainRepository.setAuthenticatorKey(key) - stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) - notificationCenterService.willEnterForegroundSubject.send() waitFor(sharedKeychainRepository.authenticatorKey != nil) XCTAssertEqual(sharedKeychainRepository.authenticatorKey, key) @@ -100,10 +97,9 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// @MainActor func test_decryptTOTPs_filtersOutDeleted() async throws { - configService.featureFlagsBool[.enableAuthenticatorSync] = true + setupInitialState() await subject.start() - stateService.activeAccount = .fixture() - cipherService.ciphersSubject.send([ + cipherDataStore.cipherSubjectByUserId["1"]?.send([ .fixture( id: "1234", login: .fixture( @@ -121,9 +117,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { ), ]) stateService.syncToAuthenticatorSubject.send(("1", true)) - notificationCenterService.willEnterForegroundSubject.send() - - waitFor(authBridgeItemService.replaceAllCalled) + waitFor(authBridgeItemService.storedItems["1"]?.first != nil) let items = try XCTUnwrap(authBridgeItemService.storedItems["1"]) XCTAssertEqual(items.count, 1) @@ -134,10 +128,9 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// @MainActor func test_decryptTOTPs_ignoresItemsWithoutTOTP() async throws { - configService.featureFlagsBool[.enableAuthenticatorSync] = true + setupInitialState() await subject.start() - stateService.activeAccount = .fixture() - cipherService.ciphersSubject.send([ + cipherDataStore.cipherSubjectByUserId["1"]?.send([ .fixture( id: "1234", login: .fixture( @@ -153,9 +146,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { ), ]) stateService.syncToAuthenticatorSubject.send(("1", true)) - notificationCenterService.willEnterForegroundSubject.send() - - waitFor(authBridgeItemService.replaceAllCalled) + waitFor(authBridgeItemService.storedItems["1"]?.first != nil) let items = try XCTUnwrap(authBridgeItemService.storedItems["1"]) XCTAssertEqual(items.count, 1) @@ -167,10 +158,9 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// @MainActor func test_decryptTOTPs_providesIdIfNil() async throws { - configService.featureFlagsBool[.enableAuthenticatorSync] = true + setupInitialState() await subject.start() - stateService.activeAccount = .fixture() - cipherService.ciphersSubject.send([ + cipherDataStore.cipherSubjectByUserId["1"]?.send([ .fixture( login: .fixture( username: "user@bitwarden.com", @@ -179,9 +169,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { ), ]) stateService.syncToAuthenticatorSubject.send(("1", true)) - notificationCenterService.willEnterForegroundSubject.send() - - waitFor(authBridgeItemService.replaceAllCalled) + waitFor(authBridgeItemService.storedItems["1"]?.first != nil) let item = try XCTUnwrap(authBridgeItemService.storedItems["1"]?.first) XCTAssertEqual(item.favorite, false) @@ -196,10 +184,9 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// @MainActor func test_decryptTOTPs_success() async throws { - configService.featureFlagsBool[.enableAuthenticatorSync] = true + setupInitialState() await subject.start() - stateService.activeAccount = .fixture() - cipherService.ciphersSubject.send([ + cipherDataStore.cipherSubjectByUserId["1"]?.send([ .fixture( id: "1234", login: .fixture( @@ -209,9 +196,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { ), ]) stateService.syncToAuthenticatorSubject.send(("1", true)) - notificationCenterService.willEnterForegroundSubject.send() - - waitFor(authBridgeItemService.replaceAllCalled) + waitFor(authBridgeItemService.storedItems["1"]?.first != nil) let item = try XCTUnwrap(authBridgeItemService.storedItems["1"]?.first) XCTAssertEqual(item.favorite, false) @@ -224,28 +209,38 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// Verifies that the AuthSyncService handles and reports errors when sync is turned On.. /// @MainActor - func test_handleSyncOn_error() async throws { - configService.featureFlagsBool[.enableAuthenticatorSync] = true + func test_determineSyncForUserId_error() async throws { + setupInitialState() await subject.start() sharedKeychainRepository.errorToThrow = BitwardenTestError.example - stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) - notificationCenterService.willEnterForegroundSubject.send() + waitFor(!errorReporter.errors.isEmpty) + } + + /// Verifies that the AuthSyncService handles and reports errors when vault is unlocked + /// + @MainActor + func test_determineSyncForUserId_errorHandledByVaultSubscriber() async throws { + setupInitialState() + sharedKeychainRepository.errorToThrow = BitwardenTestError.example + await subject.start() + vaultTimeoutService.vaultLockStatusSubject.send( + VaultLockStatus(isVaultLocked: false, userId: "1") + ) waitFor(!errorReporter.errors.isEmpty) } /// Verifies that the AuthSyncService stops listening for Cipher updates when the user has sync turned off. /// @MainActor - func test_handleSyncOff() async throws { - configService.featureFlagsBool[.enableAuthenticatorSync] = true + func test_determineSyncForUserId_syncOff() async throws { + setupInitialState() await subject.start() - stateService.activeAccount = .fixture() + stateService.syncToAuthenticatorByUserId["1"] = false stateService.syncToAuthenticatorSubject.send(("1", false)) - notificationCenterService.willEnterForegroundSubject.send() - cipherService.ciphersSubject.send([ + cipherDataStore.cipherSubjectByUserId["1"]?.send([ .fixture( id: "1234", login: .fixture( @@ -260,32 +255,250 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { XCTAssertFalse(authBridgeItemService.replaceAllCalled) } + /// When user "1" has sync turned on and user "2" unlocks their vault, the service should not take + /// any action because "1" has a locked vault and "2" doesn't have sync turned on. + /// + @MainActor + func test_determineSyncForUserId_unlockDifferentVault() async throws { + stateService.activeAccount = .fixture() + stateService.syncToAuthenticatorByUserId["1"] = true + vaultTimeoutService.isClientLocked["1"] = true + configService.featureFlagsBool[.enableAuthenticatorSync] = true + cipherDataStore.cipherSubjectByUserId["1"] = CurrentValueSubject<[Cipher], Error>([]) + await subject.start() + stateService.syncToAuthenticatorSubject.send(("1", true)) + + vaultTimeoutService.isClientLocked["2"] = false + vaultTimeoutService.vaultLockStatusSubject.send( + VaultLockStatus(isVaultLocked: false, userId: "2") + ) + + cipherDataStore.cipherSubjectByUserId["1"]?.send([ + .fixture( + id: "1234", + login: .fixture( + username: "user@bitwarden.com", + totp: "totp" + ) + ), + ]) + + try await Task.sleep(nanoseconds: 50_000_000) + + XCTAssertFalse(authBridgeItemService.replaceAllCalled) + } + + /// The sync service should handle multiple vaults being sync'd at the same time. + /// + @MainActor + func test_determineSyncForUserId_unlockMultipleVaults() async throws { + setupInitialState() + cipherDataStore.cipherSubjectByUserId["2"] = CurrentValueSubject<[Cipher], Error>([]) + await subject.start() + stateService.syncToAuthenticatorSubject.send(("1", true)) + + cipherDataStore.cipherSubjectByUserId["1"]?.send([ + .fixture( + id: "1234", + login: .fixture( + username: "user@bitwarden.com", + totp: "totp" + ) + ), + ]) + waitFor(authBridgeItemService.storedItems["1"]?.first != nil) + + let item = try XCTUnwrap(authBridgeItemService.storedItems["1"]?.first) + XCTAssertEqual(item.favorite, false) + XCTAssertEqual(item.id, "1234") + XCTAssertEqual(item.name, "Bitwarden") + XCTAssertEqual(item.totpKey, "totp") + XCTAssertEqual(item.username, "user@bitwarden.com") + + await stateService.addAccount(.fixture(profile: .fixture(email: "different@bitwarden.com", + userId: "2"))) + stateService.syncToAuthenticatorByUserId["2"] = true + vaultTimeoutService.isClientLocked["2"] = false + stateService.syncToAuthenticatorSubject.send(("2", true)) + + cipherDataStore.cipherSubjectByUserId["2"]?.send([ + .fixture( + id: "4321", + login: .fixture( + username: "different@bitwarden.com", + totp: "totp2" + ) + ), + ]) + waitFor(authBridgeItemService.storedItems["2"]?.first != nil) + + let otherItem = try XCTUnwrap(authBridgeItemService.storedItems["2"]?.first) + XCTAssertEqual(otherItem.favorite, false) + XCTAssertEqual(otherItem.id, "4321") + XCTAssertEqual(otherItem.name, "Bitwarden") + XCTAssertEqual(otherItem.totpKey, "totp2") + XCTAssertEqual(otherItem.username, "different@bitwarden.com") + } + + /// When the sync is turned on, but the vault is locked, the service should subscribe and wait + /// for the vault unlock to occur. + /// + @MainActor + func test_determineSyncForUserId_vaultUnlocked() async throws { + setupInitialState(vaultLocked: true) + await subject.start() + stateService.syncToAuthenticatorSubject.send(("1", true)) + + vaultTimeoutService.isClientLocked["1"] = false + vaultTimeoutService.vaultLockStatusSubject.send( + VaultLockStatus(isVaultLocked: false, userId: "1") + ) + + cipherDataStore.cipherSubjectByUserId["1"]?.send([ + .fixture( + id: "1234", + login: .fixture( + username: "user@bitwarden.com", + totp: "totp" + ) + ), + ]) + + waitFor(authBridgeItemService.storedItems["1"]?.first != nil) + + let item = try XCTUnwrap(authBridgeItemService.storedItems["1"]?.first) + XCTAssertEqual(item.favorite, false) + XCTAssertEqual(item.id, "1234") + XCTAssertEqual(item.name, "Bitwarden") + XCTAssertEqual(item.totpKey, "totp") + XCTAssertEqual(item.username, "user@bitwarden.com") + } + + /// Verifies that the AuthSyncService stops listening for Cipher updates when the user's vault is locked. + /// + @MainActor + func test_determineSyncForUserId_vaultLocked() async throws { + setupInitialState() + await subject.start() + stateService.syncToAuthenticatorSubject.send(("1", true)) + try await Task.sleep(nanoseconds: 10_000_000) + + vaultTimeoutService.isClientLocked["1"] = true + vaultTimeoutService.vaultLockStatusSubject.send( + VaultLockStatus(isVaultLocked: true, userId: "1") + ) + try await Task.sleep(nanoseconds: 10_000_000) + + cipherDataStore.cipherSubjectByUserId["1"]?.send([ + .fixture( + id: "1234", + login: .fixture( + username: "user@bitwarden.com", + totp: "totp" + ) + ), + ]) + + try await Task.sleep(nanoseconds: 10_000_000) + XCTAssertNil(authBridgeItemService.storedItems["1"]?.first) + } + /// Starting the service when the feature flag is off should do nothing - no subscriptions or responses. /// @MainActor func test_start_featureFlagOff() async throws { + setupInitialState() configService.featureFlagsBool[.enableAuthenticatorSync] = false await subject.start() try sharedKeychainRepository.deleteAuthenticatorKey() - stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) - notificationCenterService.willEnterForegroundSubject.send() try await Task.sleep(nanoseconds: 10_000_000) XCTAssertNil(sharedKeychainRepository.authenticatorKey) } - /// Verifies that the AuthSyncService handles and reports errors thrown by the Cipher service.. + /// If the `start()` method is called multiple times, it should only start once - i.e. only one set of listeners, + /// no double sync, etc. + /// + @MainActor + func test_start_multipleStartsIgnored() async throws { + setupInitialState() + await subject.start() + await subject.start() + stateService.syncToAuthenticatorSubject.send(("1", true)) + cipherDataStore.cipherSubjectByUserId["1"]?.send([ + .fixture( + id: "1234", + login: .fixture( + username: "user@bitwarden.com", + totp: "totp" + ) + ), + ]) + + waitFor(authBridgeItemService.storedItems["1"]?.first != nil) + let items = try XCTUnwrap(authBridgeItemService.storedItems["1"]) + XCTAssertEqual(items.count, 1) + XCTAssertEqual(items.first?.id, "1234") + } + + /// Verifies that the AuthSyncService handles and reports errors thrown by the Cipher service. /// @MainActor func test_subscribeToCipherUpdates_error() async throws { - configService.featureFlagsBool[.enableAuthenticatorSync] = true + setupInitialState() await subject.start() - stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) - notificationCenterService.willEnterForegroundSubject.send() - cipherService.ciphersSubject.send(completion: .failure(BitwardenTestError.example)) + + cipherDataStore.cipherSubjectByUserId["1"]?.send(completion: .failure(BitwardenTestError.example)) waitFor(!errorReporter.errors.isEmpty) } -} + + /// The AuthService may not get notified about Vault locking if the user has switched accounts. Verify + /// that it checks the vault lock before beginning to decrypt and write new Ciphers received. + /// + @MainActor + func test_writeCiphers_vaultLocked() async throws { + setupInitialState() + await subject.start() + stateService.syncToAuthenticatorSubject.send(("1", true)) + try await Task.sleep(nanoseconds: 10_000_000) + + vaultTimeoutService.isClientLocked["1"] = true + cipherDataStore.cipherSubjectByUserId["1"]?.send([ + .fixture( + id: "1234", + login: .fixture( + username: "user@bitwarden.com", + totp: "totp" + ) + ), + ]) + try await Task.sleep(nanoseconds: 10_000_000) + XCTAssertNil(authBridgeItemService.storedItems["1"]?.first) + XCTAssertTrue(errorReporter.errors.isEmpty) + } + + // MARK: - Private Methods + + /// Helper function that sets up testing parameters based on the flags passed in + /// + /// Note: The defaults passed in set everything up for sync to work immediately - sync on and vault unlocked. + /// All that is necessary is to publish the sync setting or the vault status as-is to kick off sync. Override + /// to turn sync off. + /// + /// - Parameters: + /// - syncOn: The state of the syncToAuthenticator feature flag. Defaults to `true`. `true` means sync is enabled. + /// - vaultLocked: The state of the vault - `true` means the vault is locked. + /// `false` means the vault is unlocked. Defaults to `false` + /// + @MainActor + private func setupInitialState(syncOn: Bool = true, vaultLocked: Bool = false) { + cipherDataStore.cipherSubjectByUserId["1"] = CurrentValueSubject<[Cipher], Error>([]) + configService.featureFlagsBool[.enableAuthenticatorSync] = true + stateService.activeAccount = .fixture() + stateService.syncToAuthenticatorByUserId["1"] = syncOn + vaultTimeoutService.isClientLocked["1"] = vaultLocked + } +} // swiftlint:disable:this file_length diff --git a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift index e4524084ba..25a4805c51 100644 --- a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift +++ b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift @@ -632,7 +632,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le let authenticatorSyncService = DefaultAuthenticatorSyncService( authBridgeItemService: authBridgeItemService, - cipherService: cipherService, + cipherDataStore: dataStore, clientService: clientService, configService: configService, errorReporter: errorReporter, diff --git a/BitwardenShared/Core/Vault/Services/CipherServiceTests.swift b/BitwardenShared/Core/Vault/Services/CipherServiceTests.swift index 0f0261728d..eb86e3cae9 100644 --- a/BitwardenShared/Core/Vault/Services/CipherServiceTests.swift +++ b/BitwardenShared/Core/Vault/Services/CipherServiceTests.swift @@ -78,7 +78,8 @@ class CipherServiceTests: BitwardenTestCase { _ = try await iterator.next() let cipher = Cipher.fixture() - cipherDataStore.cipherSubject.value = [cipher] + let userId = stateService.activeAccount?.profile.userId ?? "" + cipherDataStore.cipherSubjectByUserId[userId]?.value = [cipher] let publisherValue = try await iterator.next() try XCTAssertEqual(XCTUnwrap(publisherValue), [cipher]) } diff --git a/BitwardenShared/Core/Vault/Services/Stores/TestHelpers/MockCipherDataStore.swift b/BitwardenShared/Core/Vault/Services/Stores/TestHelpers/MockCipherDataStore.swift index ac39f5f127..21054b75ad 100644 --- a/BitwardenShared/Core/Vault/Services/Stores/TestHelpers/MockCipherDataStore.swift +++ b/BitwardenShared/Core/Vault/Services/Stores/TestHelpers/MockCipherDataStore.swift @@ -15,7 +15,7 @@ class MockCipherDataStore: CipherDataStore { var fetchCipherId: String? var fetchCipherResult: Cipher? - var cipherSubject = CurrentValueSubject<[Cipher], Error>([]) + var cipherSubjectByUserId: [String: CurrentValueSubject<[Cipher], Error>] = [:] var replaceCiphersValue: [Cipher]? var replaceCiphersUserId: String? @@ -42,8 +42,14 @@ class MockCipherDataStore: CipherDataStore { return fetchCipherResult } - func cipherPublisher(userId _: String) -> AnyPublisher<[Cipher], Error> { - cipherSubject.eraseToAnyPublisher() + func cipherPublisher(userId: String) -> AnyPublisher<[Cipher], Error> { + if let subject = cipherSubjectByUserId[userId] { + return subject.eraseToAnyPublisher() + } else { + let subject = CurrentValueSubject<[Cipher], Error>([]) + cipherSubjectByUserId[userId] = subject + return subject.eraseToAnyPublisher() + } } func replaceCiphers(_ ciphers: [Cipher], userId: String) async throws {