ios/BitwardenShared/UI/Platform/Application/AppProcessorTests.swift

1485 lines
66 KiB
Swift

import AuthenticationServices
import AuthenticatorBridgeKit
import BitwardenKit
import BitwardenKitMocks
import BitwardenResources
import Foundation
import TestHelpers
import XCTest
@testable import BitwardenShared
// swiftlint:disable file_length
class AppProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_body_length
// MARK: Properties
var appIntentMediator: MockAppIntentMediator!
var appModule: MockAppModule!
var authRepository: MockAuthRepository!
var authenticatorSyncService: MockAuthenticatorSyncService!
var autofillCredentialService: MockAutofillCredentialService!
var clientService: MockClientService!
var configService: MockConfigService!
var coordinator: MockCoordinator<AppRoute, AppEvent>!
var environmentService: MockEnvironmentService!
var errorReporter: MockErrorReporter!
var fido2UserInterfaceHelper: MockFido2UserInterfaceHelper!
var eventService: MockEventService!
var migrationService: MockMigrationService!
var notificationCenterService: MockNotificationCenterService!
var notificationService: MockNotificationService!
var pendingAppIntentActionMediator: MockPendingAppIntentActionMediator!
var policyService: MockPolicyService!
var router: MockRouter<AuthEvent, AuthRoute>!
var stateService: MockStateService!
var subject: AppProcessor!
var syncService: MockSyncService!
var timeProvider: MockTimeProvider!
var vaultRepository: MockVaultRepository!
var vaultTimeoutService: MockVaultTimeoutService!
var didEnterBackgroundCalled = 0
var willEnterForegroundCalled = 0
// MARK: Setup & Teardown
override func setUp() { // swiftlint:disable:this function_body_length
super.setUp()
router = MockRouter(routeForEvent: { _ in .landing })
appIntentMediator = MockAppIntentMediator()
appModule = MockAppModule()
authRepository = MockAuthRepository()
authenticatorSyncService = MockAuthenticatorSyncService()
autofillCredentialService = MockAutofillCredentialService()
clientService = MockClientService()
configService = MockConfigService()
coordinator = MockCoordinator()
appModule.authRouter = router
appModule.appCoordinator = coordinator
environmentService = MockEnvironmentService()
errorReporter = MockErrorReporter()
fido2UserInterfaceHelper = MockFido2UserInterfaceHelper()
eventService = MockEventService()
migrationService = MockMigrationService()
notificationCenterService = MockNotificationCenterService()
notificationService = MockNotificationService()
pendingAppIntentActionMediator = MockPendingAppIntentActionMediator()
policyService = MockPolicyService()
stateService = MockStateService()
syncService = MockSyncService()
timeProvider = MockTimeProvider(.currentTime)
vaultRepository = MockVaultRepository()
vaultTimeoutService = MockVaultTimeoutService()
subject = AppProcessor(
appIntentMediator: appIntentMediator,
appModule: appModule,
debugDidEnterBackground: { [weak self] in self?.didEnterBackgroundCalled += 1 },
debugWillEnterForeground: { [weak self] in self?.willEnterForegroundCalled += 1 },
services: ServiceContainer.withMocks(
authRepository: authRepository,
authenticatorSyncService: authenticatorSyncService,
autofillCredentialService: autofillCredentialService,
clientService: clientService,
configService: configService,
environmentService: environmentService,
errorReporter: errorReporter,
eventService: eventService,
fido2UserInterfaceHelper: fido2UserInterfaceHelper,
migrationService: migrationService,
notificationService: notificationService,
pendingAppIntentActionMediator: pendingAppIntentActionMediator,
policyService: policyService,
notificationCenterService: notificationCenterService,
stateService: stateService,
syncService: syncService,
vaultRepository: vaultRepository,
vaultTimeoutService: vaultTimeoutService,
),
)
subject.coordinator = coordinator.asAnyCoordinator()
}
override func tearDown() {
super.tearDown()
appModule = nil
authRepository = nil
autofillCredentialService = nil
clientService = nil
configService = nil
coordinator = nil
environmentService = nil
errorReporter = nil
fido2UserInterfaceHelper = nil
eventService = nil
migrationService = nil
notificationCenterService = nil
notificationService = nil
pendingAppIntentActionMediator = nil
policyService = nil
router = nil
stateService = nil
subject = nil
syncService = nil
timeProvider = nil
vaultRepository = nil
vaultTimeoutService = nil
}
// MARK: Tests
/// `init()` subscribes to app background events and logs an error if one occurs when
/// setting the last active time.
@MainActor
func test_appBackgrounded_error() {
stateService.activeAccount = .fixture()
vaultTimeoutService.setLastActiveTimeError = BitwardenTestError.example
notificationCenterService.didEnterBackgroundSubject.send()
waitFor(!errorReporter.errors.isEmpty)
XCTAssertEqual(errorReporter.errors as? [BitwardenTestError], [.example])
}
/// The user's last active time is updated when the app is backgrounded.
@MainActor
func test_appBackgrounded_setLastActiveTime() {
let account: Account = .fixture()
stateService.activeAccount = account
vaultTimeoutService.lastActiveTime[account.profile.userId] = .distantPast
notificationCenterService.didEnterBackgroundSubject.send()
waitFor(vaultTimeoutService.lastActiveTime[account.profile.userId] != .distantPast)
let updated = vaultTimeoutService.lastActiveTime[account.profile.userId]
XCTAssertEqual(timeProvider.presentTime.timeIntervalSince1970, updated!.timeIntervalSince1970, accuracy: 1.0)
}
/// `showDebugMenu` will send the correct route to the coordinator.
@MainActor
func test_showDebugMenu() {
subject.showDebugMenu()
XCTAssertEqual(coordinator.routes.last, .debugMenu)
}
/// `didRegister(withToken:)` passes the token to the notification service.
@MainActor
func test_didRegister() throws {
let tokenData = try XCTUnwrap("tokensForFree".data(using: .utf8))
let task = Task {
subject.didRegister(withToken: tokenData)
}
waitFor(notificationService.registrationTokenData == tokenData)
task.cancel()
}
/// `failedToRegister(_:)` records the error.
@MainActor
func test_failedToRegister() {
subject.failedToRegister(BitwardenTestError.example)
XCTAssertEqual(errorReporter.errors.last as? BitwardenTestError, .example)
}
/// `init()` subscribes to will enter foreground events and handles an active user timeout.
@MainActor
func test_init_appForeground_activeUserTimeout() {
let account1 = Account.fixture(profile: .fixture(userId: "1"))
let account2 = Account.fixture(profile: .fixture(userId: "2"))
stateService.activeAccount = account1
stateService.accounts = [account1, account2]
vaultTimeoutService.shouldSessionTimeout["1"] = true
notificationCenterService.willEnterForegroundSubject.send()
// Wait for the checkSessionTimeouts method to be called
waitFor(authRepository.checkSessionTimeoutCalled)
// Simulate calling the handleActiveUser closure
if let handleActiveUserClosure = authRepository.handleActiveUserClosure {
Task {
await handleActiveUserClosure("1")
}
}
waitFor(!coordinator.events.isEmpty)
XCTAssertEqual(coordinator.events, [.didTimeout(userId: "1")])
}
/// `init()` subscribes to will enter foreground events ands completes the user's autofill setup
/// process if autofill is enabled and they previously choose to set it up later.
@MainActor
func test_init_appForeground_completeAutofillAccountSetup() async throws {
// The processor checks for autofill completion when entering the foreground. Wait for the
// initial check to finish when the test starts before continuing.
try await waitForAsync { self.willEnterForegroundCalled == 1 }
autofillCredentialService.isAutofillCredentialsEnabled = true
stateService.activeAccount = .fixture()
stateService.accounts = [.fixture()]
stateService.accountSetupAutofill["1"] = .setUpLater
notificationCenterService.willEnterForegroundSubject.send()
try await waitForAsync { self.willEnterForegroundCalled == 2 }
XCTAssertEqual(stateService.accountSetupAutofill, ["1": .complete])
}
/// `init()` subscribes to will enter foreground events and handles accountBecameActive if the
/// never timeout account is unlocked in extension and there is no pending `AppIntent` actions.
@MainActor
func test_init_appForeground_checkAccountBecomeActive() async throws {
// The processor checks for switched accounts when entering the foreground. Wait for the
// initial check to finish when the test starts before continuing.
try await waitForAsync { self.willEnterForegroundCalled == 1 }
let account: Account = .fixture(profile: .fixture(userId: "2"))
let userId = account.profile.userId
stateService.activeAccount = account
authRepository.activeAccount = account
stateService.didAccountSwitchInExtensionResult = .success(true)
authRepository.vaultTimeout = [userId: .never]
authRepository.isLockedResult = .success(true)
stateService.manuallyLockedAccounts = [userId: false]
notificationCenterService.willEnterForegroundSubject.send()
try await waitForAsync { self.willEnterForegroundCalled == 2 }
XCTAssertEqual(
coordinator.events.last,
AppEvent.accountBecameActive(
account,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: false,
),
)
}
/// `init()` subscribes to will enter foreground events and handles accountBecameActive if the
/// never timeout account is unlocked in extension and there is an empty collection of pending `AppIntent` actions.
@MainActor
func test_init_appForeground_checkAccountBecomeActivePendingAppIntentActionsEmpty() async throws {
// The processor checks for switched accounts when entering the foreground. Wait for the
// initial check to finish when the test starts before continuing.
try await waitForAsync { self.willEnterForegroundCalled == 1 }
let account: Account = .fixture(profile: .fixture(userId: "2"))
let userId = account.profile.userId
stateService.activeAccount = account
authRepository.activeAccount = account
stateService.didAccountSwitchInExtensionResult = .success(true)
authRepository.vaultTimeout = [userId: .never]
authRepository.isLockedResult = .success(true)
stateService.manuallyLockedAccounts = [userId: false]
stateService.pendingAppIntentActions = []
notificationCenterService.willEnterForegroundSubject.send()
try await waitForAsync { self.willEnterForegroundCalled == 2 }
XCTAssertEqual(
coordinator.events.last,
AppEvent.accountBecameActive(
account,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: false,
),
)
}
/// `init()` subscribes to will enter foreground events and doesn't handle accountBecameActive if the
/// never timeout account is unlocked in extension but there's a pending `.lockAll` `AppIntent`.
@MainActor
func test_init_appForeground_checkAccountBecomeActiveEventDoesntHappenWhenPendingLockAllAppIntent() async throws {
// The processor checks for switched accounts when entering the foreground. Wait for the
// initial check to finish when the test starts before continuing.
try await waitForAsync { self.willEnterForegroundCalled == 1 }
let account: Account = .fixture(profile: .fixture(userId: "2"))
let userId = account.profile.userId
stateService.activeAccount = account
authRepository.activeAccount = account
stateService.didAccountSwitchInExtensionResult = .success(true)
authRepository.vaultTimeout = [userId: .never]
authRepository.isLockedResult = .success(true)
stateService.manuallyLockedAccounts = [userId: false]
stateService.pendingAppIntentActions = [.lockAll]
notificationCenterService.willEnterForegroundSubject.send()
try await waitForAsync { self.willEnterForegroundCalled == 2 }
XCTAssertNotEqual(
coordinator.events.last,
AppEvent.accountBecameActive(
account,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: false,
),
)
}
/// `init()` subscribes to will enter foreground events and logs an error if one occurs while
/// checking if the active account was changed in an extension.
@MainActor
func test_init_appForeground_checkIfExtensionSwitchedAccounts_error() async throws {
// The processor checks for switched accounts when entering the foreground. Wait for the
// initial check to finish when the test starts before continuing.
try await waitForAsync { self.willEnterForegroundCalled == 1 }
stateService.didAccountSwitchInExtensionResult = .failure(BitwardenTestError.example)
notificationCenterService.willEnterForegroundSubject.send()
try await waitForAsync { self.willEnterForegroundCalled == 2 }
XCTAssertTrue(coordinator.events.isEmpty)
XCTAssertEqual(errorReporter.errors as? [BitwardenTestError], [.example])
}
/// `init()` subscribes to will enter foreground events and doesn't make any navigation changes
/// if the active account wasn't changed in the extension.
@MainActor
func test_init_appForeground_checkIfExtensionSwitchedAccounts_accountNotSwitched() async throws {
// The processor checks for switched accounts when entering the foreground. Wait for the
// initial check to finish when the test starts before continuing.
try await waitForAsync { self.willEnterForegroundCalled == 1 }
stateService.didAccountSwitchInExtensionResult = .success(false)
notificationCenterService.willEnterForegroundSubject.send()
try await waitForAsync { self.willEnterForegroundCalled == 2 }
XCTAssertTrue(coordinator.events.isEmpty)
}
/// `init()` subscribes to will enter foreground events and handles switching accounts if the
/// active account was changed in the extension.
@MainActor
func test_init_appForeground_checkIfExtensionSwitchedAccounts_accountSwitched() async throws {
// The processor checks for switched accounts when entering the foreground. Wait for the
// initial check to finish when the test starts before continuing.
try await waitForAsync { self.willEnterForegroundCalled == 1 }
stateService.activeAccount = .fixture(profile: .fixture(userId: "2"))
stateService.didAccountSwitchInExtensionResult = .success(true)
notificationCenterService.willEnterForegroundSubject.send()
try await waitForAsync { self.willEnterForegroundCalled == 2 }
XCTAssertEqual(coordinator.events, [.switchAccounts(userId: "2", isAutomatic: false)])
}
/// `init()` subscribes to will enter foreground events and doesn't check for an account switch
/// when running in the extension.
@MainActor
func test_init_appForeground_checkIfExtensionSwitchedAccounts_inExtension() async throws {
let delegate = MockAppExtensionDelegate()
delegate.isInAppExtension = true
let notificationCenterService = MockNotificationCenterService()
let stateService = MockStateService()
var willEnterForegroundCalled = 0
_ = AppProcessor(
appExtensionDelegate: delegate,
appModule: appModule,
debugWillEnterForeground: { willEnterForegroundCalled += 1 },
services: ServiceContainer.withMocks(
notificationCenterService: notificationCenterService,
stateService: stateService,
),
)
try await waitForAsync { willEnterForegroundCalled == 1 }
stateService.didAccountSwitchInExtensionResult = .success(true)
notificationCenterService.willEnterForegroundSubject.send()
try await waitForAsync { willEnterForegroundCalled == 2 }
XCTAssertTrue(coordinator.events.isEmpty)
}
/// `init()` subscribes to will enter foreground events and restarts the app is there's no
/// active account.
@MainActor
func test_init_appForeground_checkIfExtensionSwitchedAccounts_noActiveAccount() async throws {
// The processor checks for switched accounts when entering the foreground. Wait for the
// initial check to finish when the test starts before continuing.
try await waitForAsync { self.willEnterForegroundCalled == 1 }
stateService.didAccountSwitchInExtensionResult = .success(true)
notificationCenterService.willEnterForegroundSubject.send()
try await waitForAsync { self.willEnterForegroundCalled == 2 }
XCTAssertEqual(coordinator.events, [.didStart])
}
/// `init()` sets the `AppProcessor` as the delegate of any necessary services.
func test_init_setDelegates() {
XCTAssertIdentical(notificationService.delegate, subject)
XCTAssertIdentical(syncService.delegate, subject)
}
/// `handleAppLinks(URL)` navigates the user based on the input URL.
@MainActor
func test_init_handleAppLinks() {
// swiftlint:disable:next line_length
let url = URL(string: "https://bitwarden.com/redirect-connector.html#finish-signup?email=example@email.com&token=verificationtoken&fromEmail=true")
subject.handleAppLinks(incomingURL: url!)
XCTAssertEqual(coordinator.routes.last, .auth(.completeRegistrationFromAppLink(
emailVerificationToken: "verificationtoken",
userEmail: "example@email.com",
fromEmail: true,
)))
}
/// `handleAppLinks(URL)` navigates the user based on the input URL with wrong fromEmail value.
@MainActor
func test_init_handleAppLinks_fromEmail_notBool() {
// swiftlint:disable:next line_length
let url = URL(string: "https://bitwarden.eu/redirect-connector.html#finish-signup?email=example@email.com&token=verificationtoken&fromEmail=potato")
subject.handleAppLinks(incomingURL: url!)
XCTAssertEqual(coordinator.routes.last, .auth(.completeRegistrationFromAppLink(
emailVerificationToken: "verificationtoken",
userEmail: "example@email.com",
fromEmail: true,
)))
}
/// `handleAppLinks(URL)` checks error report for `.appLinksInvalidURL`.
@MainActor
func test_init_handleAppLinks_invalidURL() {
// swiftlint:disable:next line_length
let noPathUrl = URL(string: "https://bitwarden.com/redirect-connector.html#email=example@email.com&token=verificationtoken")
subject.handleAppLinks(incomingURL: noPathUrl!)
XCTAssertEqual(errorReporter.errors.last as? AppProcessorError, .appLinksInvalidURL)
XCTAssertEqual(errorReporter.errors.count, 1)
errorReporter.errors.removeAll()
let noParamsUrl = URL(string: "https://bitwarden.com/redirect-connector.html#finish-signup/")
subject.handleAppLinks(incomingURL: noParamsUrl!)
XCTAssertEqual(errorReporter.errors.last as? AppProcessorError, .appLinksInvalidURL)
XCTAssertEqual(errorReporter.errors.count, 1)
errorReporter.errors.removeAll()
let invalidHostUrl = URL(string: "/finish-signup?email=example@email.com")
subject.handleAppLinks(incomingURL: invalidHostUrl!)
XCTAssertEqual(errorReporter.errors.last as? AppProcessorError, .appLinksInvalidURL)
XCTAssertEqual(errorReporter.errors.count, 1)
}
/// `handleAppLinks(URL)` checks error report for `.appLinksInvalidPath`.
@MainActor
func test_init_handleAppLinks_invalidPath() {
// swiftlint:disable:next line_length
let url = URL(string: "https://bitwarden.com/redirect-connector.html#not-valid?email=example@email.com&token=verificationtoken&fromEmail=true")
subject.handleAppLinks(incomingURL: url!)
XCTAssertEqual(errorReporter.errors.last as? AppProcessorError, .appLinksInvalidPath)
}
/// `handleAppLinks(URL)` checks error report for `.appLinksInvalidParametersForPath`.
@MainActor
func test_init_handleAppLinks_invalidParametersForPath() {
// swiftlint:disable:next line_length
var url = URL(string: "https://bitwarden.com/redirect-connector.html#finish-signup?token=verificationtoken&fromEmail=true")
subject.handleAppLinks(incomingURL: url!)
XCTAssertEqual(errorReporter.errors.last as? AppProcessorError, .appLinksInvalidParametersForPath)
XCTAssertEqual(errorReporter.errors.count, 1)
errorReporter.errors.removeAll()
// swiftlint:disable:next line_length
url = URL(string: "https://bitwarden.com/redirect-connector.html#finish-signup?email=example@email.com&fromEmail=true")
subject.handleAppLinks(incomingURL: url!)
XCTAssertEqual(errorReporter.errors.last as? AppProcessorError, .appLinksInvalidParametersForPath)
XCTAssertEqual(errorReporter.errors.count, 1)
errorReporter.errors.removeAll()
// swiftlint:disable:next line_length
url = URL(string: "https://bitwarden.com/redirect-connector.html#finish-signup?email=example@email.com&token=verificationtoken")
subject.handleAppLinks(incomingURL: url!)
XCTAssertEqual(errorReporter.errors.last as? AppProcessorError, .appLinksInvalidParametersForPath)
XCTAssertEqual(errorReporter.errors.count, 1)
errorReporter.errors.removeAll()
}
/// `init()` subscribes to will pending App Intent actions publisher and handles an active user timeout.
@MainActor
func test_init_pendingAppIntentActionsTask() {
// Wait and reset for the first publisher default values which are `nil`.
waitFor(pendingAppIntentActionMediator.executePendingAppIntentActionsCalled)
pendingAppIntentActionMediator.executePendingAppIntentActionsCalled = false
stateService.pendingAppIntentActionsSubject.send([.lockAll])
waitFor(pendingAppIntentActionMediator.executePendingAppIntentActionsCalled)
}
/// `init()` starts the upload-event timer and attempts to upload events.
@MainActor
func test_init_uploadEvents() {
XCTAssertNotNil(subject.sendEventTimer)
XCTAssertEqual(subject.sendEventTimer?.isValid, true)
subject.sendEventTimer?.fire() // Necessary because it's a 5-minute timer
waitFor(eventService.uploadCalled)
XCTAssertTrue(eventService.uploadCalled)
}
/// `getter:isAutofillingFromList` returns `false` when delegate is not a Fido2 one.
@MainActor
func test_isAutofillingFromList_falseNoFido2Delegate() async throws {
XCTAssertFalse(subject.isAutofillingFromList)
}
/// `messageReceived(_:notificationDismissed:notificationTapped)` passes the data to the notification service.
func test_messageReceived() async {
let message: [AnyHashable: Any] = ["knock knock": "who's there?"]
await subject.messageReceived(message)
XCTAssertEqual(notificationService.messageReceivedMessage?.keys.first, "knock knock")
}
/// `onNeedsUserInteraction()` doesn't throw when `appExtensionDelegate` is not a Fido2 one.
func test_onNeedsUserInteraction_flowWithUserInteraction() async {
await assertAsyncDoesNotThrow {
try await subject.onNeedsUserInteraction()
}
}
/// `onPendingAppIntentActionSuccess(_:data:)` handles event `.accountBecameActive` when
/// pending app intent action is `.lockAll` and `data` is an account.
@MainActor
func test_onPendingAppIntentActionSuccess_lockAll() async {
let account = Account.fixture()
await subject.onPendingAppIntentActionSuccess(.lockAll, data: account)
XCTAssertEqual(
coordinator.events,
[
.accountBecameActive(
account,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: false,
),
],
)
}
/// `onPendingAppIntentActionSuccess(_:data:)` doesn't handle event `.accountBecameActive` when
/// pending app intent action is `.lockAll` and `data` is `nil`.
@MainActor
func test_onPendingAppIntentActionSuccess_lockAllNoData() async {
await subject.onPendingAppIntentActionSuccess(.lockAll, data: nil)
XCTAssertTrue(coordinator.events.isEmpty)
}
/// `onPendingAppIntentActionSuccess(_:data:)` doesn't handle event `.accountBecameActive` when
/// pending app intent action is `.lockAll` and `data` is not an `Account`.
@MainActor
func test_onPendingAppIntentActionSuccess_lockAllDataNoAccount() async {
await subject.onPendingAppIntentActionSuccess(.lockAll, data: "noAccount")
XCTAssertTrue(coordinator.events.isEmpty)
}
/// `onPendingAppIntentActionSuccess(_:data:)` handles event `.didLogOutAll` when
/// pending app intent action is `.logOutAll`.
@MainActor
func test_onPendingAppIntentActionSuccess_logOutAll() async {
await subject.onPendingAppIntentActionSuccess(.logOutAll, data: nil)
XCTAssertEqual(
coordinator.events,
[.didLogout(userId: nil, userInitiated: true)],
)
}
/// `onPendingAppIntentActionSuccess(_:data:)` sets `setAuthCompletionRoute` as the generator when
/// pending app intent action is `.openGenerator` and the vault is locked.
@MainActor
func test_onPendingAppIntentActionSuccess_openGeneratorVaultLocked() async throws {
await subject.onPendingAppIntentActionSuccess(.openGenerator, data: nil)
XCTAssertEqual(coordinator.events, [.setAuthCompletionRoute(.tab(.generator(.generator())))])
}
/// `onPendingAppIntentActionSuccess(_:data:)` handles navigation to the generator screen when
/// pending app intent action is `.openGenerator` and the vault is unlocked.
@MainActor
func test_onPendingAppIntentActionSuccess_openGeneratorVaultUnlocked() async throws {
let account = Account.fixture()
stateService.activeAccount = .fixture()
vaultTimeoutService.isClientLocked[account.profile.userId] = false
await subject.onPendingAppIntentActionSuccess(.openGenerator, data: nil)
XCTAssertEqual(coordinator.routes.last, .tab(.generator(.generator())))
}
/// `onPendingAppIntentActionSuccess(_:data:)` handles receiving a pending AppIntent action for `.openGenerator`
/// and setting an auth completion route on the coordinator if the the user's vault is unlocked
/// but will be timing out as the app is foregrounded.
@MainActor
func test_onPendingAppIntentActionSuccess_openGeneratorVaultUnlockedTimeout() async throws {
let account = Account.fixture()
stateService.activeAccount = .fixture()
vaultTimeoutService.isClientLocked[account.profile.userId] = false
vaultTimeoutService.shouldSessionTimeout[account.profile.userId] = true
await subject.onPendingAppIntentActionSuccess(.openGenerator, data: nil)
XCTAssertEqual(coordinator.events, [.setAuthCompletionRoute(.tab(.generator(.generator())))])
}
/// `onPendingAppIntentActionSuccess(_:data:)` handles receiving a pending AppIntent action for `.openGenerator`
/// and setting an auth completion route on the coordinator if the the user's vault is unlocked
/// but checking timing out as throws an error.
@MainActor
func test_onPendingAppIntentActionSuccess_openGeneratorVaultUnlockedTimeoutError() async throws {
let account = Account.fixture()
stateService.activeAccount = .fixture()
vaultTimeoutService.isClientLocked[account.profile.userId] = false
vaultTimeoutService.shouldSessionTimeoutError = BitwardenTestError.example
await subject.onPendingAppIntentActionSuccess(.openGenerator, data: nil)
XCTAssertEqual(coordinator.events, [.setAuthCompletionRoute(.tab(.generator(.generator())))])
}
/// `openUrl(_:)` handles receiving a bitwarden deep link and setting an auth completion route on the
/// coordinator to handle routing to the account security screen when the vault is unlocked.
@MainActor
func test_openUrl_bitwardenAccountSecurity_vaultLocked() async throws {
await subject.openUrl(.bitwardenAccountSecurity)
XCTAssertEqual(coordinator.events, [.setAuthCompletionRoute(.tab(.settings(.accountSecurity)))])
}
/// `openUrl(_:)` handles receiving a bitwarden deep link and routing to the account security screen.
@MainActor
func test_openUrl_bitwardenAccountSecurity_vaultUnlocked() async throws {
let account = Account.fixture()
stateService.activeAccount = .fixture()
vaultTimeoutService.isClientLocked[account.profile.userId] = false
await subject.openUrl(.bitwardenAccountSecurity)
XCTAssertEqual(coordinator.routes.last, .tab(.settings(.accountSecurity)))
}
/// `openUrl(_:)` handles receiving a bitwarden deep link and setting an auth completion route on the
/// coordinator if the the user's vault is unlocked but will be timing out as the app is
/// foregrounded.
@MainActor
func test_openUrl_bitwardenAccountSecurity_vaultUnlockedTimeout() async throws {
let account = Account.fixture()
stateService.activeAccount = .fixture()
vaultTimeoutService.isClientLocked[account.profile.userId] = false
vaultTimeoutService.shouldSessionTimeout[account.profile.userId] = true
await subject.openUrl(.bitwardenAccountSecurity)
XCTAssertEqual(coordinator.events, [.setAuthCompletionRoute(.tab(.settings(.accountSecurity)))])
}
/// `openUrl(_:)` handles receiving a bitwarden deep link and setting an auth completion route on the
/// coordinator if the the user's vault is unlocked but will be timing out as the app is
/// foregrounded.
@MainActor
func test_openUrl_bitwardenAccountSecurity_vaultUnlockedTimeoutError() async throws {
let account = Account.fixture()
stateService.activeAccount = .fixture()
vaultTimeoutService.isClientLocked[account.profile.userId] = false
vaultTimeoutService.shouldSessionTimeoutError = BitwardenTestError.example
await subject.openUrl(.bitwardenAccountSecurity)
XCTAssertEqual(coordinator.events, [.setAuthCompletionRoute(.tab(.settings(.accountSecurity)))])
}
/// `openUrl(_:)` handles receiving a bitwarden Authenticator new item deep link with the vault unlocked and an
/// invalid item is found. It shows a generic error alert and does not produce a route.
@MainActor
func test_openUrl_bitwardenAuthenticatorNewItem_invalidItem() async throws {
let account = Account.fixture()
stateService.activeAccount = .fixture()
vaultTimeoutService.isClientLocked[account.profile.userId] = false
authenticatorSyncService.tempItem = AuthenticatorBridgeItemDataView(
accountDomain: nil,
accountEmail: nil,
favorite: false,
id: "",
name: "",
totpKey: nil,
username: nil,
)
await subject.openUrl(.bitwardenAuthenticatorNewItem)
XCTAssertEqual(coordinator.alertShown.first,
.defaultAlert(title: Localizations.somethingWentWrong,
message: Localizations.unableToMoveTheSelectedItemPleaseTryAgain))
XCTAssertEqual(coordinator.routes, [])
}
/// `openUrl(_:)` handles receiving a bitwarden Authenticator new item deep link when no temporary item is
/// found in the shared store. It shows a generic error alert and does not produce a route.
@MainActor
func test_openUrl_bitwardenAuthenticatorNewItem_noItemFound() async throws {
let account = Account.fixture()
stateService.activeAccount = .fixture()
vaultTimeoutService.isClientLocked[account.profile.userId] = false
authenticatorSyncService.tempItem = nil
await subject.openUrl(.bitwardenAuthenticatorNewItem)
XCTAssertEqual(coordinator.alertShown.first,
.defaultAlert(title: Localizations.somethingWentWrong,
message: Localizations.unableToMoveTheSelectedItemPleaseTryAgain))
XCTAssertEqual(coordinator.routes, [])
}
/// `openUrl(_:)` handles receiving a bitwarden Authenticator new item deep link with the vault unlocked and the
/// item is found, but the item has no TOTP key. It shows a generic error alert and does not produce a route.
@MainActor
func test_openUrl_bitwardenAuthenticatorNewItem_noTotpKey() async throws {
let account = Account.fixture()
stateService.activeAccount = .fixture()
vaultTimeoutService.isClientLocked[account.profile.userId] = false
authenticatorSyncService.tempItem = AuthenticatorBridgeItemDataView(
accountDomain: nil,
accountEmail: nil,
favorite: false,
id: "",
name: "",
totpKey: nil,
username: nil,
)
await subject.openUrl(.bitwardenAuthenticatorNewItem)
XCTAssertEqual(coordinator.alertShown.first,
.defaultAlert(title: Localizations.somethingWentWrong,
message: Localizations.unableToMoveTheSelectedItemPleaseTryAgain))
XCTAssertEqual(coordinator.routes, [])
}
/// `openUrl(_:)` handles receiving a bitwarden Authenticator new item deep link with the vault unlocked and the
/// item is found.
@MainActor
func test_openUrl_bitwardenAuthenticatorNewItem_success() async throws {
let account = Account.fixture()
let otpKey: String = .otpAuthUriKeyComplete
let model = TOTPKeyModel(authenticatorKey: otpKey)
stateService.activeAccount = .fixture()
vaultTimeoutService.isClientLocked[account.profile.userId] = false
authenticatorSyncService.tempItem = AuthenticatorBridgeItemDataView(
accountDomain: nil,
accountEmail: nil,
favorite: false,
id: "",
name: "",
totpKey: otpKey,
username: nil,
)
await subject.openUrl(.bitwardenAuthenticatorNewItem)
XCTAssertEqual(coordinator.routes.last, .tab(.vault(.vaultItemSelection(model))))
}
/// `openUrl(_:)` handles receiving a bitwarden link with an invalid path and
/// silently returns with a no-op.
@MainActor
func test_openUrl_bitwardenInvalidPath_failSilently() async throws {
await subject.openUrl(.bitwardenInvalidPath)
XCTAssertEqual(coordinator.alertShown, [])
XCTAssertEqual(coordinator.routes, [])
}
/// `openUrl(_:)` handles receiving a bitwarden link with nothing but the scheme (i.e. `bitwarden://`) and
/// silently returns with a no-op.
@MainActor
func test_openUrl_bitwardenSchemeOnly_failSilently() async throws {
await subject.openUrl(.bitwardenSchemeOnly)
XCTAssertEqual(coordinator.alertShown, [])
XCTAssertEqual(coordinator.routes, [])
}
/// `openUrl(_:)` handles receiving an OTP deep link and setting an auth completion route on the
/// coordinator to handle routing to the vault item selection screen when the vault is unlocked.
@MainActor
func test_openUrl_otpKey_vaultLocked() async throws {
let otpKey: String = .otpAuthUriKeyComplete
try await subject.openUrl(XCTUnwrap(URL(string: otpKey)))
let model = TOTPKeyModel(authenticatorKey: otpKey)
XCTAssertEqual(coordinator.events, [.setAuthCompletionRoute(.tab(.vault(.vaultItemSelection(model))))])
}
/// `openUrl(_:)` handles receiving an OTP deep link and routing to the vault item selection screen.
@MainActor
func test_openUrl_otpKey_vaultUnlocked() async throws {
let account = Account.fixture()
let otpKey: String = .otpAuthUriKeyComplete
stateService.activeAccount = .fixture()
vaultTimeoutService.isClientLocked[account.profile.userId] = false
try await subject.openUrl(XCTUnwrap(URL(string: otpKey)))
let model = TOTPKeyModel(authenticatorKey: otpKey)
XCTAssertEqual(coordinator.routes.last, .tab(.vault(.vaultItemSelection(model))))
}
/// `openUrl(_:)` handles receiving an OTP deep link and setting an auth completion route on the
/// coordinator if the the user's vault is unlocked but will be timing out as the app is
/// foregrounded.
@MainActor
func test_openUrl_otpKey_vaultUnlockedTimeout() async throws {
let account = Account.fixture()
let otpKey: String = .otpAuthUriKeyComplete
stateService.activeAccount = .fixture()
vaultTimeoutService.isClientLocked[account.profile.userId] = false
vaultTimeoutService.shouldSessionTimeout[account.profile.userId] = true
try await subject.openUrl(XCTUnwrap(URL(string: otpKey)))
let model = TOTPKeyModel(authenticatorKey: otpKey)
XCTAssertEqual(coordinator.events, [.setAuthCompletionRoute(.tab(.vault(.vaultItemSelection(model))))])
}
/// `openUrl(_:)` handles receiving an OTP deep link and setting an auth completion route on the
/// coordinator if the the user's vault is unlocked but will be timing out as the app is
/// foregrounded.
@MainActor
func test_openUrl_otpKey_vaultUnlockedTimeoutError() async throws {
let account = Account.fixture()
let otpKey: String = .otpAuthUriKeyComplete
stateService.activeAccount = .fixture()
vaultTimeoutService.isClientLocked[account.profile.userId] = false
vaultTimeoutService.shouldSessionTimeoutError = BitwardenTestError.example
try await subject.openUrl(XCTUnwrap(URL(string: otpKey)))
let model = TOTPKeyModel(authenticatorKey: otpKey)
XCTAssertEqual(coordinator.events, [.setAuthCompletionRoute(.tab(.vault(.vaultItemSelection(model))))])
}
/// `openUrl(_:)` handles receiving an non OTP deep link and silently returns with a no-op.
@MainActor
func test_openUrl_nonOtpKey_failSilently() async throws {
try await subject.openUrl(XCTUnwrap(URL(string: "bitwarden://")))
XCTAssertEqual(coordinator.alertShown, [])
XCTAssertEqual(coordinator.routes, [])
}
/// `openUrl(_:)` handles receiving an OTP deep link if the URL isn't an OTP key.
@MainActor
func test_openUrl_otpKey_invalid() async throws {
let otpKey: String = .otpAuthUriKeyNoSecret
try await subject.openUrl(XCTUnwrap(URL(string: otpKey)))
XCTAssertEqual(coordinator.alertShown, [.defaultAlert(title: Localizations.anErrorHasOccurred)])
XCTAssertEqual(coordinator.routes, [])
}
/// `provideCredential(for:)` returns the credential with the specified identifier.
func test_provideCredential() async throws {
let credential = ASPasswordCredential(user: "user@bitwarden.com", password: "password123")
autofillCredentialService.provideCredentialPasswordCredential = credential
let providedCredential = try await subject.provideCredential(for: "1")
XCTAssertEqual(providedCredential.user, "user@bitwarden.com")
XCTAssertEqual(providedCredential.password, "password123")
}
/// `provideCredential(for:)` throws an error if one occurs.
func test_provideCredential_error() async throws {
autofillCredentialService.provideCredentialError = ASExtensionError(.userInteractionRequired)
await assertAsyncThrows(error: ASExtensionError(.userInteractionRequired)) {
_ = try await subject.provideCredential(for: "1")
}
}
/// `provideOTPCredential(for:repromptPasswordValidated:)` returns the credential with the specified identifier.
@available(iOS 18.0, *)
func test_provideOTPCredential() async throws {
let credential = ASOneTimeCodeCredential(code: "123")
autofillCredentialService.provideOTPCredentialResult = .success(credential)
let providedCredential = try await subject.provideOTPCredential(for: "1")
XCTAssertEqual(providedCredential.code, "123")
}
/// `provideOTPCredential(for:repromptPasswordValidated:)` throws an error if one occurs.
@available(iOS 18.0, *)
func test_provideOTPCredential_error() async throws {
autofillCredentialService.provideOTPCredentialResult = .failure(ASExtensionError(.userInteractionRequired))
await assertAsyncThrows(error: ASExtensionError(.userInteractionRequired)) {
_ = try await subject.provideOTPCredential(for: "1")
}
}
/// `repromptForCredentialIfNecessary(for:)` reprompts the user for their master password if
/// reprompt is enabled for the cipher.
@MainActor
func test_repromptForCredentialIfNecessary() throws {
vaultRepository.repromptRequiredForCipherResult = .success(true)
var masterPasswordValidated: Bool?
let expectation = expectation(description: #function)
Task {
try await subject.repromptForCredentialIfNecessary(for: "1") { validated in
masterPasswordValidated = validated
expectation.fulfill()
}
}
waitFor(!coordinator.alertShown.isEmpty)
XCTAssertEqual(coordinator.alertShown.count, 1)
let alert = try XCTUnwrap(coordinator.alertShown.last)
XCTAssertEqual(alert, .masterPasswordPrompt { _ in })
var textField = try XCTUnwrap(alert.alertTextFields.first)
textField = AlertTextField(id: "password", text: "password")
let submitAction = try XCTUnwrap(alert.alertActions.first(where: { $0.title == Localizations.submit }))
Task {
await submitAction.handler?(submitAction, [textField])
}
waitForExpectations(timeout: 1)
XCTAssertEqual(masterPasswordValidated, true)
}
/// `repromptForCredentialIfNecessary(for:)` logs the error if one occurs.
@MainActor
func test_repromptForCredentialIfNecessary_error() throws {
authRepository.validatePasswordResult = .failure(BitwardenTestError.example)
vaultRepository.repromptRequiredForCipherResult = .success(true)
var masterPasswordValidated: Bool?
let expectation = expectation(description: #function)
Task {
try await subject.repromptForCredentialIfNecessary(for: "1") { validated in
masterPasswordValidated = validated
expectation.fulfill()
}
}
waitFor(!coordinator.alertShown.isEmpty)
XCTAssertEqual(coordinator.alertShown.count, 1)
let alert = try XCTUnwrap(coordinator.alertShown.last)
XCTAssertEqual(alert, .masterPasswordPrompt { _ in })
Task {
try await alert.tapAction(title: Localizations.submit)
}
waitForExpectations(timeout: 1)
XCTAssertEqual(errorReporter.errors as? [BitwardenTestError], [.example])
XCTAssertEqual(masterPasswordValidated, false)
}
/// `repromptForCredentialIfNecessary(for:)` displays an alert if the user enters an invalid
/// password into the master password reprompt alert.
@MainActor
func test_repromptForCredentialIfNecessary_invalidPassword() throws {
authRepository.validatePasswordResult = .success(false)
vaultRepository.repromptRequiredForCipherResult = .success(true)
var masterPasswordValidated: Bool?
let expectation = expectation(description: #function)
Task {
try await subject.repromptForCredentialIfNecessary(for: "1") { validated in
masterPasswordValidated = validated
expectation.fulfill()
}
}
waitFor(!coordinator.alertShown.isEmpty)
XCTAssertEqual(coordinator.alertShown.count, 1)
var alert = try XCTUnwrap(coordinator.alertShown.last)
Task {
try await alert.tapAction(title: Localizations.submit)
}
waitFor(coordinator.alertShown.count == 2)
alert = try XCTUnwrap(coordinator.alertShown.last)
XCTAssertEqual(alert, .defaultAlert(title: Localizations.invalidMasterPassword))
Task {
try await alert.tapAction(title: Localizations.ok)
}
coordinator.alertOnDismissed?()
waitForExpectations(timeout: 1)
XCTAssertEqual(masterPasswordValidated, false)
}
/// `repromptForCredentialIfNecessary(for:)` calls the completion handler if reprompt isn't
/// required for the cipher.
func test_repromptForCredentialIfNecessary_repromptNotRequired() async throws {
vaultRepository.repromptRequiredForCipherResult = .success(false)
var masterPasswordValidated: Bool?
try await subject.repromptForCredentialIfNecessary(for: "1") { validated in
masterPasswordValidated = validated
}
XCTAssertEqual(masterPasswordValidated, false)
}
/// `routeToLanding(_:)` navigates to show the landing view.
@MainActor
func test_routeToLanding() async {
await subject.routeToLanding()
XCTAssertEqual(coordinator.routes.last, .auth(.landing))
}
/// `showLoginRequest(_:)` navigates to show the login request view.
@MainActor
func test_showLoginRequest() {
subject.showLoginRequest(.fixture())
XCTAssertEqual(coordinator.routes.last, .loginRequest(.fixture()))
}
/// `start(navigator:)` builds the AppCoordinator and navigates to the initial route if provided.
@MainActor
func test_start_initialRoute() async {
let rootNavigator = MockRootNavigator()
await subject.start(
appContext: .mainApp,
initialRoute: .extensionSetup(.extensionActivation(type: .appExtension)),
navigator: rootNavigator,
window: nil,
)
waitFor(!coordinator.routes.isEmpty)
XCTAssertTrue(appModule.appCoordinator.isStarted)
XCTAssertEqual(
appModule.appCoordinator.routes,
[.extensionSetup(.extensionActivation(type: .appExtension))],
)
XCTAssertEqual(migrationService.didPerformMigrations, true)
}
/// `start(navigator:)` builds the AppCoordinator and navigates to the `.didStart` route.
@MainActor
func test_start_authRoute() async {
let rootNavigator = MockRootNavigator()
await subject.start(appContext: .mainApp, navigator: rootNavigator, window: nil)
waitFor(!coordinator.events.isEmpty)
XCTAssertTrue(appModule.appCoordinator.isStarted)
XCTAssertEqual(appModule.appCoordinator.events, [.didStart])
XCTAssertEqual(migrationService.didPerformMigrations, true)
}
/// `start(navigator:)` doesn't complete the accounts autofill setup when running in an app extension.
@MainActor
func test_start_completeAutofillAccountSetupIfEnabled_appExtension() async throws {
let delegate = MockAppExtensionDelegate()
delegate.isInAppExtension = true
var willEnterForegroundCalled = 0
let subject = AppProcessor(
appExtensionDelegate: delegate,
appModule: appModule,
debugWillEnterForeground: { willEnterForegroundCalled += 1 },
services: ServiceContainer.withMocks(
autofillCredentialService: autofillCredentialService,
configService: configService,
stateService: stateService,
),
)
try await waitForAsync { willEnterForegroundCalled == 1 }
autofillCredentialService.isAutofillCredentialsEnabled = true
stateService.activeAccount = .fixture()
stateService.accounts = [.fixture()]
stateService.accountSetupAutofill["1"] = .setUpLater
let rootNavigator = MockRootNavigator()
await subject.start(appContext: .mainApp, navigator: rootNavigator, window: nil)
XCTAssertEqual(stateService.accountSetupAutofill, ["1": .setUpLater])
}
/// `start(navigator:)` doesn't complete the accounts autofill setup if autofill is disabled.
@MainActor
func test_start_completeAutofillAccountSetupIfEnabled_autofillDisabled() async {
autofillCredentialService.isAutofillCredentialsEnabled = false
stateService.activeAccount = .fixture()
stateService.accounts = [.fixture()]
stateService.accountSetupAutofill["1"] = .setUpLater
let rootNavigator = MockRootNavigator()
await subject.start(appContext: .mainApp, navigator: rootNavigator, window: nil)
XCTAssertEqual(stateService.accountSetupAutofill, ["1": .setUpLater])
}
/// `start(navigator:)` logs an error if one occurs while updating the account's autofill setup.
@MainActor
func test_start_completeAutofillAccountSetupIfEnabled_error() async throws {
// The processor checks for autofill completion when entering the foreground. Wait for the
// initial check to finish when the test starts before continuing.
try await waitForAsync { self.willEnterForegroundCalled == 1 }
autofillCredentialService.isAutofillCredentialsEnabled = true
stateService.accounts = [.fixture()]
stateService.accountSetupAutofill["1"] = .setUpLater
stateService.accountSetupAutofillError = BitwardenTestError.example
let rootNavigator = MockRootNavigator()
await subject.start(appContext: .mainApp, navigator: rootNavigator, window: nil)
XCTAssertEqual(errorReporter.errors as? [BitwardenTestError], [.example])
XCTAssertEqual(stateService.accountSetupAutofill, ["1": .setUpLater])
}
/// `start(navigator:)` doesn't log an error if there's no accounts.
@MainActor
func test_start_completeAutofillAccountSetupIfEnabled_noAccounts() async throws {
autofillCredentialService.isAutofillCredentialsEnabled = true
let rootNavigator = MockRootNavigator()
await subject.start(appContext: .mainApp, navigator: rootNavigator, window: nil)
XCTAssertTrue(errorReporter.errors.isEmpty)
XCTAssertTrue(stateService.accountSetupAutofill.isEmpty)
}
/// `start(navigator:)` doesn't update the user's autofill setup progress if they have no
/// current progress recorded.
@MainActor
func test_start_completeAutofillAccountSetupIfEnabled_noProgress() async {
autofillCredentialService.isAutofillCredentialsEnabled = true
stateService.activeAccount = .fixture()
stateService.accounts = [.fixture()]
let rootNavigator = MockRootNavigator()
await subject.start(appContext: .mainApp, navigator: rootNavigator, window: nil)
XCTAssertTrue(stateService.accountSetupAutofill.isEmpty)
}
/// `start(navigator:)` completes the user's autofill setup progress if autofill is enabled and
/// they previously choose to set it up later.
@MainActor
func test_start_completeAutofillAccountSetupIfEnabled_success() async throws {
// The processor checks for autofill completion when entering the foreground. Wait for the
// initial check to finish when the test starts before continuing.
try await waitForAsync { self.willEnterForegroundCalled == 1 }
autofillCredentialService.isAutofillCredentialsEnabled = true
stateService.activeAccount = .fixture()
stateService.accounts = [.fixture()]
stateService.accountSetupAutofill["1"] = .setUpLater
let rootNavigator = MockRootNavigator()
await subject.start(appContext: .mainApp, navigator: rootNavigator, window: nil)
XCTAssertEqual(stateService.accountSetupAutofill, ["1": .complete])
}
/// `switchAccountsForLoginRequest(to:showAlert:)` has the coordinator switch to the specified
/// account without showing a confirmation alert.
@MainActor
func test_switchAccountsForLoginRequest() async {
await subject.switchAccountsForLoginRequest(to: .fixture(), showAlert: false)
XCTAssertEqual(coordinator.events, [.switchAccounts(userId: "1", isAutomatic: false)])
}
/// `switchAccountsForLoginRequest(to:showAlert:)` shows an alert to confirm the user wants to
/// switch to the specified account and then has the coordinator switch accounts.
@MainActor
func test_switchAccountsForLoginRequest_showAlert() async throws {
let account = Account.fixture()
await subject.switchAccountsForLoginRequest(to: account, showAlert: true)
let alert = try XCTUnwrap(coordinator.alertShown.last)
XCTAssertEqual(
alert,
.confirmation(
title: Localizations.logInRequested,
message: Localizations.loginAttemptFromXDoYouWantToSwitchToThisAccount(account.profile.email),
confirmationHandler: {},
),
)
try await alert.tapAction(title: Localizations.cancel)
XCTAssertTrue(coordinator.events.isEmpty)
try await alert.tapAction(title: Localizations.yes)
XCTAssertEqual(coordinator.events, [.switchAccounts(userId: account.profile.userId, isAutomatic: false)])
}
/// `unlockVaultWithNeverlockKey()` unlocks it calling the auth repository.
func test_unlockVaultWithNeverlockKey() async throws {
try await subject.unlockVaultWithNeverlockKey()
XCTAssertTrue(authRepository.unlockVaultWithNeverlockKeyCalled)
}
/// `unlockVaultWithNeverlockKey()` throws because auth repository call throws.
func test_unlockVaultWithNeverlockKey_throws() async throws {
authRepository.unlockVaultWithNeverlockResult = .failure(BitwardenTestError.example)
await assertAsyncThrows(error: BitwardenTestError.example) {
try await subject.unlockVaultWithNeverlockKey()
}
}
// MARK: SyncServiceDelegate
/// `onFetchSyncSucceeded(userId:)` clear the unlock user pins when it has performed sync after login
/// for the first time and `.removeUnlockWithPin` policy is enabled.
func test_onFetchSyncSucceeded_clearPins() async throws {
await stateService.addAccount(.fixture())
stateService.pinProtectedUserKeyValue["1"] = "pin"
stateService.encryptedPinByUserId["1"] = "encPin"
stateService.accountVolatileData["1"] = AccountVolatileData(pinProtectedUserKey: "pin")
stateService.hasPerformedSyncAfterLogin["1"] = false
policyService.policyAppliesToUserResult[.removeUnlockWithPin] = true
await subject.onFetchSyncSucceeded(userId: "1")
XCTAssertNil(stateService.pinProtectedUserKeyValue["1"])
XCTAssertNil(stateService.encryptedPinByUserId["1"])
XCTAssertNil(stateService.accountVolatileData["1"])
}
/// `onFetchSyncSucceeded(userId:)` doesn't clear the unlock user pins when it has performed sync after login
/// for the first time and `.removeUnlockWithPin` policy is disabled.
func test_onFetchSyncSucceeded_doesNotClearPinsWhenRemoveUnlockWithPinPolicyDisabled() async throws {
await stateService.addAccount(.fixture())
stateService.pinProtectedUserKeyValue["1"] = "pin"
stateService.encryptedPinByUserId["1"] = "encPin"
stateService.accountVolatileData["1"] = AccountVolatileData(pinProtectedUserKey: "pin")
stateService.hasPerformedSyncAfterLogin["1"] = false
policyService.policyAppliesToUserResult[.removeUnlockWithPin] = false
await subject.onFetchSyncSucceeded(userId: "1")
XCTAssertNotNil(stateService.pinProtectedUserKeyValue["1"])
XCTAssertNotNil(stateService.encryptedPinByUserId["1"])
XCTAssertNotNil(stateService.accountVolatileData["1"])
XCTAssertTrue(stateService.hasPerformedSyncAfterLogin["1"] == true)
}
/// `onFetchSyncSucceeded(userId:)` doesn't clear the unlock user pins when it's not the first time it has
/// performed sync after login.
func test_onFetchSyncSucceeded_doesNotClearPinsWhenNotFirstTimeSyncAfterLogin() async throws {
await stateService.addAccount(.fixture())
stateService.pinProtectedUserKeyValue["1"] = "pin"
stateService.encryptedPinByUserId["1"] = "encPin"
stateService.accountVolatileData["1"] = AccountVolatileData(pinProtectedUserKey: "pin")
stateService.hasPerformedSyncAfterLogin["1"] = true
await subject.onFetchSyncSucceeded(userId: "1")
XCTAssertTrue(policyService.policyAppliesToUserPolicies.isEmpty)
XCTAssertNotNil(stateService.pinProtectedUserKeyValue["1"])
XCTAssertNotNil(stateService.encryptedPinByUserId["1"])
XCTAssertNotNil(stateService.accountVolatileData["1"])
}
/// `onFetchSyncSucceeded(userId:)` doesn't do anything when `getHasPerformedSyncAfterLogin(userId:)` throws.
func test_onFetchSyncSucceeded_getHasPerformedSyncAfterLoginThrows() async throws {
stateService.pinProtectedUserKeyValue["1"] = "pin"
stateService.encryptedPinByUserId["1"] = "encPin"
stateService.accountVolatileData["1"] = AccountVolatileData(pinProtectedUserKey: "pin")
stateService.getHasPerformedSyncAfterLoginError = BitwardenTestError.example
await subject.onFetchSyncSucceeded(userId: "1")
XCTAssertTrue(policyService.policyAppliesToUserPolicies.isEmpty)
XCTAssertNotNil(stateService.pinProtectedUserKeyValue["1"])
XCTAssertNotNil(stateService.encryptedPinByUserId["1"])
XCTAssertNotNil(stateService.accountVolatileData["1"])
XCTAssertEqual(errorReporter.errors as? [BitwardenTestError], [.example])
}
/// `onFetchSyncSucceeded(userId:)` doesn't do anything when
/// `setHasPerformedSyncAfterLogin(hasBeenPerformed:, userId:)` throws.
func test_onFetchSyncSucceeded_setHasPerformedSyncAfterLoginThrows() async throws {
stateService.pinProtectedUserKeyValue["1"] = "pin"
stateService.encryptedPinByUserId["1"] = "encPin"
stateService.accountVolatileData["1"] = AccountVolatileData(pinProtectedUserKey: "pin")
stateService.setHasPerformedSyncAfterLoginError = BitwardenTestError.example
await subject.onFetchSyncSucceeded(userId: "1")
XCTAssertTrue(policyService.policyAppliesToUserPolicies.isEmpty)
XCTAssertNotNil(stateService.pinProtectedUserKeyValue["1"])
XCTAssertNotNil(stateService.encryptedPinByUserId["1"])
XCTAssertNotNil(stateService.accountVolatileData["1"])
XCTAssertEqual(errorReporter.errors as? [BitwardenTestError], [.example])
}
/// `onFetchSyncSucceeded(userId:)` doesn't clear pins when
/// `clearPins(userId:)` throws.
func test_onFetchSyncSucceeded_clearPinsThrows() async throws {
stateService.pinProtectedUserKeyValue["1"] = "pin"
stateService.encryptedPinByUserId["1"] = "encPin"
stateService.accountVolatileData["1"] = AccountVolatileData(pinProtectedUserKey: "pin")
stateService.hasPerformedSyncAfterLogin["1"] = false
policyService.policyAppliesToUserResult[.removeUnlockWithPin] = true
stateService.activeAccount = nil
await subject.onFetchSyncSucceeded(userId: "1")
XCTAssertNotNil(stateService.pinProtectedUserKeyValue["1"])
XCTAssertNotNil(stateService.encryptedPinByUserId["1"])
XCTAssertNotNil(stateService.accountVolatileData["1"])
XCTAssertEqual(errorReporter.errors as? [StateServiceError], [.noActiveAccount])
}
/// `removeMasterPassword(organizationName:)` notifies the coordinator to show the remove
/// master password screen.
@MainActor
func test_removeMasterPassword() {
coordinator.isLoadingOverlayShowing = true
subject.removeMasterPassword(
organizationName: "Example Org",
organizationId: "ORG_ID",
keyConnectorUrl: "https://example.com",
)
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
XCTAssertEqual(
coordinator.routes,
[
.auth(.removeMasterPassword(
organizationName: "Example Org",
organizationId: "ORG_ID",
keyConnectorUrl: "https://example.com",
)),
],
)
}
/// `removeMasterPassword(organizationName:)` doesn't show the remove master password screen in
/// the extension.
@MainActor
func test_removeMasterPassword_extension() {
let delegate = MockAppExtensionDelegate()
let subject = AppProcessor(
appExtensionDelegate: delegate,
appModule: appModule,
services: ServiceContainer.withMocks(),
)
subject.removeMasterPassword(
organizationName: "Example Org",
organizationId: "ORG_ID",
keyConnectorUrl: "https://example.com",
)
XCTAssertTrue(coordinator.routes.isEmpty)
}
/// `securityStampChanged(userId:)` logs the user out and notifies the coordinator.
@MainActor
func test_securityStampChanged() async {
coordinator.isLoadingOverlayShowing = true
await subject.securityStampChanged(userId: "1")
XCTAssertTrue(authRepository.logoutCalled)
XCTAssertEqual(authRepository.logoutUserId, "1")
XCTAssertFalse(authRepository.logoutUserInitiated)
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
XCTAssertEqual(coordinator.events, [.didLogout(userId: "1", userInitiated: false)])
}
/// `securityStampChanged(userId:)` throws logging the user out which is logged and notifies the coordinator.
@MainActor
func test_securityStampChanged_throwsLogging() async {
coordinator.isLoadingOverlayShowing = true
authRepository.logoutResult = .failure(BitwardenTestError.example)
await subject.securityStampChanged(userId: "1")
XCTAssertTrue(authRepository.logoutCalled)
XCTAssertEqual(errorReporter.errors as? [BitwardenTestError], [.example])
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
XCTAssertEqual(coordinator.events, [.didLogout(userId: "1", userInitiated: false)])
}
/// `onRefreshTokenError(error:)` logs the user out and notifies the coordinator when a 401 is
/// received while refreshing the token.
@MainActor
func test_onRefreshTokenError_logOut401() async throws {
coordinator.isLoadingOverlayShowing = true
try await subject.onRefreshTokenError(error: ResponseValidationError(response: .failure(statusCode: 401)))
XCTAssertTrue(authRepository.logoutCalled)
XCTAssertEqual(authRepository.logoutUserId, nil)
XCTAssertFalse(authRepository.logoutUserInitiated)
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
XCTAssertEqual(coordinator.events, [.didLogout(userId: nil, userInitiated: false)])
}
/// `onRefreshTokenError(error:)` logs the user out and notifies the coordinator a 403 is
/// received while refreshing the token.
@MainActor
func test_onRefreshTokenError_logOut403() async throws {
coordinator.isLoadingOverlayShowing = true
try await subject.onRefreshTokenError(error: ResponseValidationError(response: .failure(statusCode: 403)))
XCTAssertTrue(authRepository.logoutCalled)
XCTAssertEqual(authRepository.logoutUserId, nil)
XCTAssertFalse(authRepository.logoutUserInitiated)
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
XCTAssertEqual(coordinator.events, [.didLogout(userId: nil, userInitiated: false)])
}
/// `onRefreshTokenError(error:)` logs the user out and notifies the coordinator when error is `.invalidGrant`.
@MainActor
func test_onRefreshTokenError_logOutInvalidGrant() async throws {
coordinator.isLoadingOverlayShowing = true
try await subject.onRefreshTokenError(error: IdentityTokenRefreshRequestError.invalidGrant)
XCTAssertTrue(authRepository.logoutCalled)
XCTAssertEqual(authRepository.logoutUserId, nil)
XCTAssertFalse(authRepository.logoutUserInitiated)
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
XCTAssertEqual(coordinator.events, [.didLogout(userId: nil, userInitiated: false)])
}
/// `onRefreshTokenError(error:)` throws logging the user out which is logged and notifies the coordinator
/// when error is `.invalidGrant`.
@MainActor
func test_onRefreshTokenError_logOutInvalidGrantThrowsLogging() async throws {
coordinator.isLoadingOverlayShowing = true
authRepository.logoutResult = .failure(BitwardenTestError.example)
try await subject.onRefreshTokenError(error: IdentityTokenRefreshRequestError.invalidGrant)
XCTAssertTrue(authRepository.logoutCalled)
XCTAssertEqual(errorReporter.errors as? [BitwardenTestError], [.example])
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
XCTAssertEqual(coordinator.events, [.didLogout(userId: nil, userInitiated: false)])
}
/// `onRefreshTokenError(error:)` doesn't perform log out when error is not `.invalidGrant`.
@MainActor
func test_onRefreshTokenError_notInvalidGrant() async throws {
coordinator.isLoadingOverlayShowing = true
try await subject.onRefreshTokenError(error: BitwardenTestError.example)
XCTAssertFalse(authRepository.logoutCalled)
XCTAssertTrue(coordinator.isLoadingOverlayShowing)
XCTAssertTrue(coordinator.events.isEmpty)
}
/// `prepareEnvironmentConfig()` loads the URLs for the active account and the configuration.
@MainActor
func test_prepareEnvironmentConfig() async throws {
await subject.prepareEnvironmentConfig()
XCTAssertTrue(environmentService.didLoadURLsForActiveAccount)
XCTAssertTrue(configService.configMocker.called)
}
}