BIT-1029: Never Lock, Events, & Routers (#401)

Co-authored-by: Jubie Alade <125899965+jubie-livefront@users.noreply.github.com>
Co-authored-by: Matt Czech <matt@livefront.com>
This commit is contained in:
Eliot Williams 2024-01-31 15:43:53 -08:00 committed by GitHub
parent a3febaeeb4
commit 41d8e61a3b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
185 changed files with 5230 additions and 1877 deletions

View File

@ -24,14 +24,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// If the app is running tests, show a testing view.
window = UIWindow(windowScene: windowScene)
window?.makeKeyAndVisible()
window?.rootViewController = UIHostingController(rootView: ZStack {
Color("backgroundSplash").ignoresSafeArea()
Image("logoBitwarden")
.resizable()
.scaledToFit()
.frame(width: 238)
})
window?.rootViewController = UIHostingController(rootView: Splash())
}
return
}

View File

@ -2,8 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BitwardenAppGroupIdentifier</key>
<string>group.$(ORGANIZATION_IDENTIFIER).bitwarden</string>
<key>BitwardenAppIdentifier</key>
<string>$(ORGANIZATION_IDENTIFIER).bitwarden</string>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>

View File

@ -1,86 +1,86 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BitwardenAppGroupIdentifier</key>
<string>group.$(ORGANIZATION_IDENTIFIER).bitwarden</string>
<key>CFBundleDisplayName</key>
<string>Autofill with Bitwarden</string>
<key>CFBundleName</key>
<string>Bitwarden Extension</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>es</string>
<string>zh-Hans</string>
<string>zh-Hant</string>
<string>pt-PT</string>
<string>pt-BR</string>
<string>sv</string>
<string>sk</string>
<string>it</string>
<string>fi</string>
<string>fr</string>
<string>ro</string>
<string>id</string>
<string>hr</string>
<string>hu</string>
<string>nl</string>
<string>tr</string>
<string>uk</string>
<string>de</string>
<string>dk</string>
<string>cz</string>
<string>nb</string>
<string>ja</string>
<string>et</string>
<string>vi</string>
<string>pl</string>
<string>ko</string>
<string>fa</string>
<string>ru</string>
<string>be</string>
<string>bg</string>
<string>ca</string>
<string>cs</string>
<string>el</string>
<string>th</string>
</array>
<key>CFBundleDevelopmentRegion</key>
<dict>
<key>BitwardenAppIdentifier</key>
<string>$(ORGANIZATION_IDENTIFIER).bitwarden</string>
<key>CFBundleDisplayName</key>
<string>Autofill with Bitwarden</string>
<key>CFBundleName</key>
<string>Bitwarden Extension</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CADisableMinimumFrameDurationOnPhone</key>
<string>es</string>
<string>zh-Hans</string>
<string>zh-Hant</string>
<string>pt-PT</string>
<string>pt-BR</string>
<string>sv</string>
<string>sk</string>
<string>it</string>
<string>fi</string>
<string>fr</string>
<string>ro</string>
<string>id</string>
<string>hr</string>
<string>hu</string>
<string>nl</string>
<string>tr</string>
<string>uk</string>
<string>de</string>
<string>dk</string>
<string>cz</string>
<string>nb</string>
<string>ja</string>
<string>et</string>
<string>vi</string>
<string>pl</string>
<string>ko</string>
<string>fa</string>
<string>ru</string>
<string>be</string>
<string>bg</string>
<string>ca</string>
<string>cs</string>
<string>el</string>
<string>th</string>
</array>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIRequiredDeviceCapabilities</key>
<dict>
<key>arm64</key>
<true/>
<key>UIRequiredDeviceCapabilities</key>
</dict>
<key>ITSAppUsesNonExemptEncryption</key>
<true/>
<key>ITSEncryptionExportComplianceCode</key>
<string>ecf076d3-4824-4d7b-b716-2a9a47d7d296</string>
<key>NSFaceIDUsageDescription</key>
<string>Use Face ID to unlock your vault.</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>arm64</key>
<true/>
</dict>
<key>ITSAppUsesNonExemptEncryption</key>
<true/>
<key>ITSEncryptionExportComplianceCode</key>
<string>ecf076d3-4824-4d7b-b716-2a9a47d7d296</string>
<key>NSFaceIDUsageDescription</key>
<string>Use Face ID to unlock your vault.</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionJavaScriptPreprocessingFile</key>
<string>extension</string>
<key>NSExtensionActivationRule</key>
<string>SUBQUERY (
<key>NSExtensionJavaScriptPreprocessingFile</key>
<string>extension</string>
<key>NSExtensionActivationRule</key>
<string>SUBQUERY (
extensionItems,
$extensionItem,
SUBQUERY (
@ -96,13 +96,13 @@
|| ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO &quot;com.8bit.bitwarden.extension-setup&quot;
).@count == $extensionItem.attachments.@count
).@count == 1</string>
</dict>
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.ui-services</string>
</dict>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.ui-services</string>
</dict>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
</dict>
</plist>

View File

@ -2,8 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BitwardenAppGroupIdentifier</key>
<string>group.$(ORGANIZATION_IDENTIFIER).bitwarden</string>
<key>BitwardenAppIdentifier</key>
<string>$(ORGANIZATION_IDENTIFIER).bitwarden</string>
<key>CFBundleDisplayName</key>
<string>Bitwarden</string>
<key>CFBundleName</key>

View File

@ -2,8 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BitwardenAppGroupIdentifier</key>
<string>group.$(ORGANIZATION_IDENTIFIER).bitwarden</string>
<key>BitwardenAppIdentifier</key>
<string>$(ORGANIZATION_IDENTIFIER).bitwarden</string>
<key>CFBundleDisplayName</key>
<string>Bitwarden Share</string>
<key>CFBundleName</key>

View File

@ -1,20 +1,22 @@
import BitwardenSdk
import Foundation
import LocalAuthentication
// swiftlint:disable file_length
/// A protocol for an `AuthRepository` which manages access to the data needed by the UI layer.
///
protocol AuthRepository: AnyObject {
// MARK: Methods
/// Enables or disables biometric unlock for a user.
/// Enables or disables biometric unlock for the active user.
///
/// - Parameters:
/// - enabled: Whether or not the the user wants biometric auth enabled.
/// If `true`, the userAuthKey is stored to the keychain and the user preference is set to false.
/// If `false`, any userAuthKey is deleted from the keychain and the user preference is set to false.
/// - userId: The user Id to be configured.
///
func allowBioMetricUnlock(_ enabled: Bool, userId: String?) async throws
func allowBioMetricUnlock(_ enabled: Bool) async throws
/// Clears the pins stored on device and in memory.
///
@ -39,12 +41,12 @@ protocol AuthRepository: AnyObject {
///
func getActiveAccount() async throws -> ProfileSwitcherItem
/// Gets the account for a `ProfileSwitcherItem`.
/// Gets the account for a user id.
///
/// - Parameter userId: The user Id to be mapped to an account.
/// - Returns: The user account.
///
func getAccount(for userId: String) async throws -> Account
func getAccount(for userId: String?) async throws -> Account
/// Gets the current account's unique fingerprint phrase.
///
@ -58,6 +60,12 @@ protocol AuthRepository: AnyObject {
///
func isPinUnlockAvailable() async throws -> Bool
/// Checks the locked status of a user vault by user id
/// - Parameter userId: The userId of the account
/// - Returns: A bool, true if locked, false if unlocked.
///
func isLocked(userId: String?) async throws -> Bool
/// Locks the user's vault and clears decrypted data from memory.
///
/// - Parameter userId: The userId of the account to lock.
@ -95,10 +103,22 @@ protocol AuthRepository: AnyObject {
///
func setActiveAccount(userId: String) async throws -> Account
/// Sets the SessionTimeoutValue.
///
/// - Parameters:
/// - newValue: The timeout value.
/// - userId: The user's ID.
///
func setVaultTimeout(value newValue: SessionTimeoutValue, userId: String?) async throws
/// Attempts to unlock the user's vault with biometrics.
///
func unlockVaultWithBiometrics() async throws
/// Attempts to unlock the user's vault with the stored neverlock key.
///
func unlockVaultWithNeverlockKey() async throws
/// Attempts to unlock the user's vault with their master password.
///
/// - Parameter password: The user's master password to unlock the vault.
@ -113,11 +133,35 @@ protocol AuthRepository: AnyObject {
}
extension AuthRepository {
/// Gets the account for the active user id.
///
/// - Returns: The active user account.
///
func getAccount() async throws -> Account {
try await getAccount(for: nil)
}
/// Checks the locked status of a user vault by user id
///
/// - Returns: A bool, true if locked, false if unlocked.
///
func isLocked() async throws -> Bool {
try await isLocked(userId: nil)
}
/// Logs the user out of the active account.
///
func logout() async throws {
try await logout(userId: nil)
}
/// Sets the SessionTimeoutValue upon the app being backgrounded.
///
/// - Parameter newValue: The timeout value.
///
func setVaultTimeout(value newValue: SessionTimeoutValue) async throws {
try await setVaultTimeout(value: newValue, userId: nil)
}
}
// MARK: - DefaultAuthRepository
@ -134,7 +178,7 @@ class DefaultAuthRepository {
private let authService: AuthService
/// The service to use system Biometrics for vault unlock.
let biometricsService: BiometricsService
let biometricsRepository: BiometricsRepository
/// The client used by the application to handle auth related encryption and decryption tasks.
private let clientAuth: ClientAuthProtocol
@ -148,6 +192,9 @@ class DefaultAuthRepository {
/// The service used by the application to manage the environment settings.
private let environmentService: EnvironmentService
/// The keychain service used by this repository.
private let keychainService: KeychainRepository
/// The service used to manage syncing and updates to the user's organizations.
private let organizationService: OrganizationService
@ -164,11 +211,12 @@ class DefaultAuthRepository {
/// - Parameters:
/// - accountAPIService: The services used by the application to make account related API requests.
/// - authService: The service used that handles some of the auth logic.
/// - biometricsService: The service to use system Biometrics for vault unlock.
/// - biometricsRepository: The service to use system Biometrics for vault unlock.
/// - clientAuth: The client used by the application to handle auth related encryption and decryption tasks.
/// - clientCrypto: The client used by the application to handle encryption and decryption setup tasks.
/// - clientPlatform: The client used by the application to handle generating account fingerprints.
/// - environmentService: The service used by the application to manage the environment settings.
/// - keychainService: The keychain service used by the application.
/// - organizationService: The service used to manage syncing and updates to the user's organizations.
/// - stateService: The service used by the application to manage account state.
/// - vaultTimeoutService: The service used by the application to manage vault access.
@ -176,22 +224,24 @@ class DefaultAuthRepository {
init(
accountAPIService: AccountAPIService,
authService: AuthService,
biometricsService: BiometricsService,
biometricsRepository: BiometricsRepository,
clientAuth: ClientAuthProtocol,
clientCrypto: ClientCryptoProtocol,
clientPlatform: ClientPlatformProtocol,
environmentService: EnvironmentService,
keychainService: KeychainRepository,
organizationService: OrganizationService,
stateService: StateService,
vaultTimeoutService: VaultTimeoutService
) {
self.accountAPIService = accountAPIService
self.authService = authService
self.biometricsService = biometricsService
self.biometricsRepository = biometricsRepository
self.clientAuth = clientAuth
self.clientCrypto = clientCrypto
self.clientPlatform = clientPlatform
self.environmentService = environmentService
self.keychainService = keychainService
self.organizationService = organizationService
self.stateService = stateService
self.vaultTimeoutService = vaultTimeoutService
@ -201,15 +251,14 @@ class DefaultAuthRepository {
// MARK: - AuthRepository
extension DefaultAuthRepository: AuthRepository {
func clearPins() async throws {
try await stateService.clearPins()
func allowBioMetricUnlock(_ enabled: Bool) async throws {
try await biometricsRepository.setBiometricUnlockKey(
authKey: enabled ? clientCrypto.getUserEncryptionKey() : nil
)
}
func allowBioMetricUnlock(_ enabled: Bool, userId: String?) async throws {
try await biometricsService.setBiometricUnlockKey(
authKey: enabled ? clientCrypto.getUserEncryptionKey() : nil,
for: userId
)
func clearPins() async throws {
try await stateService.clearPins()
}
func deleteAccount(passwordText: String) async throws {
@ -235,14 +284,8 @@ extension DefaultAuthRepository: AuthRepository {
return await profileItem(from: active)
}
func getAccount(for userId: String) async throws -> Account {
let accounts = try await stateService.getAccounts()
guard let match = accounts.first(where: { account in
account.profile.userId == userId
}) else {
throw StateServiceError.noAccounts
}
return match
func getAccount(for userId: String?) async throws -> Account {
try await stateService.getAccount(userId: userId)
}
func getFingerprintPhrase() async throws -> String {
@ -250,6 +293,12 @@ extension DefaultAuthRepository: AuthRepository {
return try await clientPlatform.userFingerprint(fingerprintMaterial: userId)
}
func isLocked(userId: String?) async throws -> Bool {
try await vaultTimeoutService.isLocked(
userId: userIdOrActive(userId)
)
}
func isPinUnlockAvailable() async throws -> Bool {
try await stateService.pinProtectedUserKey() != nil
}
@ -259,8 +308,8 @@ extension DefaultAuthRepository: AuthRepository {
}
func logout(userId: String?) async throws {
try? await biometricsRepository.setBiometricUnlockKey(authKey: nil)
await vaultTimeoutService.remove(userId: userId)
try? await biometricsService.setBiometricUnlockKey(authKey: nil, for: userId)
try await stateService.logoutAccount(userId: userId)
}
@ -283,12 +332,41 @@ extension DefaultAuthRepository: AuthRepository {
)
}
func setVaultTimeout(value newValue: SessionTimeoutValue, userId: String?) async throws {
// Ensure we have a user id.
let id = try await userIdOrActive(userId)
let currentValue = try? await vaultTimeoutService.sessionTimeoutValue(userId: id)
// Set or delete the never lock key according to the current and new values.
if case .never = newValue {
try await keychainService.setUserAuthKey(
for: .neverLock(userId: id),
value: clientCrypto.getUserEncryptionKey()
)
} else if currentValue == .never {
try await keychainService.deleteUserAuthKey(
for: .neverLock(userId: id)
)
}
// Then configure the vault timeout service with the correct value.
try await vaultTimeoutService.setVaultTimeout(
value: newValue,
userId: id
)
}
func unlockVaultWithBiometrics() async throws {
let account = try await stateService.getActiveAccount()
let decryptedUserKey = try await biometricsService.getUserAuthKey(for: account.profile.userId)
let decryptedUserKey = try await biometricsRepository.getUserAuthKey()
try await unlockVault(method: .decryptedKey(decryptedUserKey: decryptedUserKey))
}
func unlockVaultWithNeverlockKey() async throws {
let id = try await stateService.getActiveAccountId()
let key = KeychainItem.neverLock(userId: id)
let neverlockKey = try await keychainService.getUserAuthKeyValue(for: key)
try await unlockVault(method: .decryptedKey(decryptedUserKey: neverlockKey))
}
func unlockVaultWithPassword(password: String) async throws {
let account = try await stateService.getActiveAccount()
let encryptionKeys = try await stateService.getAccountEncryptionKeys(userId: account.profile.userId)
@ -310,29 +388,22 @@ extension DefaultAuthRepository: AuthRepository {
/// - Returns: The `ProfileSwitcherItem` representing the account.
///
private func profileItem(from account: Account) async -> ProfileSwitcherItem {
var profile = ProfileSwitcherItem(
let isLocked = await (try? isLocked(userId: account.profile.userId)) ?? true
let hasNeverLock = await (try? stateService
.getVaultTimeout(userId: account.profile.userId)) == .never
let displayAsUnlocked = !isLocked || hasNeverLock
return ProfileSwitcherItem(
email: account.profile.email,
isUnlocked: displayAsUnlocked,
userId: account.profile.userId,
userInitials: account.initials()
?? ".."
)
do {
let isUnlocked = try !vaultTimeoutService.isLocked(userId: account.profile.userId)
profile.isUnlocked = isUnlocked
return profile
} catch {
profile.isUnlocked = false
let userId = profile.userId
await vaultTimeoutService.lockVault(userId: userId)
return profile
}
}
/// Unlocks the vault with the pin or password.
/// Attempts to unlock the vault with a given method.
///
/// - Parameters:
/// - passwordOrPin: The user's password or pin.
/// - method: The unlocking method, which is either password or pin.
/// - Parameter method: The unlocking `InitUserCryptoMethod` method.
///
private func unlockVault(method: InitUserCryptoMethod) async throws {
let account = try await stateService.getActiveAccount()
@ -348,6 +419,11 @@ extension DefaultAuthRepository: AuthRepository {
)
switch method {
case .authRequest:
break
case .decryptedKey:
// No-op: nothing extra to do for decryptedKey.
break
case let .password(password, _):
let hashedPassword = try await authService.hashPassword(
password: password,
@ -363,13 +439,12 @@ extension DefaultAuthRepository: AuthRepository {
}
// Re-enable biometrics, if required.
let biometricUnlockStatus = try? await biometricsService.getBiometricUnlockStatus()
let biometricUnlockStatus = try? await biometricsRepository.getBiometricUnlockStatus()
switch biometricUnlockStatus {
case .available(_, true, false):
try await biometricsService.configureBiometricIntegrity()
try await biometricsService.setBiometricUnlockKey(
authKey: clientCrypto.getUserEncryptionKey(),
for: account.profile.userId
try await biometricsRepository.configureBiometricIntegrity()
try await biometricsRepository.setBiometricUnlockKey(
authKey: clientCrypto.getUserEncryptionKey()
)
default:
break
@ -377,12 +452,14 @@ extension DefaultAuthRepository: AuthRepository {
case .pin:
// No-op: nothing extra to do for pin unlock.
break
case .decryptedKey:
break
case .authRequest:
break
}
await vaultTimeoutService.unlockVault(userId: account.profile.userId)
try await organizationService.initializeOrganizationCrypto()
}
private func userIdOrActive(_ maybeId: String?) async throws -> String {
if let maybeId { return maybeId }
return try await stateService.getActiveAccountId()
}
}

View File

@ -8,12 +8,13 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
var accountAPIService: APIService!
var authService: MockAuthService!
var biometricsService: MockBiometricsService!
var biometricsRepository: MockBiometricsRepository!
var client: MockHTTPClient!
var clientAuth: MockClientAuth!
var clientCrypto: MockClientCrypto!
var clientPlatform: MockClientPlatform!
var environmentService: MockEnvironmentService!
var keychainService: MockKeychainRepository!
var organizationService: MockOrganizationService!
var subject: DefaultAuthRepository!
var stateService: MockStateService!
@ -78,10 +79,11 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
clientAuth = MockClientAuth()
accountAPIService = APIService(client: client)
authService = MockAuthService()
biometricsService = MockBiometricsService()
biometricsRepository = MockBiometricsRepository()
clientCrypto = MockClientCrypto()
clientPlatform = MockClientPlatform()
environmentService = MockEnvironmentService()
keychainService = MockKeychainRepository()
organizationService = MockOrganizationService()
stateService = MockStateService()
vaultTimeoutService = MockVaultTimeoutService()
@ -89,11 +91,12 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
subject = DefaultAuthRepository(
accountAPIService: accountAPIService,
authService: authService,
biometricsService: biometricsService,
biometricsRepository: biometricsRepository,
clientAuth: clientAuth,
clientCrypto: clientCrypto,
clientPlatform: clientPlatform,
environmentService: environmentService,
keychainService: keychainService,
organizationService: organizationService,
stateService: stateService,
vaultTimeoutService: vaultTimeoutService
@ -105,12 +108,13 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
accountAPIService = nil
authService = nil
biometricsService = nil
biometricsRepository = nil
client = nil
clientAuth = nil
clientCrypto = nil
clientPlatform = nil
environmentService = nil
keychainService = nil
organizationService = nil
subject = nil
stateService = nil
@ -150,50 +154,49 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
}
/// `allowBioMetricUnlock(:)` throws an error if required.
func test_allowBioMetricUnlock_biometricsServiceError() async throws {
biometricsService.setBiometricUnlockKeyError = BiometricsServiceError.setAuthKeyFailed
func test_allowBioMetricUnlock_biometricsRepositoryError() async throws {
biometricsRepository.setBiometricUnlockKeyError = BiometricsServiceError.setAuthKeyFailed
await assertAsyncThrows(error: BiometricsServiceError.setAuthKeyFailed) {
try await subject.allowBioMetricUnlock(true, userId: nil)
try await subject.allowBioMetricUnlock(true)
}
}
/// `allowBioMetricUnlock(:)` throws an error if required.
func test_allowBioMetricUnlock_cryptoError() async throws {
biometricsService.setBiometricUnlockKeyError = nil
biometricsRepository.setBiometricUnlockKeyError = nil
struct ClientError: Error, Equatable {}
clientCrypto.getUserEncryptionKeyResult = .failure(ClientError())
await assertAsyncThrows(error: ClientError()) {
try await subject.allowBioMetricUnlock(true, userId: "123")
try await subject.allowBioMetricUnlock(true)
}
}
/// `allowBioMetricUnlock(:)` throws an error if required.
func test_allowBioMetricUnlock_true_success() async throws {
biometricsService.setBiometricUnlockKeyError = nil
stateService.activeAccount = .fixture()
biometricsRepository.setBiometricUnlockKeyError = nil
let key = "userKey"
clientCrypto.getUserEncryptionKeyResult = .success(key)
try await subject.allowBioMetricUnlock(true, userId: "123")
XCTAssertEqual(biometricsService.capturedUserAuthKey, key)
XCTAssertEqual("123", biometricsService.capturedUserID)
try await subject.allowBioMetricUnlock(true)
XCTAssertEqual(biometricsRepository.capturedUserAuthKey, key)
}
/// `allowBioMetricUnlock(:)` throws an error if required.
func test_allowBioMetricUnlock_false_success() async throws {
biometricsService.setBiometricUnlockKeyError = nil
stateService.activeAccount = .fixture()
biometricsRepository.setBiometricUnlockKeyError = nil
let key = "userKey"
clientCrypto.getUserEncryptionKeyResult = .success(key)
try await subject.allowBioMetricUnlock(false, userId: "456")
XCTAssertNil(biometricsService.capturedUserAuthKey)
XCTAssertEqual("456", biometricsService.capturedUserID)
try await subject.allowBioMetricUnlock(false)
XCTAssertNil(biometricsRepository.capturedUserAuthKey)
}
/// `allowBioMetricUnlock(:)` throws an error if required.
func test_allowBioMetricUnlock_false_success_biometricsServiceError() async throws {
biometricsService.setBiometricUnlockKeyError = nil
func test_allowBioMetricUnlock_false_success_biometricsRepositoryError() async throws {
biometricsRepository.setBiometricUnlockKeyError = nil
clientCrypto.getUserEncryptionKeyResult = .failure(BiometricsServiceError.getAuthKeyFailed)
try await subject.allowBioMetricUnlock(false, userId: nil)
XCTAssertNil(biometricsService.capturedUserAuthKey)
XCTAssertNil(biometricsService.capturedUserID)
try await subject.allowBioMetricUnlock(false)
XCTAssertNil(biometricsRepository.capturedUserAuthKey)
}
/// `getAccounts()` throws an error when the accounts are nil.
@ -217,7 +220,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
let accounts = try await subject.getAccounts()
XCTAssertEqual(
accounts.first,
ProfileSwitcherItem(
ProfileSwitcherItem.fixture(
email: anneAccount.profile.email,
userId: anneAccount.profile.userId,
userInitials: "AA"
@ -225,7 +228,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
)
XCTAssertEqual(
accounts[1],
ProfileSwitcherItem(
ProfileSwitcherItem.fixture(
email: beeAccount.profile.email,
userId: beeAccount.profile.userId,
userInitials: "BA"
@ -233,7 +236,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
)
XCTAssertEqual(
accounts[2],
ProfileSwitcherItem(
ProfileSwitcherItem.fixture(
email: claimedAccount.profile.email,
userId: claimedAccount.profile.userId,
userInitials: "CL"
@ -241,7 +244,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
)
XCTAssertEqual(
accounts[3],
ProfileSwitcherItem(
ProfileSwitcherItem.fixture(
email: "",
userId: "4",
userInitials: ".."
@ -249,7 +252,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
)
XCTAssertEqual(
accounts[4],
ProfileSwitcherItem(
ProfileSwitcherItem.fixture(
email: shortEmail.profile.email,
userId: shortEmail.profile.userId,
userInitials: "A"
@ -257,7 +260,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
)
XCTAssertEqual(
accounts[5],
ProfileSwitcherItem(
ProfileSwitcherItem.fixture(
email: shortName.profile.email,
userId: shortName.profile.userId,
userInitials: "AJ"
@ -317,7 +320,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
let active = try await subject.getActiveAccount()
XCTAssertEqual(
active,
ProfileSwitcherItem(
ProfileSwitcherItem.fixture(
email: anneAccount.profile.email,
userId: anneAccount.profile.userId,
userInitials: "AA"
@ -331,7 +334,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
anneAccount,
]
stateService.activeAccount = anneAccount
let profile = ProfileSwitcherItem(
let profile = ProfileSwitcherItem.fixture(
email: anneAccount.profile.email,
userId: anneAccount.profile.userId,
userInitials: "AA"
@ -350,7 +353,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
anneAccount,
]
stateService.activeAccount = anneAccount
let profile = ProfileSwitcherItem(
let profile = ProfileSwitcherItem.fixture(
email: beeAccount.profile.email,
userId: beeAccount.profile.userId,
userInitials: "BA"
@ -382,16 +385,181 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
}
}
/// `isPinUnlockAvailable` returns the value from the state service.
func test_isPinUnlockAvailable() async throws {
/// `isLocked` returns the lock state of an active user.
func test_isLocked_noUser() async {
await assertAsyncThrows(error: StateServiceError.noActiveAccount) {
_ = try await subject.isLocked()
}
}
/// `isLocked` returns the lock state of an active user.
func test_isLocked_noHistory() async throws {
stateService.activeAccount = .fixture()
stateService.pinProtectedUserKeyValue = ["1": "something"]
let isLocked = try await subject.isLocked()
XCTAssertTrue(isLocked)
}
/// `isLocked` returns the lock state of an active user.
func test_isLocked_value() async throws {
stateService.activeAccount = .fixture(profile: .fixture(userId: "1"))
vaultTimeoutService.timeoutStore = [
"1": false,
]
let isLocked = try await subject.isLocked()
XCTAssertFalse(isLocked)
}
/// `isPinUnlockAvailable` returns the pin unlock availability for the active user.
func test_isPinUnlockAvailable_noUser() async {
await assertAsyncThrows(error: StateServiceError.noActiveAccount) {
_ = try await subject.isPinUnlockAvailable()
}
}
/// `isPinUnlockAvailable` returns the pin unlock availability for the active user.
func test_isPinUnlockAvailable_noValue() async throws {
stateService.activeAccount = .fixture()
let value = try await subject.isPinUnlockAvailable()
XCTAssertFalse(value)
}
/// `isPinUnlockAvailable` returns the pin unlock availability for the active user.
func test_isPinUnlockAvailable_value() async throws {
let active = Account.fixture()
stateService.activeAccount = active
stateService.pinProtectedUserKeyValue = [
active.profile.userId: "123",
]
let value = try await subject.isPinUnlockAvailable()
XCTAssertTrue(value)
}
/// `setVaultTimeout` correctly configures the user's timeout value.
func test_setVaultTimeout_noUser() async {
await assertAsyncThrows(error: StateServiceError.noActiveAccount) {
try await subject.setVaultTimeout(value: .fourHours)
}
}
/// `setVaultTimeout` correctly configures the user's timeout value.
func test_setVaultTimeout_success() async throws {
let active = Account.fixture()
stateService.activeAccount = active
try await subject.setVaultTimeout(value: .fourHours)
XCTAssertEqual(vaultTimeoutService.vaultTimeout[active.profile.userId], .fourHours)
}
/// `setVaultTimeout` correctly configures the user's timeout value.
func test_setVaultTimeout_never_cryptoError() async throws {
let active = Account.fixture()
stateService.activeAccount = active
clientCrypto.getUserEncryptionKeyResult = .failure(BitwardenTestError.example)
await assertAsyncThrows(error: BitwardenTestError.example) {
try await subject.setVaultTimeout(value: .never)
}
}
/// `setVaultTimeout` correctly configures the user's timeout value.
func test_setVaultTimeout_deleteNeverlock_error() async {
let active = Account.fixture()
stateService.activeAccount = active
vaultTimeoutService.vaultTimeout = [
active.profile.userId: .never,
]
keychainService.deleteResult = .failure(BitwardenTestError.example)
await assertAsyncThrows(error: BitwardenTestError.example) {
try await subject.setVaultTimeout(value: .fiveMinutes)
}
}
/// `setVaultTimeout` correctly configures the user's timeout value.
func test_setVaultTimeout_deleteNeverlock_success() async throws {
let active = Account.fixture()
stateService.activeAccount = active
vaultTimeoutService.vaultTimeout = [
active.profile.userId: .never,
]
keychainService.mockStorage = [
keychainService.formattedKey(
for: KeychainItem.neverLock(
userId: active.profile.userId
)
):
"pasta",
]
keychainService.deleteResult = .success(())
try await subject.setVaultTimeout(value: .fiveMinutes)
XCTAssertTrue(keychainService.mockStorage.isEmpty)
}
/// `setVaultTimeout` correctly configures the user's timeout value.
func test_setVaultTimeout_never_success() async throws {
let active = Account.fixture()
stateService.activeAccount = active
clientCrypto.getUserEncryptionKeyResult = .success("pasta")
try await subject.setVaultTimeout(value: .never)
XCTAssertEqual(vaultTimeoutService.vaultTimeout[active.profile.userId], .never)
XCTAssertEqual(
keychainService.mockStorage,
[
keychainService.formattedKey(
for: KeychainItem.neverLock(userId: active.profile.userId)
):
"pasta",
]
)
}
/// `unlockVaultWithNeverlockKey` attempts to unlock the vault using an auth key from the keychain.
func test_unlockVaultWithNeverlockKey_error() async throws {
let active = Account.fixture()
keychainService.mockStorage = [
keychainService.formattedKey(
for: KeychainItem.neverLock(
userId: active.profile.userId
)
):
"pasta",
]
stateService.accountEncryptionKeys = [
active.profile.userId: .init(
encryptedPrivateKey: "secret",
encryptedUserKey: "recipe"
),
]
clientCrypto.getUserEncryptionKeyResult = .success("sauce")
clientCrypto.initializeUserCryptoResult = .success(())
await assertAsyncThrows(error: StateServiceError.noActiveAccount) {
try await subject.unlockVaultWithNeverlockKey()
}
}
/// `unlockVaultWithNeverlockKey` attempts to unlock the vault using an auth key from the keychain.
func test_unlockVaultWithNeverlockKey_success() async throws {
let active = Account.fixture()
stateService.activeAccount = active
keychainService.mockStorage = [
keychainService.formattedKey(
for: KeychainItem.neverLock(
userId: active.profile.userId
)
):
"pasta",
]
stateService.accountEncryptionKeys = [
active.profile.userId: .init(
encryptedPrivateKey: "secret",
encryptedUserKey: "recipe"
),
]
clientCrypto.getUserEncryptionKeyResult = .success("sauce")
clientCrypto.initializeUserCryptoResult = .success(())
await assertAsyncDoesNotThrow {
try await subject.unlockVaultWithNeverlockKey()
}
}
/// `lockVault(userId:)` locks the vault for the specified user id.
func test_lockVault() async {
await subject.lockVault(userId: "10")
@ -509,16 +677,19 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
XCTAssertTrue(organizationService.initializeOrganizationCryptoCalled)
XCTAssertEqual(authService.hashPasswordPassword, "password")
XCTAssertEqual(stateService.masterPasswordHashes["1"], "hashed")
XCTAssertFalse(biometricsService.didConfigureBiometricIntegrity)
XCTAssertFalse(biometricsRepository.didConfigureBiometricIntegrity)
}
/// `unlockVaultWithPassword(password:)` configures biometric integrity refreshes.
func test_unlockVault_integrityRefresh() async throws {
stateService.activeAccount = .fixture()
stateService.accountEncryptionKeys = [
"1": AccountEncryptionKeys(encryptedPrivateKey: "PRIVATE_KEY", encryptedUserKey: "USER_KEY"),
"1": AccountEncryptionKeys(
encryptedPrivateKey: "PRIVATE_KEY",
encryptedUserKey: "USER_KEY"
),
]
biometricsService.biometricUnlockStatus = .success(
biometricsRepository.biometricUnlockStatus = .success(
.available(.faceID, enabled: true, hasValidIntegrity: false)
)
@ -539,7 +710,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
XCTAssertTrue(organizationService.initializeOrganizationCryptoCalled)
XCTAssertEqual(authService.hashPasswordPassword, "password")
XCTAssertEqual(stateService.masterPasswordHashes["1"], "hashed")
XCTAssertTrue(biometricsService.didConfigureBiometricIntegrity)
XCTAssertTrue(biometricsRepository.didConfigureBiometricIntegrity)
}
/// `unlockVaultWithBiometrics()` throws an error if the vault is unable to be unlocked.
@ -567,10 +738,10 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
}
/// `unlockVaultWithBiometrics()` throws an error if the vault is unable to be unlocked.
func test_unlockVaultWithBiometrics_error_biometricsService_noKeys() async {
func test_unlockVaultWithBiometrics_error_biometricsRepository_noKeys() async {
stateService.activeAccount = .fixture()
struct KeyError: Error, Equatable {}
biometricsService.getUserAuthKeyResult = .failure(KeyError())
biometricsRepository.getUserAuthKeyResult = .failure(KeyError())
await assertAsyncThrows(error: KeyError()) {
_ = try await subject.unlockVaultWithBiometrics()
}
@ -580,7 +751,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
func test_unlockVaultWithBiometrics_error_stateService_noKey() async {
stateService.activeAccount = .fixture()
stateService.accountEncryptionKeys = [:]
biometricsService.getUserAuthKeyResult = .success("UserKey")
biometricsRepository.getUserAuthKeyResult = .success("UserKey")
clientCrypto.initializeUserCryptoResult = .success(())
organizationService.initializeOrganizationCryptoError = nil
await assertAsyncThrows(error: StateServiceError.noActiveAccount) {
@ -597,7 +768,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
encryptedUserKey: "Encrypted User Key"
),
]
biometricsService.getUserAuthKeyResult = .success("UserKey")
biometricsRepository.getUserAuthKeyResult = .success("UserKey")
clientCrypto.initializeUserCryptoResult = .success(())
struct OrgError: Error, Equatable {}
organizationService.initializeOrganizationCryptoError = OrgError()
@ -615,7 +786,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
),
]
stateService.activeAccount = .fixture()
biometricsService.getUserAuthKeyResult = .success("")
biometricsRepository.getUserAuthKeyResult = .success("")
await assertAsyncDoesNotThrow {
try await subject.unlockVaultWithBiometrics()
}
@ -661,8 +832,8 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
stateService.accounts = [account]
stateService.activeAccount = account
vaultTimeoutService.timeoutStore = [account.profile.userId: false]
biometricsService.capturedUserAuthKey = "Value"
biometricsService.setBiometricUnlockKeyError = nil
biometricsRepository.capturedUserAuthKey = "Value"
biometricsRepository.setBiometricUnlockKeyError = nil
let task = Task {
try await subject.logout()
}
@ -670,7 +841,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
task.cancel()
XCTAssertEqual([account.profile.userId], stateService.accountsLoggedOut)
XCTAssertNil(biometricsService.capturedUserAuthKey)
XCTAssertNil(biometricsRepository.capturedUserAuthKey)
}
/// `unlockVault(password:)` throws an error if the vault is unable to be unlocked.

View File

@ -1,8 +1,10 @@
@testable import BitwardenShared
class MockAuthRepository: AuthRepository {
var accountsResult: Result<[ProfileSwitcherItem], Error> = .failure(StateServiceError.noAccounts)
var activeAccountResult: Result<ProfileSwitcherItem, Error> = .failure(StateServiceError.noActiveAccount)
var activeProfileSwitcherItemResult: Result<
ProfileSwitcherItem,
Error
> = .failure(StateServiceError.noActiveAccount)
var allowBiometricUnlock: Bool?
var allowBiometricUnlockResult: Result<Void, Error> = .success(())
var accountForItemResult: Result<Account, Error> = .failure(StateServiceError.noAccounts)
@ -12,6 +14,10 @@ class MockAuthRepository: AuthRepository {
var email: String = ""
var encryptedPin: String = "123"
var fingerprintPhraseResult: Result<String, Error> = .success("fingerprint")
var activeAccount: Account?
var altAccounts = [Account]()
var getAccountError: Error?
var isLockedResult: Result<Bool, Error> = .success(true)
var isPinUnlockAvailable = false
var lockVaultUserId: String?
var logoutCalled = false
@ -21,7 +27,10 @@ class MockAuthRepository: AuthRepository {
var passwordStrengthPassword: String?
var passwordStrengthResult: UInt8 = 0
var pinProtectedUserKey = "123"
var setActiveAccountResult: Result<Account, Error> = .failure(StateServiceError.noAccounts)
var profileSwitcherItemsResult: Result<[ProfileSwitcherItem], Error> = .failure(StateServiceError.noAccounts)
var setActiveAccountId: String?
var setActiveAccountError: Error?
var setVaultTimeoutError: Error?
var unlockVaultPassword: String?
var unlockVaultPIN: String?
var unlockWithPasswordResult: Result<Void, Error> = .success(())
@ -29,8 +38,14 @@ class MockAuthRepository: AuthRepository {
var unlockVaultResult: Result<Void, Error> = .success(())
var unlockVaultWithBiometricsResult: Result<Void, Error> = .success(())
var unlockVaultWithNeverlockResult: Result<Void, Error> = .success(())
func allowBioMetricUnlock(_ enabled: Bool, userId _: String?) async throws {
var allAccounts: [Account] {
let combined = [activeAccount] + altAccounts
return combined.compactMap { $0 }
}
func allowBioMetricUnlock(_ enabled: Bool) async throws {
allowBiometricUnlock = enabled
try allowBiometricUnlockResult.get()
}
@ -44,21 +59,38 @@ class MockAuthRepository: AuthRepository {
}
func getAccounts() async throws -> [ProfileSwitcherItem] {
try accountsResult.get()
try profileSwitcherItemsResult.get()
}
func getActiveAccount() async throws -> ProfileSwitcherItem {
try activeAccountResult.get()
try activeProfileSwitcherItemResult.get()
}
func getAccount(for _: String) async throws -> Account {
try accountForItemResult.get()
func getAccount(for userId: String?) async throws -> Account {
if let getAccountError {
throw getAccountError
}
switch (userId, activeAccount) {
case let (nil, .some(active)):
return active
case (nil, nil):
throw StateServiceError.noActiveAccount
case let (id, _):
guard let match = allAccounts.first(where: { $0.profile.userId == id }) else {
throw StateServiceError.noAccounts
}
return match
}
}
func getFingerprintPhrase() async throws -> String {
try fingerprintPhraseResult.get()
}
func isLocked(userId: String?) async throws -> Bool {
try isLockedResult.get()
}
func isPinUnlockAvailable() async throws -> Bool {
isPinUnlockAvailable
}
@ -83,8 +115,17 @@ class MockAuthRepository: AuthRepository {
try logoutResult.get()
}
func setActiveAccount(userId _: String) async throws -> Account {
try setActiveAccountResult.get()
func setActiveAccount(userId: String) async throws -> Account {
setActiveAccountId = userId
let priorActive = activeAccount
if let setActiveAccountError { throw setActiveAccountError }
guard let match = allAccounts
.first(where: { $0.profile.userId == userId }) else { throw StateServiceError.noAccounts }
activeAccount = match
altAccounts = altAccounts
.filter { $0.profile.userId == userId }
+ [priorActive].compactMap { $0 }
return match
}
func setPins(_ pin: String, requirePasswordAfterRestart _: Bool) async throws {
@ -92,6 +133,12 @@ class MockAuthRepository: AuthRepository {
pinProtectedUserKey = pin
}
func setVaultTimeout(value: BitwardenShared.SessionTimeoutValue, userId: String?) async throws {
if let setVaultTimeoutError {
throw setVaultTimeoutError
}
}
func unlockVaultWithPIN(pin: String) async throws {
unlockVaultPIN = pin
try unlockWithPINResult.get()
@ -105,4 +152,8 @@ class MockAuthRepository: AuthRepository {
func unlockVaultWithBiometrics() async throws {
try unlockVaultWithBiometricsResult.get()
}
func unlockVaultWithNeverlockKey() async throws {
try unlockVaultWithNeverlockResult.get()
}
}

View File

@ -0,0 +1,206 @@
import BitwardenSdk
import LocalAuthentication
// MARK: - BiometricsStatus
enum BiometricsUnlockStatus: Equatable {
/// Biometric Unlock is available.
case available(BiometricAuthenticationType, enabled: Bool, hasValidIntegrity: Bool)
/// Biometric Unlock is not available.
case notAvailable
}
// MARK: - BiometricsRepository
/// A protocol for returning the available authentication policies and access controls for the user's device.
///
protocol BiometricsRepository: AnyObject {
/// Configures the device Biometric Integrity state.
/// Should be called following a successful launch when biometric unlock is enabled.
func configureBiometricIntegrity() async throws
/// Sets the biometric unlock preference for the active user.
/// If permissions have not been requested, this request should trigger the system permisisons dialog.
///
/// - Parameter authKey: An optional `String` representing the user auth key. If nil, Biometric Unlock is disabled.
///
func setBiometricUnlockKey(authKey: String?) async throws
/// Returns the status for user BiometricAuthentication.
///
/// - Returns: The a `BiometricAuthorizationStatus`.
///
func getBiometricUnlockStatus() async throws -> BiometricsUnlockStatus
/// Attempts to retrieve a user's auth key with biometrics.
///
func getUserAuthKey() async throws -> String
}
// MARK: - DefaultBiometricsRepository
/// A default implementation of `BiometricsRepository`, which returns the available authentication policies
/// and access controls for the user's device, and logs an error if one occurs
/// while obtaining the device's biometric authentication type.
///
class DefaultBiometricsRepository: BiometricsRepository {
// MARK: Parameters
/// A service used to track device biometry data & status.
var biometricsService: BiometricsService
/// A service used to store the UserAuthKey key/value pair.
var keychainRepository: KeychainRepository
/// A service used to store the Biometric Integrity Source key/value pair.
var stateService: StateService
// MARK: Initialization
/// Initializes the service.
///
/// - Parameters:
/// - biometricsService: The service used to track device biometry data & status.
/// - keychainService: The service used to store the UserAuthKey key/value pair.
/// - stateService: The service used to update user preferences.
///
init(
biometricsService: BiometricsService,
keychainService: KeychainRepository,
stateService: StateService
) {
self.biometricsService = biometricsService
keychainRepository = keychainService
self.stateService = stateService
}
func configureBiometricIntegrity() async throws {
if let state = biometricsService.getBiometricIntegrityState() {
let base64State = state.base64EncodedString()
try await stateService.setBiometricIntegrityState(base64State)
}
}
func setBiometricUnlockKey(authKey: String?) async throws {
guard let authKey,
try await biometricsService.evaluateBiometricPolicy() else {
try await stateService.setBiometricAuthenticationEnabled(false)
try await stateService.setBiometricIntegrityState(nil)
try? await deleteUserAuthKey()
return
}
try await setUserBiometricAuthKey(value: authKey)
try await stateService.setBiometricAuthenticationEnabled(true)
}
func getBiometricUnlockStatus() async throws -> BiometricsUnlockStatus {
let biometryStatus = biometricsService.getBiometricAuthStatus()
if case .lockedOut = biometryStatus {
throw BiometricsServiceError.biometryLocked
}
let hasEnabledBiometricUnlock = try await stateService.getBiometricAuthenticationEnabled()
let hasValidIntegrityState = await isBiometricIntegrityValid()
switch biometryStatus {
case let .authorized(type):
return .available(
type,
enabled: hasEnabledBiometricUnlock,
hasValidIntegrity: hasValidIntegrityState
)
case .denied,
.lockedOut,
.noBiometrics,
.notDetermined,
.notEnrolled,
.unknownError:
return .notAvailable
}
}
func getUserAuthKey() async throws -> String {
let id = try await stateService.getActiveAccountId()
let key = KeychainItem.biometrics(userId: id)
do {
let string = try await keychainRepository.getUserAuthKeyValue(for: key)
guard !string.isEmpty else {
throw BiometricsServiceError.getAuthKeyFailed
}
if let state = biometricsService.getBiometricIntegrityState() {
let base64State = state.base64EncodedString()
try await stateService.setBiometricIntegrityState(base64State)
}
return string
} catch let error as KeychainServiceError {
switch error {
case .accessControlFailed,
.keyNotFound:
throw BiometricsServiceError.getAuthKeyFailed
case let .osStatusError(status):
switch status {
case kLAErrorBiometryLockout:
throw BiometricsServiceError.biometryLocked
case errSecUserCanceled,
kLAErrorAppCancel,
kLAErrorSystemCancel,
kLAErrorUserCancel:
throw BiometricsServiceError.biometryCancelled
case kLAErrorBiometryDisconnected,
kLAErrorUserFallback:
throw BiometricsServiceError.biometryFailed
default:
throw BiometricsServiceError.getAuthKeyFailed
}
}
} catch {
throw BiometricsServiceError.getAuthKeyFailed
}
}
}
// MARK: Private Methods
extension DefaultBiometricsRepository {
/// Attempts to delete the active user's AuthKey from the keychain.
///
private func deleteUserAuthKey() async throws {
let id = try await stateService.getActiveAccountId()
let key = KeychainItem.biometrics(userId: id)
do {
try await keychainRepository.deleteUserAuthKey(for: key)
} catch {
throw BiometricsServiceError.deleteAuthKeyFailed
}
}
/// Checks if the device evaluatedPolicyDomainState matches the data saved to user defaults.
///
/// - Returns: A `Bool` indicating if the stored Data matches the current data.
/// If no data is stored to the device, `true` is returned by default.
///
private func isBiometricIntegrityValid() async -> Bool {
guard let data = biometricsService.getBiometricIntegrityState() else {
// Fallback for devices unable to retrieve integrity state.
return true
}
let integrityString: String? = try? await stateService.getBiometricIntegrityState()
return data.base64EncodedString() == integrityString
}
/// Attempts to save an auth key to the keychain with biometrics.
///
/// - Parameter value: The key to be stored.
///
private func setUserBiometricAuthKey(value: String) async throws {
let id = try await stateService.getActiveAccountId()
let key = KeychainItem.biometrics(userId: id)
do {
try await keychainRepository.setUserAuthKey(for: key, value: value)
} catch {
throw BiometricsServiceError.setAuthKeyFailed
}
}
}

View File

@ -0,0 +1,479 @@
import LocalAuthentication
import XCTest
// swiftlint:disable file_length
@testable import BitwardenShared
// MARK: - BiometricsRepositoryTests
final class BiometricsRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_body_length
// MARK: Types
enum TestError: Error, Equatable {
case mock(String)
}
// MARK: Properties
var biometricsService: MockBiometricsService!
var keychainService: MockKeychainRepository!
var stateService: MockStateService!
var subject: DefaultBiometricsRepository!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
biometricsService = MockBiometricsService()
keychainService = MockKeychainRepository()
stateService = MockStateService()
subject = DefaultBiometricsRepository(
biometricsService: biometricsService,
keychainService: keychainService,
stateService: stateService
)
}
override func tearDown() {
super.tearDown()
biometricsService = nil
keychainService = nil
stateService = nil
subject = nil
}
// MARK: Tests
/// `configureBiometricIntegrity` does not store empty data.
func test_configureBiometricIntegrity_noData() async throws {
biometricsService.biometricIntegrityState = nil
stateService.activeAccount = .fixture()
stateService.setBiometricIntegrityStateError = nil
try await subject.configureBiometricIntegrity()
XCTAssertTrue(stateService.biometricIntegrityStates.isEmpty)
}
/// `configureBiometricIntegrity` successfully stores data to state.
func test_configureBiometricIntegrity_success() async throws {
let mockData = Data("Mock User Key".utf8)
let expectedBase64String = mockData.base64EncodedString()
biometricsService.biometricIntegrityState = mockData
stateService.activeAccount = .fixture()
stateService.setBiometricIntegrityStateError = nil
try await subject.configureBiometricIntegrity()
XCTAssertEqual(
stateService.biometricIntegrityStates,
[
"1": expectedBase64String,
]
)
}
/// `setBiometricUnlockKey` throws for a no user situation.
func test_getBiometricUnlockKey_noActiveAccount() async throws {
stateService.activeAccount = nil
await assertAsyncThrows(error: StateServiceError.noActiveAccount) {
_ = try await subject.getUserAuthKey()
}
}
/// `setBiometricUnlockKey` throws for a keychain error.
func test_getBiometricUnlockKey_keychainServiceError() async throws {
stateService.activeAccount = .fixture()
keychainService.getResult = .failure(
KeychainServiceError.keyNotFound(.biometrics(userId: "1"))
)
await assertAsyncThrows(error: BiometricsServiceError.getAuthKeyFailed) {
_ = try await subject.getUserAuthKey()
}
}
/// `setBiometricUnlockKey` throws an error for an empty key.
func test_getBiometricUnlockKey_emptyString() async throws {
let expectedKey = ""
stateService.activeAccount = .fixture()
keychainService.getResult = .success(expectedKey)
await assertAsyncThrows(error: BiometricsServiceError.getAuthKeyFailed) {
_ = try await subject.getUserAuthKey()
}
}
/// `setBiometricUnlockKey` returns the correct key for the active user.
func test_getBiometricUnlockKey_success() async throws {
let expectedKey = "expectedKey"
stateService.activeAccount = .fixture()
keychainService.getResult = .success(expectedKey)
let key = try await subject.getUserAuthKey()
XCTAssertEqual(key, expectedKey)
}
/// `getBiometricUnlockStatus` throws an error if the user has locked biometrics.
func test_getBiometricUnlockStatus_lockout() async throws {
let active = Account.fixture()
stateService.activeAccount = active
biometricsService.biometricAuthStatus = .lockedOut(.faceID)
let integrity = Data("Face/Off".utf8)
biometricsService.biometricIntegrityState = integrity
stateService.biometricIntegrityStates = [
active.profile.userId: integrity.base64EncodedString(),
]
stateService.biometricsEnabled = [
active.profile.userId: false,
]
await assertAsyncThrows(error: BiometricsServiceError.biometryLocked) {
_ = try await subject.getBiometricUnlockStatus()
}
}
/// `getBiometricUnlockStatus` marks devices without any biometric integrity data as having valid integrity.
func test_getBiometricUnlockStatus_noDeviceIntegrityData() async throws {
let active = Account.fixture()
stateService.activeAccount = active
biometricsService.biometricAuthStatus = .authorized(.faceID)
biometricsService.biometricIntegrityState = nil
stateService.biometricIntegrityStates = [
active.profile.userId: Data("National Treasure".utf8).base64EncodedString(),
]
stateService.biometricsEnabled = [
active.profile.userId: true,
]
let status = try await subject.getBiometricUnlockStatus()
XCTAssertEqual(
status,
BiometricsUnlockStatus.available(
.faceID,
enabled: true,
hasValidIntegrity: true
)
)
}
/// `getBiometricUnlockStatus` tracks the availablity of biometrics.
func test_getBiometricUnlockStatus_success_denied() async throws {
let active = Account.fixture()
stateService.activeAccount = active
biometricsService.biometricAuthStatus = .denied(.touchID)
let integrity = Data("Face/Off".utf8)
biometricsService.biometricIntegrityState = integrity
stateService.biometricIntegrityStates = [
active.profile.userId: integrity.base64EncodedString(),
]
stateService.biometricsEnabled = [
active.profile.userId: false,
]
let status = try await subject.getBiometricUnlockStatus()
XCTAssertEqual(
status,
.notAvailable
)
}
/// `getBiometricUnlockStatus` tracks if a user has enabled or disabled biometrics.
func test_getBiometricUnlockStatus_success_disabled() async throws {
let active = Account.fixture()
stateService.activeAccount = active
biometricsService.biometricAuthStatus = .authorized(.touchID)
let integrity = Data("Face/Off".utf8)
biometricsService.biometricIntegrityState = integrity
stateService.biometricIntegrityStates = [
active.profile.userId: integrity.base64EncodedString(),
]
stateService.biometricsEnabled = [
active.profile.userId: false,
]
let status = try await subject.getBiometricUnlockStatus()
XCTAssertEqual(
status,
BiometricsUnlockStatus.available(
.touchID,
enabled: false,
hasValidIntegrity: true
)
)
}
/// `getBiometricUnlockStatus` tracks integrity state validity.
func test_getBiometricUnlockStatus_success_invalidIntegrity() async throws {
let active = Account.fixture()
stateService.activeAccount = active
biometricsService.biometricAuthStatus = .authorized(.faceID)
let integrity = Data("Face/Off".utf8)
biometricsService.biometricIntegrityState = integrity
stateService.biometricIntegrityStates = [
active.profile.userId: Data("National Treasure".utf8).base64EncodedString(),
]
stateService.biometricsEnabled = [
active.profile.userId: true,
]
let status = try await subject.getBiometricUnlockStatus()
XCTAssertEqual(
status,
BiometricsUnlockStatus.available(
.faceID,
enabled: true,
hasValidIntegrity: false
)
)
}
/// `getBiometricUnlockStatus` tracks all biometrics components.
func test_getBiometricUnlockStatus_success() async throws {
let active = Account.fixture()
stateService.activeAccount = active
biometricsService.biometricAuthStatus = .authorized(.faceID)
let integrity = Data("Face/Off".utf8)
biometricsService.biometricIntegrityState = integrity
stateService.biometricIntegrityStates = [
active.profile.userId: integrity.base64EncodedString(),
]
stateService.biometricsEnabled = [
active.profile.userId: true,
]
let status = try await subject.getBiometricUnlockStatus()
XCTAssertEqual(
status,
BiometricsUnlockStatus.available(
.faceID,
enabled: true,
hasValidIntegrity: true
)
)
}
/// `getUserAuthKey` throws on empty keys.
func test_getUserAuthKey_emptyString() async throws {
let active = Account.fixture()
stateService.activeAccount = active
let integrity = Data("Face/Off".utf8)
biometricsService.biometricIntegrityState = integrity
stateService.biometricsEnabled = [
active.profile.userId: true,
]
keychainService.getResult = .success("")
await assertAsyncThrows(error: BiometricsServiceError.getAuthKeyFailed) {
_ = try await subject.getUserAuthKey()
}
}
/// `getUserAuthKey` retrieves the key from keychain and updates integrity state.
func test_getUserAuthKey_success() async throws {
let active = Account.fixture()
stateService.activeAccount = active
let integrity = Data("Face/Off".utf8)
biometricsService.biometricIntegrityState = integrity
stateService.biometricsEnabled = [
active.profile.userId: true,
]
keychainService.getResult = .success("Dramatic Masterpiece")
let key = try await subject.getUserAuthKey()
XCTAssertEqual(
key,
"Dramatic Masterpiece"
)
XCTAssertEqual(
stateService.biometricIntegrityStates,
[
active.profile.userId: integrity.base64EncodedString(),
]
)
}
/// `getUserAuthKey` retrieves the key from keychain and updates integrity state.
func test_getUserAuthKey_lockedError() async throws {
let active = Account.fixture()
stateService.activeAccount = active
let integrity = Data("Face/Off".utf8)
biometricsService.biometricIntegrityState = integrity
stateService.biometricsEnabled = [
active.profile.userId: true,
]
// -8 is the code for kLAErrorBiometryLockout.
keychainService.getResult = .failure(KeychainServiceError.osStatusError(-8))
await assertAsyncThrows(error: BiometricsServiceError.biometryLocked) {
_ = try await subject.getUserAuthKey()
}
}
/// `getUserAuthKey` retrieves the key from keychain and updates integrity state.
func test_getUserAuthKey_biometryFailed() async throws {
let active = Account.fixture()
stateService.activeAccount = active
let integrity = Data("Face/Off".utf8)
biometricsService.biometricIntegrityState = integrity
stateService.biometricsEnabled = [
active.profile.userId: true,
]
keychainService.getResult = .failure(KeychainServiceError.osStatusError(kLAErrorBiometryDisconnected))
await assertAsyncThrows(error: BiometricsServiceError.biometryFailed) {
_ = try await subject.getUserAuthKey()
}
}
/// `getUserAuthKey` retrieves the key from keychain and updates integrity state.
func test_getUserAuthKey_cancelled() async throws {
let active = Account.fixture()
stateService.activeAccount = active
let integrity = Data("Face/Off".utf8)
biometricsService.biometricIntegrityState = integrity
stateService.biometricsEnabled = [
active.profile.userId: true,
]
// Send the user cancelled code.
keychainService.getResult = .failure(KeychainServiceError.osStatusError(errSecUserCanceled))
await assertAsyncThrows(error: BiometricsServiceError.biometryCancelled) {
_ = try await subject.getUserAuthKey()
}
}
/// `getUserAuthKey` retrieves the key from keychain and updates integrity state.
func test_getUserAuthKey_unknownError() async throws {
let active = Account.fixture()
stateService.activeAccount = active
let integrity = Data("Face/Off".utf8)
biometricsService.biometricIntegrityState = integrity
stateService.biometricsEnabled = [
active.profile.userId: true,
]
keychainService.getResult = .failure(BitwardenTestError.example)
await assertAsyncThrows(error: BiometricsServiceError.getAuthKeyFailed) {
_ = try await subject.getUserAuthKey()
}
}
/// `setBiometricUnlockKey` throws when there is no active account.
func test_setBiometricUnlockKey_nilValue_noActiveAccount() async throws {
stateService.activeAccount = nil
await assertAsyncThrows(error: StateServiceError.noActiveAccount) {
try await subject.setBiometricUnlockKey(authKey: nil)
}
}
/// `setBiometricUnlockKey` throws when there is a state service error.
func test_setBiometricUnlockKey_nilValue_setBiometricAuthenticationEnabledFailed() async throws {
stateService.activeAccount = .fixture()
stateService.setBiometricAuthenticationEnabledResult = .failure(
TestError.mock("setBiometricAuthenticationEnabledFailed")
)
await assertAsyncThrows(
error: TestError.mock("setBiometricAuthenticationEnabledFailed")
) {
try await subject.setBiometricUnlockKey(authKey: nil)
}
}
/// `setBiometricUnlockKey` throws when there is a state service error.
func test_setBiometricUnlockKey_nilValue_setBiometricIntegrityStateFailed() async throws {
stateService.activeAccount = .fixture()
stateService.setBiometricIntegrityStateError = TestError
.mock("setBiometricIntegrityStateFailed")
await assertAsyncThrows(
error: TestError.mock("setBiometricIntegrityStateFailed")
) {
try await subject.setBiometricUnlockKey(authKey: nil)
}
}
/// `setBiometricUnlockKey` A failure in evaluating the biometrics policy clears any integrity state or auth key.
func test_setBiometricUnlockKey_evaluationFalse() async throws {
stateService.activeAccount = .fixture()
try? await stateService.setBiometricAuthenticationEnabled(true)
stateService.biometricIntegrityStates = [
"1": "SomeState",
]
keychainService.mockStorage = [
keychainService.formattedKey(for: .biometrics(userId: "1")): "storedKey",
]
biometricsService.evaluationResult = false
stateService.setBiometricAuthenticationEnabledResult = .success(())
keychainService.deleteResult = .success(())
try await subject.setBiometricUnlockKey(authKey: nil)
waitFor(keychainService.mockStorage.isEmpty)
let result = try XCTUnwrap(stateService.biometricsEnabled["1"])
XCTAssertFalse(result)
}
/// `setBiometricUnlockKey` can remove a user key from the keychain and track the availbility in state.
func test_setBiometricUnlockKey_nilValue_success() async throws {
stateService.activeAccount = .fixture()
try? await stateService.setBiometricAuthenticationEnabled(true)
stateService.biometricIntegrityStates = [
"1": "SomeState",
]
keychainService.mockStorage = [
keychainService.formattedKey(for: .biometrics(userId: "1")): "storedKey",
]
stateService.setBiometricAuthenticationEnabledResult = .success(())
keychainService.deleteResult = .success(())
try await subject.setBiometricUnlockKey(authKey: nil)
waitFor(keychainService.mockStorage.isEmpty)
let result = try XCTUnwrap(stateService.biometricsEnabled["1"])
XCTAssertFalse(result)
}
/// `setBiometricUnlockKey` throws on a keychain error.
func test_setBiometricUnlockKey_nilValue_successWithKeychainError() async throws {
stateService.activeAccount = .fixture()
stateService.setBiometricAuthenticationEnabledResult = .success(())
keychainService.deleteResult = .failure(KeychainServiceError.osStatusError(13))
await assertAsyncDoesNotThrow {
try await subject.setBiometricUnlockKey(authKey: nil)
}
}
/// `setBiometricUnlockKey` throws when there is no active account.
func test_setBiometricUnlockKey_withValue_noActiveAccount() async throws {
stateService.activeAccount = nil
await assertAsyncThrows(error: StateServiceError.noActiveAccount) {
try await subject.setBiometricUnlockKey(authKey: "authKey")
}
}
/// `setBiometricUnlockKey` throws when there is no active account.
func test_setBiometricUnlockKey_withValue_setBiometricAuthenticationEnabledFailed() async throws {
stateService.activeAccount = .fixture()
stateService.setBiometricAuthenticationEnabledResult = .failure(
TestError.mock("setBiometricAuthenticationEnabledFailed")
)
await assertAsyncThrows(
error: TestError.mock("setBiometricAuthenticationEnabledFailed")
) {
try await subject.setBiometricUnlockKey(authKey: "authKey")
}
}
/// `setBiometricUnlockKey` throws on a keychain error.
func test_setBiometricUnlockKey_withValue_keychainError() async throws {
stateService.activeAccount = .fixture()
stateService.setBiometricAuthenticationEnabledResult = .success(())
keychainService.setResult = .failure(KeychainServiceError.osStatusError(13))
await assertAsyncThrows(
error: BiometricsServiceError.setAuthKeyFailed
) {
try await subject.setBiometricUnlockKey(authKey: "authKey")
}
}
/// `setBiometricUnlockKey` can store a user key to the keychain and track the availability in state.
func test_setBiometricUnlockKey_withValue_success() async throws {
stateService.activeAccount = .fixture()
stateService.setBiometricAuthenticationEnabledResult = .success(())
keychainService.setResult = .success(())
try await subject.setBiometricUnlockKey(authKey: "authKey")
waitFor(!keychainService.mockStorage.isEmpty)
XCTAssertEqual(
"authKey",
keychainService.mockStorage[keychainService.formattedKey(
for: .biometrics(
userId: "1"
)
)]
)
let result = try XCTUnwrap(stateService.biometricsEnabled["1"])
XCTAssertTrue(result)
XCTAssertEqual(keychainService.securityType, .biometryCurrentSet)
}
}

View File

@ -0,0 +1,168 @@
import LocalAuthentication
import OSLog
// MARK: - BiometricsService
/// A protocol for returning the available authentication policies and access controls for the user's device.
///
protocol BiometricsService: AnyObject {
/// Evaluate's the users biometrics policy via `BiometricAuthorizationStatus`
///
/// - Parameter biometricAuthStatus: The status to be checked.
/// If `true`, a system dialog may prompt the user for permissions.
/// - Returns: A `Bool` indicating if the evaluation was successful.
///
func evaluateBiometricPolicy(
_ suppliedContext: LAContext?,
for biometricAuthStatus: BiometricAuthorizationStatus
) async -> Bool
/// Returns the status for device BiometricAuthenticationType.
///
/// - Returns: The `BiometricAuthenticationType`.
///
func getBiometricAuthenticationType(_ suppliedContext: LAContext?) -> BiometricAuthenticationType?
/// Returns the status for user BiometricAuthentication.
///
/// - Returns: The a `BiometricAuthorizationStatus`.
///
func getBiometricAuthStatus() -> BiometricAuthorizationStatus
/// Returns the `Data` for device evaluatedPolicyDomainState.
///
/// - Returns: The `Data` for evaluatedPolicyDomainState.
///
func getBiometricIntegrityState() -> Data?
}
extension BiometricsService {
/// Evaluate's the users biometrics policy via `BiometricAuthorizationStatus`
///
/// - Returns: An evaluated status for the user's biometric authorization.
///
func evaluateBiometricPolicy() async throws -> Bool {
let initialStatus = getBiometricAuthStatus()
return await evaluateBiometricPolicy(nil, for: initialStatus)
}
}
class DefaultBiometricsService: BiometricsService {
func evaluateBiometricPolicy(
_ suppliedContext: LAContext?,
for biometricAuthStatus: BiometricAuthorizationStatus
) async -> Bool {
// First check if the existing status can be evaluated.
guard case .authorized = biometricAuthStatus else {
// If not, return false
return false
}
// Then, evaluate the policy, which prompts the user for FaceID
// or biometrics permissions.
let authContext = suppliedContext ?? LAContext()
do {
let result = try await authContext.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: Localizations.useBiometricsToUnlock
)
return result
} catch {
Logger.processor.error("Error evaluating biometrics policy: \(error)")
return false
}
}
func getBiometricAuthenticationType(_ suppliedContext: LAContext?) -> BiometricAuthenticationType? {
let authContext = suppliedContext ?? LAContext()
var error: NSError?
guard authContext.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else {
Logger.processor.error("Error checking biometrics type: \(error)")
return nil
}
switch authContext.biometryType {
case .none,
.opticID:
return .none
case .touchID:
return .touchID
case .faceID:
return .faceID
@unknown default:
return .none
}
}
func getBiometricAuthStatus() -> BiometricAuthorizationStatus {
let context = LAContext()
var error: NSError?
let biometricAuthType = getBiometricAuthenticationType(context)
// Check if the device supports biometric authentication.
if let biometricAuthType,
context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
// Biometrics are available and enrolled, permissions are undetermined or granted.
return .authorized(biometricAuthType)
} else {
guard let error else {
return .notDetermined
}
return errorStatus(biometricAuthType: biometricAuthType, error: error)
}
}
func getBiometricIntegrityState() -> Data? {
let context = LAContext()
var error: NSError?
context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
return context.evaluatedPolicyDomainState
}
// MARK: Private Methods
/// Derives a BiometricAuthStatus from a supplied error.
///
/// - Parameters:
/// - biometricAuthType: The biometry type.
/// - error: The error to use in constructing an auth status.
/// - Returns: A BiometricAuthStatus.
///
func errorStatus(
biometricAuthType: BiometricAuthenticationType?,
error: Error
) -> BiometricAuthorizationStatus {
guard let biometricAuthType else {
// Biometrics are not available on the device.
Logger.application.log("Biometry is not available.")
return .noBiometrics
}
guard let laError = error as? LAError else {
// A non LAError occured
Logger.application.log("Other error: \(error.localizedDescription)")
return .unknownError(error.localizedDescription, biometricAuthType)
}
// If canEvaluatePolicy returns false, check the error code.
switch laError.code {
case .biometryNotAvailable:
// The user has denied Biometrics permission for this app.
Logger.application.log("Biometric permission denied!")
return .denied(biometricAuthType)
case .biometryNotEnrolled:
// Biometrics are supported but not enrolled.
Logger.application.log("Biometry is supported but not enrolled.")
return .notEnrolled(biometricAuthType)
case .biometryLockout:
// Biometrics are locked out, typically due to too many failed attempts.
Logger.application.log("Biometry is temporarily locked out.")
return .lockedOut(biometricAuthType)
default:
// Other types of errors.
Logger.application.log("Other error: \(laError.localizedDescription)")
return .unknownError(laError.localizedDescription, biometricAuthType)
}
}
}

View File

@ -1,8 +1,16 @@
// MARK: - BiometricsServiceError
/// An error thrown by a BiometricsService.
/// An error thrown by a BiometricsRepository.
///
enum BiometricsServiceError: Error, Equatable {
/// An error when the user, app, or system cancels a biometric unlock
///
case biometryCancelled
/// An error for when biometry fails for a benign reason.
///
case biometryFailed
/// An error for when the user has passed the maximum failed attempts at biometric unlock.
///
case biometryLocked

View File

@ -1,331 +0,0 @@
import BitwardenSdk
import LocalAuthentication
import OSLog
// MARK: - BiometricsStatus
enum BiometricsUnlockStatus: Equatable {
/// Biometric Unlock is available.
case available(BiometricAuthenticationType, enabled: Bool, hasValidIntegrity: Bool)
/// Biometric Unlock is not available.
case notAvailable
}
// MARK: - BiometricsService
/// A protocol for returning the available authentication policies and access controls for the user's device.
///
protocol BiometricsService: AnyObject {
/// Configures the device Biometric Integrity state.
/// Should be called following a successful launch when biometric unlock is enabled.
func configureBiometricIntegrity() async throws
/// Returns the status for user BiometricAuthentication.
///
/// - Returns: The a `BiometricAuthorizationStatus`.
///
func getBiometricUnlockStatus() async throws -> BiometricsUnlockStatus
/// Sets the biometric unlock preference for a given user.
///
/// - Parameters:
/// - authKey: An optional `String` representing the user auth key. If nil, Biometric Unlock is disabled.
/// - userId: The id of the user. Defaults to the active user id.
///
func setBiometricUnlockKey(authKey: String?, for userId: String?) async throws
/// Attempts to retrieve a user's auth key with biometrics.
///
/// - Parameter userId: The userId for the stored auth key.
///
func getUserAuthKey(for userId: String?) async throws -> String
}
// MARK: - DefaultBiometricsService
/// A default implementation of `BiometricsService`, which returns the available authentication policies
/// and access controls for the user's device, and logs an error if one occurs
/// while obtaining the device's biometric authentication type.
///
class DefaultBiometricsService: BiometricsService {
// MARK: Parameters
/// A service used to store the Biometric Integrity Source key/value pair.
var stateService: StateService
// MARK: Initialization
/// Initializes the service.
///
/// - Parameter stateService: The service used to update user preferences.
///
init(stateService: StateService) {
self.stateService = stateService
}
func configureBiometricIntegrity() async throws {
if let state = getBiometricIntegrityState() {
let base64State = state.base64EncodedString()
try await stateService.setBiometricIntegrityState(base64State)
}
}
func getBiometricUnlockStatus() async throws -> BiometricsUnlockStatus {
let biometryStatus = getBiometricAuthStatus()
if case .lockedOut = biometryStatus {
throw BiometricsServiceError.deleteAuthKeyFailed
}
let hasEnabledBiometricUnlock = try await stateService.getBiometricAuthenticationEnabled()
let hasValidIntegrityState = await isBiometricIntegrityValid()
switch biometryStatus {
case let .authorized(type):
return .available(
type,
enabled: hasEnabledBiometricUnlock,
hasValidIntegrity: hasValidIntegrityState
)
case .denied,
.lockedOut,
.noBiometrics,
.notDetermined,
.notEnrolled,
.unknownError:
return .notAvailable
}
}
func setBiometricUnlockKey(authKey: String?, for userId: String? = nil) async throws {
guard let authKey else {
try await stateService.setBiometricAuthenticationEnabled(false)
try await stateService.setBiometricIntegrityState(nil)
try? await deleteUserAuthKey(for: userId)
return
}
try await setUserAuthKey(value: authKey, for: userId)
try await stateService.setBiometricAuthenticationEnabled(true)
}
func getUserAuthKey(for userId: String? = nil) async throws -> String {
let context = LAContext()
guard let bundleId = Bundle.main.bundleIdentifier else {
throw BiometricsServiceError.getAuthKeyFailed
}
let id = try await getUserId(userId)
let key = biometricStorageKey(for: id)
let searchQuery = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: bundleId,
kSecAttrAccount: key,
kSecMatchLimit: kSecMatchLimitOne,
kSecReturnData: true,
kSecReturnAttributes: true,
] as CFDictionary
var item: AnyObject?
let status = SecItemCopyMatching(searchQuery, &item)
if status == errSecItemNotFound {
throw BiometricsServiceError.getAuthKeyFailed
}
if let resultDictionary = item as? [String: Any],
let data = resultDictionary[kSecValueData as String] as? Data {
let string = String(decoding: data, as: UTF8.self)
guard !string.isEmpty else {
throw BiometricsServiceError.getAuthKeyFailed
}
if let state = context.evaluatedPolicyDomainState {
let base64State = state.base64EncodedString()
try await stateService.setBiometricIntegrityState(base64State)
}
return string
}
throw BiometricsServiceError.getAuthKeyFailed
}
/// Attempts to save an auth key to the keychain with biometrics.
///
/// - Parameters
/// - value: The key to be stored.
/// - userId: The userId for the key to be saved to the keychain.
///
private func setUserAuthKey(value: String, for userId: String?) async throws {
guard let bundleId = Bundle.main.bundleIdentifier else {
throw BiometricsServiceError.setAuthKeyFailed
}
let id = try await getUserId(userId)
let key = biometricStorageKey(for: id)
var error: Unmanaged<CFError>?
let accessControl = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.biometryCurrentSet,
&error
)
guard accessControl != nil,
error == nil else { throw BiometricsServiceError.setAuthKeyFailed }
let query = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: bundleId,
kSecAttrAccount: key,
kSecValueData: Data(value.utf8),
kSecAttrAccessControl: accessControl as Any,
] as CFDictionary
// Try to delete the previous secret, if it exists
// Otherwise we get `errSecDuplicateItem`
SecItemDelete(query)
let status = SecItemAdd(query, nil)
guard status == errSecSuccess else {
throw BiometricsServiceError.setAuthKeyFailed
}
}
}
// MARK: Private Methods
extension DefaultBiometricsService {
private func biometricStorageKey(for userId: String) -> String {
"biometric_key_\(userId)"
}
/// Attempts to delete the userAuthKey from the keychain.
///
/// - Parameter userId: The userId for the key to be deleted.
///
private func deleteUserAuthKey(for userId: String?) async throws {
guard let bundleId = Bundle.main.bundleIdentifier else {
throw BiometricsServiceError.deleteAuthKeyFailed
}
let id = try await getUserId(userId)
let key = biometricStorageKey(for: id)
let queryDictionary = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: bundleId,
kSecAttrAccount: key,
] as CFDictionary
let deleteStatus = SecItemDelete(queryDictionary)
if deleteStatus != errSecSuccess {
throw BiometricsServiceError.deleteAuthKeyFailed
}
}
/// Returns the status for device BiometricAuthenticationType.
///
/// - Returns: The `BiometricAuthenticationType`.
///
private func getBiometricAuthenticationType(_ suppliedContext: LAContext? = nil) -> BiometricAuthenticationType? {
let authContext = suppliedContext ?? LAContext()
var error: NSError?
guard authContext.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else {
Logger.processor.error("Error checking biometrics type: \(error)")
return nil
}
switch authContext.biometryType {
case .none,
.opticID:
return .none
case .touchID:
return .touchID
case .faceID:
return .faceID
@unknown default:
return .none
}
}
/// Returns the status for user BiometricAuthentication.
///
/// - Parameter suppliedContext: The LAContext in which to check for the status.
/// - Returns: The a `BiometricAuthorizationStatus`.
///
private func getBiometricAuthStatus(_ suppliedContext: LAContext? = nil) -> BiometricAuthorizationStatus {
let context = suppliedContext ?? LAContext()
var error: NSError?
let biometricAuthType = getBiometricAuthenticationType(context)
// Check if the device supports biometric authentication.
if let biometricAuthType,
context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
// Biometrics are available and enrolled, permissions are undetermined or granted.
return .authorized(biometricAuthType)
} else {
guard let biometricAuthType else {
// Biometrics are not available on the device.
Logger.application.log("Biometry is not available.")
return .noBiometrics
}
guard let laError = error as? LAError else {
// A non LAError occured
Logger.application.log("Other error: \(error?.localizedDescription ?? "")")
return .unknownError(error?.localizedDescription ?? "", biometricAuthType)
}
// If canEvaluatePolicy returns false, check the error code.
switch laError.code {
case .biometryNotAvailable:
// The user has denied Biometrics permission for this app.
Logger.application.log("Biometric permission denied!")
return .denied(biometricAuthType)
case .biometryNotEnrolled:
// Biometrics are supported but not enrolled.
Logger.application.log("Biometry is supported but not enrolled.")
return .notEnrolled(biometricAuthType)
case .biometryLockout:
// Biometrics are locked out, typically due to too many failed attempts.
Logger.application.log("Biometry is temporarily locked out.")
return .lockedOut(biometricAuthType)
default:
// Other types of errors.
Logger.application.log("Other error: \(laError.localizedDescription)")
return .unknownError(laError.localizedDescription, biometricAuthType)
}
}
}
/// Returns the `Data` for device evaluatedPolicyDomainState.
///
/// - Returns: The `Data` for evaluatedPolicyDomainState.
///
private func getBiometricIntegrityState() -> Data? {
let context = LAContext()
var error: NSError?
context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
return context.evaluatedPolicyDomainState
}
private func getUserId(_ id: String?) async throws -> String {
if let id {
return id
}
return try await stateService.getActiveAccountId()
}
/// Checks if the device evaluatedPolicyDomainState matches the data saved to user defaults.
///
/// - Returns: A `Bool` indicating if the stored Data matches the current data.
/// If no data is stored to the device, `true` is returned by default.
///
private func isBiometricIntegrityValid() async -> Bool {
guard let data = getBiometricIntegrityState() else {
// Fallback for devices unable to retrieve integrity state.
return true
}
let integrityString: String? = try? await stateService.getBiometricIntegrityState()
return data.base64EncodedString() == integrityString
}
}

View File

@ -0,0 +1,198 @@
import Foundation
// MARK: - KeychainItem
enum KeychainItem: Equatable {
/// The keychain item for biometrics protected user auth key.
case biometrics(userId: String)
/// The keychain item for the neverLock user auth key.
case neverLock(userId: String)
/// The `SecAccessControlCreateFlags` protection level for this keychain item.
/// If `nil`, no extra protection is applied.
///
var protection: SecAccessControlCreateFlags? {
switch self {
case .biometrics:
.biometryCurrentSet
case .neverLock:
nil
}
}
/// The storage key for this keychain item.
///
var unformattedKey: String {
switch self {
case let .biometrics(userId: id):
"biometric_key_" + id
case let .neverLock(userId: id):
"userKeyAutoUnlock_" + id
}
}
}
// MARK: - KeychainRepository
protocol KeychainRepository: AnyObject {
/// Attempts to delete the userAuthKey from the keychain.
///
/// - Parameter item: The KeychainItem to be deleted.
///
func deleteUserAuthKey(for item: KeychainItem) async throws
/// Gets a user auth key value.
///
/// - Parameter item: The storage key of the user auth key.
/// - Returns: A string representing the user auth key.
///
func getUserAuthKeyValue(for item: KeychainItem) async throws -> String
/// Sets a user auth key/value pair.
///
/// - Parameters:
/// - item: The storage key for this auth key.
/// - value: A `String` representing the user auth key.
///
func setUserAuthKey(for item: KeychainItem, value: String) async throws
}
extension KeychainRepository {
/// The format for storing a `KeychainItem`'s `unformattedKey`.
/// The first value should be a unique appID from the `appIdService`.
/// The second value is the `unformattedKey`
///
/// example: `bwKeyChainStorage:1234567890:biometric_key_98765`
///
var storageKeyFormat: String { "bwKeyChainStorage:%@:%@" }
}
// MARK: - DefaultKeychainRepository
class DefaultKeychainRepository: KeychainRepository {
// MARK: Properties
/// A service used to provide unique app ids.
///
let appIdService: AppIdService
/// An identifier for this application and extensions.
/// ie: "LTZ2PFU5D6.com.8bit.bitwarden"
///
var appSecAttrService: String {
Bundle.main.appIdentifier
}
/// An identifier for this application group and extensions
/// ie: "group.LTZ2PFU5D6.com.8bit.bitwarden"
///
var appSecAttrAccessGroup: String {
Bundle.main.groupIdentifier
}
/// The keychain service used by the repository
///
let keychainService: KeychainService
// MARK: Initialization
init(
appIdService: AppIdService,
keychainService: KeychainService
) {
self.appIdService = appIdService
self.keychainService = keychainService
}
// MARK: Methods
func deleteUserAuthKey(for item: KeychainItem) async throws {
try await keychainService.delete(
query: keychainQueryValues(for: item)
)
}
/// Generates a formated storage key for a keychain item.
///
/// - Parameter item: The keychain item that needs a formatted key.
/// - Returns: A formatted storage key.
///
func formattedKey(for item: KeychainItem) async -> String {
let appId = await appIdService.getOrCreateAppId()
return String(format: storageKeyFormat, appId, item.unformattedKey)
}
func getUserAuthKeyValue(for item: KeychainItem) async throws -> String {
let foundItem = try await keychainService.search(
query: keychainQueryValues(
for: item,
adding: [
kSecMatchLimit: kSecMatchLimitOne,
kSecReturnData: true,
kSecReturnAttributes: true,
]
)
)
if let resultDictionary = foundItem as? [String: Any],
let data = resultDictionary[kSecValueData as String] as? Data {
let string = String(decoding: data, as: UTF8.self)
guard !string.isEmpty else {
throw KeychainServiceError.keyNotFound(item)
}
return string
}
throw KeychainServiceError.keyNotFound(item)
}
/// The core key/value pairs for Keychain operations
///
/// - Parameter item: The `KeychainItem` to be queried.
///
func keychainQueryValues(
for item: KeychainItem,
adding additionalPairs: [CFString: Any] = [:]
) async -> CFDictionary {
// Prepare a formatted `kSecAttrAccount` value.
let formattedSecAttrAccount = await formattedKey(for: item)
// Configure the base dictionary
var result: [CFString: Any] = [
kSecAttrAccount: formattedSecAttrAccount,
kSecAttrAccessGroup: appSecAttrAccessGroup,
kSecAttrService: appSecAttrService,
kSecClass: kSecClassGenericPassword,
]
// Add the addional key value pairs.
additionalPairs.forEach { key, value in
result[key] = value
}
return result as CFDictionary
}
func setUserAuthKey(for item: KeychainItem, value: String) async throws {
let accessControl = try keychainService.accessControl(
for: item.protection ?? []
)
let query = await keychainQueryValues(
for: item,
adding: [
kSecAttrAccessControl: accessControl as Any,
kSecValueData: Data(value.utf8),
]
)
// Delete the previous secret, if it exists,
// otherwise we get `errSecDuplicateItem`.
try? keychainService.delete(query: query)
// Add the new key.
try keychainService.add(
attributes: query
)
}
}

View File

@ -0,0 +1,279 @@
import XCTest
@testable import BitwardenShared
// MARK: - KeychainRepositoryTests
final class KeychainRepositoryTests: BitwardenTestCase {
// MARK: Properties
var appSettingsStore: MockAppSettingsStore!
var keychainService: MockKeychainService!
var subject: DefaultKeychainRepository!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
appSettingsStore = MockAppSettingsStore()
keychainService = MockKeychainService()
subject = DefaultKeychainRepository(
appIdService: AppIdService(
appSettingStore: appSettingsStore
),
keychainService: keychainService
)
}
override func tearDown() {
super.tearDown()
appSettingsStore = nil
keychainService = nil
subject = nil
}
// MARK: Tests
/// The service provides a kSecAttrService value.
///
func test_appSecAttrService() {
XCTAssertEqual(
Bundle.main.appIdentifier,
subject.appSecAttrService
)
}
/// The service provides a kSecAttrAccessGroup value.
///
func test_appSecAttrAccessGroup() {
XCTAssertEqual(
Bundle.main.groupIdentifier,
subject.appSecAttrAccessGroup
)
}
/// `deleteUserAuthKey` failures rethrow.
///
func test_delete_error_onDelete() async {
keychainService.deleteResult = .failure(.osStatusError(-1))
await assertAsyncThrows(error: KeychainServiceError.osStatusError(-1)) {
try await subject.deleteUserAuthKey(for: .biometrics(userId: "123"))
}
}
/// `deleteUserAuthKey` succeeds quietly.
///
func test_delete_success() async throws {
let item = KeychainItem.biometrics(userId: "123")
keychainService.deleteResult = .success(())
let expectedQuery = await subject.keychainQueryValues(for: item)
try await subject.deleteUserAuthKey(for: item)
XCTAssertEqual(
keychainService.deleteQuery,
expectedQuery
)
}
/// The service should generate a storage key for a` KeychainItem`.
///
func test_formattedKey_biometrics() async {
let item = KeychainItem.biometrics(userId: "123")
appSettingsStore.appId = "testAppId"
let formattedKey = await subject.formattedKey(for: item)
let expectedKey = String(format: subject.storageKeyFormat, "testAppId", item.unformattedKey)
XCTAssertEqual(
formattedKey,
expectedKey
)
}
/// The service should generate a storage key for a` KeychainItem`.
///
func test_formattedKey_neverLock() async {
let item = KeychainItem.neverLock(userId: "123")
appSettingsStore.appId = "testAppId"
let formattedKey = await subject.formattedKey(for: item)
let expectedKey = String(format: subject.storageKeyFormat, "testAppId", item.unformattedKey)
XCTAssertEqual(
formattedKey,
expectedKey
)
}
/// `getUserAuthKeyValue(_:)` failures rethrow.
///
func test_getUserAuthKeyValue_error_searchError() async {
let item = KeychainItem.biometrics(userId: "123")
let searchError = KeychainServiceError.osStatusError(-1)
keychainService.searchResult = .failure(searchError)
await assertAsyncThrows(error: searchError) {
_ = try await subject.getUserAuthKeyValue(for: item)
}
}
/// `getUserAuthKeyValue(_:)` errors if the search results are not in the correct format.
///
func test_getUserAuthKeyValue_error_malformedData() async {
let item = KeychainItem.biometrics(userId: "123")
let notFoundError = KeychainServiceError.keyNotFound(item)
let results = [
kSecValueData: Data(),
] as CFDictionary
keychainService.searchResult = .success(results)
await assertAsyncThrows(error: notFoundError) {
_ = try await subject.getUserAuthKeyValue(for: .biometrics(userId: "123"))
}
}
/// `getUserAuthKeyValue(_:)` errors if the search results are not in the correct format.
///
func test_getUserAuthKeyValue_error_unexpectedResult() async {
let item = KeychainItem.biometrics(userId: "123")
let notFoundError = KeychainServiceError.keyNotFound(item)
let results = [
kSecValueData: 1,
] as CFDictionary
keychainService.searchResult = .success(results)
await assertAsyncThrows(error: notFoundError) {
_ = try await subject.getUserAuthKeyValue(for: .biometrics(userId: "123"))
}
}
/// `getUserAuthKeyValue(_:)` errors if the search results are empty.
///
func test_getUserAuthKeyValue_error_nilResult() async {
let item = KeychainItem.biometrics(userId: "123")
let notFoundError = KeychainServiceError.keyNotFound(item)
keychainService.searchResult = .success(nil)
await assertAsyncThrows(error: notFoundError) {
_ = try await subject.getUserAuthKeyValue(for: .biometrics(userId: "123"))
}
}
/// `getUserAuthKeyValue(_:)` returns a string on success.
///
func test_getUserAuthKeyValue_error_success() async throws {
let item = KeychainItem.biometrics(userId: "123")
let expectedKey = "1234"
let results = [
kSecValueData: Data("1234".utf8),
] as CFDictionary
keychainService.searchResult = .success(results)
let key = try await subject.getUserAuthKeyValue(for: item)
XCTAssertEqual(key, expectedKey)
}
/// The service should generate keychain Query Key/Values` KeychainItem`.
///
func test_keychainQueryValues_biometrics() async {
let item = KeychainItem.biometrics(userId: "123")
appSettingsStore.appId = "testAppId"
let formattedKey = await subject.formattedKey(for: item)
let queryValues = await subject.keychainQueryValues(for: item)
let expectedResult = [
kSecAttrAccount: formattedKey,
kSecAttrAccessGroup: subject.appSecAttrAccessGroup,
kSecAttrService: subject.appSecAttrService,
kSecClass: kSecClassGenericPassword,
] as CFDictionary
XCTAssertEqual(
queryValues,
expectedResult
)
}
/// The service should generate keychain Query Key/Values` KeychainItem`.
///
func test_keychainQueryValues_neverLock() async {
let item = KeychainItem.neverLock(userId: "123")
appSettingsStore.appId = "testAppId"
let formattedKey = await subject.formattedKey(for: item)
let queryValues = await subject.keychainQueryValues(for: item)
let expectedResult = [
kSecAttrAccount: formattedKey,
kSecAttrAccessGroup: subject.appSecAttrAccessGroup,
kSecAttrService: subject.appSecAttrService,
kSecClass: kSecClassGenericPassword,
] as CFDictionary
XCTAssertEqual(
queryValues,
expectedResult
)
}
/// `setUserAuthKey(_:)` failures rethrow.
///
func test_setUserAuthKey_error_accessControl() async {
let newKey = "123"
let item = KeychainItem.biometrics(userId: "123")
let accessError = KeychainServiceError.accessControlFailed(nil)
keychainService.accessControlResult = .failure(accessError)
keychainService.addResult = .success(())
await assertAsyncThrows(error: accessError) {
_ = try await subject.setUserAuthKey(for: item, value: newKey)
}
}
/// `setUserAuthKey(_:)` failures rethrow.
///
func test_setUserAuthKey_error_onSet() async {
let newKey = "123"
let item = KeychainItem.biometrics(userId: "123")
let addError = KeychainServiceError.osStatusError(-1)
keychainService.accessControlResult = .success(
SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
item.protection ?? [],
nil
)!
)
keychainService.addResult = .failure(addError)
await assertAsyncThrows(error: addError) {
_ = try await subject.setUserAuthKey(for: .biometrics(userId: "123"), value: newKey)
}
}
/// `setUserAuthKey(_:)` succeeds quietly.
///
func test_setUserAuthKey_success_biometrics() async throws {
let newKey = "123"
let item = KeychainItem.biometrics(userId: "123")
keychainService.accessControlResult = .success(
SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
item.protection ?? [],
nil
)!
)
keychainService.addResult = .success(())
try await subject.setUserAuthKey(for: item, value: newKey)
XCTAssertEqual(keychainService.accessControlFlags, .biometryCurrentSet)
}
/// `setUserAuthKey(_:)` succeeds quietly.
///
func test_setUserAuthKey_success_neverlock() async throws {
let newKey = "123"
let item = KeychainItem.neverLock(userId: "123")
keychainService.accessControlResult = .success(
SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
item.protection ?? [],
nil
)!
)
keychainService.addResult = .success(())
try await subject.setUserAuthKey(for: item, value: newKey)
XCTAssertEqual(keychainService.accessControlFlags, [])
}
}

View File

@ -0,0 +1,112 @@
import Foundation
// MARK: - KeychainService
/// A Service to provide a wrapper around the device Keychain.
///
protocol KeychainService: AnyObject {
/// Creates an access control for a given set of flags.
///
/// - Parameter flags: The `SecAccessControlCreateFlags` for the access control.
/// - Returns: The SecAccessControl.
///
func accessControl(
for flags: SecAccessControlCreateFlags
) throws -> SecAccessControl
/// Adds a set of attributes.
///
/// - Parameter attributes: Attributes to add.
///
func add(attributes: CFDictionary) throws
/// Attempts a deletion based on a query.
///
/// - Parameter query: Query for the delete.
///
func delete(query: CFDictionary) throws
/// Searches for a query.
///
/// - Parameter query: Query for the delete.
/// - Returns: The search results.
///
func search(query: CFDictionary) throws -> AnyObject?
}
// MARK: - KeychainServiceError
enum KeychainServiceError: Error, Equatable {
/// When creating an accessControl fails.
///
/// - Parameter CFError: The potential system error.
///
case accessControlFailed(CFError?)
/// When a `KeychainService` is unable to locate an auth key for a given storage key.
///
/// - Parameter KeychainItem: The potential storage key for the auth key.
///
case keyNotFound(KeychainItem)
/// A passthrough for OSService Error cases.
///
/// - Parameter OSStatus: The `OSStatus` returned from a keychain operation.
///
case osStatusError(OSStatus)
}
// MARK: - DefaultKeychainService
class DefaultKeychainService: KeychainService {
// MARK: Methods
func accessControl(
for flags: SecAccessControlCreateFlags
) throws -> SecAccessControl {
var error: Unmanaged<CFError>?
let accessControl = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
flags,
&error
)
guard let accessControl,
error == nil
else {
throw KeychainServiceError.accessControlFailed(error?.takeUnretainedValue())
}
return accessControl
}
func add(attributes: CFDictionary) throws {
try resolve(SecItemAdd(attributes, nil))
}
func delete(query: CFDictionary) throws {
try resolve(SecItemDelete(query))
}
func search(query: CFDictionary) throws -> AnyObject? {
var foundItem: AnyObject?
try resolve(SecItemCopyMatching(query, &foundItem))
return foundItem
}
// MARK: Private Methods
/// Ensures that a given status is a success.
/// Throws if not `errSecSuccess`.
///
/// - Parameter status: The OSStatus to check.
///
private func resolve(_ status: OSStatus) throws {
switch status {
case errSecSuccess:
break
default:
throw KeychainServiceError.osStatusError(status)
}
}
}

View File

@ -1,9 +1,8 @@
@testable import BitwardenShared
class MockBiometricsService: BiometricsService {
class MockBiometricsRepository: BiometricsRepository {
var biometricUnlockStatus: Result<BiometricsUnlockStatus, Error> = .success(.notAvailable)
var capturedUserAuthKey: String?
var capturedUserID: String?
var didConfigureBiometricIntegrity = false
var didDeleteKey: Bool = false
var getUserAuthKeyResult: Result<String, Error> = .success("UserAuthKey")
@ -17,14 +16,12 @@ class MockBiometricsService: BiometricsService {
try biometricUnlockStatus.get()
}
func getUserAuthKey(for userId: String?) async throws -> String {
capturedUserID = userId
return try getUserAuthKeyResult.get()
func getUserAuthKey() async throws -> String {
try getUserAuthKeyResult.get()
}
func setBiometricUnlockKey(authKey: String?, for userId: String?) async throws {
func setBiometricUnlockKey(authKey: String?) async throws {
capturedUserAuthKey = authKey
capturedUserID = userId
if let setBiometricUnlockKeyError {
throw setBiometricUnlockKeyError
}

View File

@ -0,0 +1,29 @@
import LocalAuthentication
@testable import BitwardenShared
class MockBiometricsService: BiometricsService {
var biometricAuthenticationType: BiometricAuthenticationType?
var biometricAuthStatus: BiometricAuthorizationStatus = .notDetermined
var biometricIntegrityState: Data?
var evaluationResult: Bool = true
func evaluateBiometricPolicy(
_ suppliedContext: LAContext?,
for biometricAuthStatus: BitwardenShared.BiometricAuthorizationStatus
) async -> Bool {
evaluationResult
}
func getBiometricAuthenticationType(_ suppliedContext: LAContext?) -> BiometricAuthenticationType? {
biometricAuthenticationType
}
func getBiometricAuthStatus() -> BiometricAuthorizationStatus {
biometricAuthStatus
}
func getBiometricIntegrityState() -> Data? {
biometricIntegrityState
}
}

View File

@ -0,0 +1,42 @@
import Foundation
@testable import BitwardenShared
class MockKeychainRepository: KeychainRepository {
var appId: String = "mockAppId"
var mockStorage = [String: String]()
var securityType: SecAccessControlCreateFlags?
var deleteResult: Result<Void, Error> = .success(())
var getResult: Result<String, Error>?
var setResult: Result<Void, Error> = .success(())
func deleteUserAuthKey(for item: KeychainItem) async throws {
try deleteResult.get()
let formattedKey = formattedKey(for: item)
mockStorage = mockStorage.filter { $0.key != formattedKey }
}
func getUserAuthKeyValue(for item: KeychainItem) async throws -> String {
let formattedKey = formattedKey(for: item)
if let result = getResult {
let value = try result.get()
mockStorage[formattedKey] = value
return value
} else if let value = mockStorage[formattedKey] {
return value
} else {
throw KeychainServiceError.keyNotFound(item)
}
}
func formattedKey(for item: KeychainItem) -> String {
String(format: storageKeyFormat, appId, item.unformattedKey)
}
func setUserAuthKey(for item: KeychainItem, value: String) async throws {
let formattedKey = formattedKey(for: item)
securityType = item.protection
try setResult.get()
mockStorage[formattedKey] = value
}
}

View File

@ -0,0 +1,40 @@
import Foundation
@testable import BitwardenShared
class MockKeychainService {
// MARK: Properties
var accessControlFlags: SecAccessControlCreateFlags?
var accessControlResult: Result<SecAccessControl, KeychainServiceError> = .failure(.accessControlFailed(nil))
var addAttributes: CFDictionary?
var addResult: Result<Void, KeychainServiceError> = .success(())
var deleteQuery: CFDictionary?
var deleteResult: Result<Void, KeychainServiceError> = .success(())
var searchQuery: CFDictionary?
var searchResult: Result<AnyObject?, KeychainServiceError> = .success(nil)
}
// MARK: KeychainService
extension MockKeychainService: KeychainService {
func accessControl(for flags: SecAccessControlCreateFlags) throws -> SecAccessControl {
accessControlFlags = flags
return try accessControlResult.get()
}
func add(attributes: CFDictionary) throws {
addAttributes = attributes
try addResult.get()
}
func delete(query: CFDictionary) throws {
deleteQuery = query
try deleteResult.get()
}
func search(query: CFDictionary) throws -> AnyObject? {
searchQuery = query
return try searchResult.get()
}
}

View File

@ -21,8 +21,15 @@ extension Bundle {
infoDictionary?["CFBundleVersion"] as? String ?? ""
}
/// Return's the app's app identifier.
var appIdentifier: String {
infoDictionary?["BitwardenAppIdentifier"] as? String
?? bundleIdentifier
?? "com.x8bit.bitwarden"
}
/// Return's the app's app group identifier.
var groupIdentifier: String {
infoDictionary?["BitwardenAppGroupIdentifier"] as? String ?? "group.\(bundleIdentifier!)"
"group." + appIdentifier
}
}

View File

@ -1,6 +1,8 @@
import BitwardenSdk
import UIKit
// swiftlint:disable file_length
/// The `ServiceContainer` contains the list of services used by the app. This can be injected into
/// `Coordinator`s throughout the app which build processors. A `Processor` can define which
/// services it needs access to by defining a typealias containing a list of services.
@ -30,7 +32,10 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
/// The service used by the application to handle authentication tasks.
let authService: AuthService
/// The service used to obtain the available authentication policies and access controls for the user's device.
/// The repository to manage bioemtric unlock policies and access controls the user.
let biometricsRepository: BiometricsRepository
/// The service used to obtain device biometrics status & data.
let biometricsService: BiometricsService
/// The service used by the application to generate captcha related artifacts.
@ -51,6 +56,12 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
/// The repository used by the application to manage generator data for the UI layer.
let generatorRepository: GeneratorRepository
/// The service used to access & store data on the device keychain.
let keychainService: KeychainService
/// The repository used to manage keychain items.
let keychainRepository: KeychainRepository
/// The service used by the application to access the system's notification center.
let notificationCenterService: NotificationCenterService
@ -109,15 +120,18 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
/// - appSettingsStore: The service used by the application to persist app setting values.
/// - authRepository: The repository used by the application to manage auth data for the UI layer.
/// - authService: The service used by the application to handle authentication tasks.
/// - biometricsService: The service used to obtain the available authentication policies
/// and access controls for the user's device.
/// - biometricsRepository: The repository to manage bioemtric unlock policies
/// and access controls for the user.
/// - biometricsService: The service used to obtain device biometrics status & data.
/// - captchaService: The service used by the application to create captcha related artifacts.
/// - cameraService: The service used by the application to manage camera use.
/// - clientService: The service used by the application to handle encryption and decryption tasks.
/// - environmentService: The service used by the application to manage the environment settings.
/// - errorReporter: The service used by the application to report non-fatal errors.
/// - generatorRepository: The repository used by the application to manage generator data for the UI layer.
/// - notificationCenterService: The service used by the application to access the system's notification center.
/// - keychainRepository: The repository used to manages keychain items.
/// - keychainService: The service used to access & store data on the device keychain.
/// - notificaitonCenterService: The service used by the application to access the system's notification center.
/// - notificationService: The service used by the application to handle notifications.
/// - pasteboardService: The service used by the application for sharing data with other apps.
/// - policyService: The service for managing the polices for the user.
@ -140,6 +154,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
appSettingsStore: AppSettingsStore,
authRepository: AuthRepository,
authService: AuthService,
biometricsRepository: BiometricsRepository,
biometricsService: BiometricsService,
captchaService: CaptchaService,
cameraService: CameraService,
@ -147,6 +162,8 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
environmentService: EnvironmentService,
errorReporter: ErrorReporter,
generatorRepository: GeneratorRepository,
keychainRepository: KeychainRepository,
keychainService: KeychainService,
notificationCenterService: NotificationCenterService,
notificationService: NotificationService,
pasteboardService: PasteboardService,
@ -169,6 +186,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
self.appSettingsStore = appSettingsStore
self.authRepository = authRepository
self.authService = authService
self.biometricsRepository = biometricsRepository
self.biometricsService = biometricsService
self.captchaService = captchaService
self.cameraService = cameraService
@ -176,6 +194,8 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
self.environmentService = environmentService
self.errorReporter = errorReporter
self.generatorRepository = generatorRepository
self.keychainService = keychainService
self.keychainRepository = keychainRepository
self.notificationCenterService = notificationCenterService
self.notificationService = notificationService
self.pasteboardService = pasteboardService
@ -207,11 +227,23 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
let clientService = DefaultClientService()
let dataStore = DataStore(errorReporter: errorReporter)
let keychainService = DefaultKeychainService()
let keychainRepository = DefaultKeychainRepository(
appIdService: appIdService,
keychainService: keychainService
)
let timeProvider = CurrentTime()
let stateService = DefaultStateService(appSettingsStore: appSettingsStore, dataStore: dataStore)
let biometricsService = DefaultBiometricsService(stateService: stateService)
let biometricsService = DefaultBiometricsService()
let biometricsRepository = DefaultBiometricsRepository(
biometricsService: biometricsService,
keychainService: keychainRepository,
stateService: stateService
)
let environmentService = DefaultEnvironmentService(stateService: stateService)
let collectionService = DefaultCollectionService(collectionDataStore: dataStore, stateService: stateService)
let settingsService = DefaultSettingsService(settingsDataStore: dataStore, stateService: stateService)
@ -277,7 +309,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
let totpService = DefaultTOTPService()
let twoStepLoginService = DefaultTwoStepLoginService(environmentService: environmentService)
let vaultTimeoutService = DefaultVaultTimeoutService(stateService: stateService)
let vaultTimeoutService = DefaultVaultTimeoutService(stateService: stateService, timeProvider: timeProvider)
let pasteboardService = DefaultPasteboardService(
errorReporter: errorReporter,
@ -308,11 +340,12 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
let authRepository = DefaultAuthRepository(
accountAPIService: apiService,
authService: authService,
biometricsService: biometricsService,
biometricsRepository: biometricsRepository,
clientAuth: clientService.clientAuth(),
clientCrypto: clientService.clientCrypto(),
clientPlatform: clientService.clientPlatform(),
environmentService: environmentService,
keychainService: keychainRepository,
organizationService: organizationService,
stateService: stateService,
vaultTimeoutService: vaultTimeoutService
@ -368,6 +401,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
appSettingsStore: appSettingsStore,
authRepository: authRepository,
authService: authService,
biometricsRepository: biometricsRepository,
biometricsService: biometricsService,
captchaService: captchaService,
cameraService: DefaultCameraService(),
@ -375,6 +409,8 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
environmentService: environmentService,
errorReporter: errorReporter,
generatorRepository: generatorRepository,
keychainRepository: keychainRepository,
keychainService: keychainService,
notificationCenterService: notificationCenterService,
notificationService: notificationService,
pasteboardService: pasteboardService,
@ -423,4 +459,4 @@ extension ServiceContainer {
var clientPlatform: ClientPlatformProtocol {
clientService.clientPlatform()
}
} // swiftlint:disable:this file_length
}

View File

@ -8,7 +8,7 @@ typealias Services = HasAPIService
& HasAuthAPIService
& HasAuthRepository
& HasAuthService
& HasBiometricsService
& HasBiometricsRepository
& HasCameraService
& HasCaptchaService
& HasClientAuth
@ -83,9 +83,9 @@ protocol HasAuthService {
/// Protocol for obtaining the device's biometric authentication type.
///
protocol HasBiometricsService {
/// The service used to obtain the available authentication policies and access controls for the user's device.
var biometricsService: BiometricsService { get }
protocol HasBiometricsRepository {
/// The repository used to obtain the available authentication policies and access controls for the user's device.
var biometricsRepository: BiometricsRepository { get }
}
/// Protocol for an object that provides a `CameraService`.

View File

@ -29,6 +29,13 @@ protocol StateService: AnyObject {
///
func deleteAccount() async throws
/// Gets the account for an id.
///
/// - Parameter userId: The id for an account. If nil, the active account will be returned.
/// - Returns: The account for the id.
///
func getAccount(userId: String?) async throws -> Account
/// Gets the account encryptions keys for an account.
///
/// - Parameter userId: The user ID of the account. Defaults to the active account if `nil`.
@ -513,6 +520,37 @@ extension StateService {
try await getAccountEncryptionKeys(userId: nil)
}
/// Gets either a valid account id or the active account id.
///
/// - Parameter userId: The possible user id.
/// If `nil`, this method will attempt to return the active account id.
/// If non-nil, this method will validate the user id.
/// - Returns: A valid user id.
///
func getAccountIdOrActiveId(userId: String?) async throws -> String {
try await getAccount(userId: userId).profile.userId
}
/// Gets the active account id.
///
/// - Returns: The active user id.
///
func getActiveAccountId() async throws -> String {
try await getActiveAccount().profile.userId
}
/// Gets the active account.
///
/// - Returns: The active user account.
///
func getActiveAccount() async throws -> Account {
do {
return try await getAccount(userId: nil)
} catch {
throw StateServiceError.noActiveAccount
}
}
/// Gets the allow sync on refresh value for the active account.
///
/// - Returns: The allow sync on refresh value.
@ -906,6 +944,18 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le
try await logoutAccount()
}
func getAccount(userId: String?) throws -> Account {
guard let accounts = appSettingsStore.state?.accounts else {
throw StateServiceError.noAccounts
}
let userId = try userId ?? getActiveAccountUserId()
guard let account = accounts
.first(where: { $0.value.profile.userId == userId })?.value else {
throw StateServiceError.noAccounts
}
return account
}
func getAccountEncryptionKeys(userId: String?) async throws -> AccountEncryptionKeys {
let userId = try userId ?? getActiveAccountUserId()
guard let encryptedPrivateKey = appSettingsStore.encryptedPrivateKey(userId: userId),
@ -919,23 +969,6 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le
)
}
func getAccountIdOrActiveId(userId: String?) throws -> String {
guard let accounts = appSettingsStore.state?.accounts else {
throw StateServiceError.noAccounts
}
if let userId {
guard accounts.contains(where: { $0.value.profile.userId == userId }) else {
throw StateServiceError.noAccounts
}
return userId
}
return try getActiveAccountId()
}
func getActiveAccountId() throws -> String {
try getActiveAccount().profile.userId
}
func getAccounts() throws -> [Account] {
guard let accounts = appSettingsStore.state?.accounts else {
throw StateServiceError.noAccounts
@ -1053,7 +1086,7 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le
}
func getVaultTimeout(userId: String?) async throws -> SessionTimeoutValue {
let userId = try userId ?? getActiveAccountId()
let userId = try getAccount(userId: userId).profile.userId
guard let rawValue = appSettingsStore.vaultTimeout(userId: userId) else {
return .fifteenMinutes
}

View File

@ -184,8 +184,8 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
XCTAssertEqual(activeAccount, account)
}
/// `getActiveAccount()` throws an error if there aren't any accounts.
func test_getActiveAccount_noAccounts() async throws {
/// `getActiveAccount()` throws an error if there aren't isn't an active account.
func test_getActiveAccount_noAccount() async throws {
await assertAsyncThrows(error: StateServiceError.noActiveAccount) {
_ = try await subject.getActiveAccount()
}

View File

@ -404,6 +404,11 @@ class DefaultAppSettingsStore {
/// A subject containing a `String?` for the userId of the active account.
lazy var activeAccountIdSubject = CurrentValueSubject<String?, Never>(state?.activeUserId)
/// The bundleId used to set values that are bundleId dependent.
var bundleId: String {
Bundle.main.bundleIdentifier ?? Bundle.main.appIdentifier
}
// MARK: Initialization
/// Initialize a `DefaultAppSettingsStore`.
@ -508,7 +513,7 @@ extension DefaultAppSettingsStore: AppSettingsStore {
case approveLoginRequests(userId: String)
case appTheme
case biometricAuthEnabled(userId: String)
case biometricIntegrityState(userId: String)
case biometricIntegrityState(userId: String, bundleId: String)
case clearClipboardValue(userId: String)
case connectToWatch(userId: String)
case defaultUriMatch(userId: String)
@ -553,8 +558,8 @@ extension DefaultAppSettingsStore: AppSettingsStore {
key = "theme"
case let .biometricAuthEnabled(userId):
key = "biometricUnlock_\(userId)"
case let .biometricIntegrityState(userId):
key = "biometricIntegritySource_\(userId)"
case let .biometricIntegrityState(userId, bundleId):
key = "biometricIntegritySource_\(userId)_\(bundleId)"
case let .clearClipboardValue(userId):
key = "clearClipboard_\(userId)"
case let .connectToWatch(userId):
@ -677,7 +682,12 @@ extension DefaultAppSettingsStore: AppSettingsStore {
}
func biometricIntegrityState(userId: String) -> String? {
fetch(for: .biometricIntegrityState(userId: userId))
fetch(
for: .biometricIntegrityState(
userId: userId,
bundleId: bundleId
)
)
}
func clearClipboardValue(userId: String) -> ClearClipboardValue {
@ -753,7 +763,13 @@ extension DefaultAppSettingsStore: AppSettingsStore {
}
func setBiometricIntegrityState(_ base64EncodedIntegrityState: String?, userId: String) {
store(base64EncodedIntegrityState, for: .biometricIntegrityState(userId: userId))
store(
base64EncodedIntegrityState,
for: .biometricIntegrityState(
userId: userId,
bundleId: bundleId
)
)
}
func setClearClipboardValue(_ clearClipboardValue: ClearClipboardValue?, userId: String) {

View File

@ -64,7 +64,7 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
}
func clearPins() async throws {
let userId = try getActiveAccount().profile.userId
let userId = try unwrapUserId(nil)
accountVolatileData.removeValue(forKey: userId)
pinProtectedUserKeyValue[userId] = nil
pinKeyEncryptedUserKeyValue[userId] = nil
@ -85,40 +85,44 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
if let error = getAccountEncryptionKeysError {
throw error
}
let userId = try userId ?? getActiveAccount().profile.userId
guard let encryptionKeys = accountEncryptionKeys[userId]
let id = try await getAccountIdOrActiveId(userId: userId)
guard let encryptionKeys = accountEncryptionKeys[id]
else {
throw StateServiceError.noActiveAccount
}
return encryptionKeys
}
func getAccount(userId: String?) async throws -> BitwardenShared.Account {
let id = try await getAccountIdOrActiveId(userId: userId)
if let activeAccount,
activeAccount.profile.userId == id {
return activeAccount
}
guard let knownAccounts = accounts,
let match = knownAccounts.first(where: { $0.profile.userId == id }) else {
throw StateServiceError.noAccounts
}
return match
}
func getAccounts() async throws -> [Account] {
guard let accounts else { throw StateServiceError.noAccounts }
guard let accounts else {
throw StateServiceError.noAccounts
}
return accounts
}
func getActiveAccount() throws -> Account {
guard let activeAccount else { throw StateServiceError.noActiveAccount }
return activeAccount
}
func getAccountIdOrActiveId(userId: String?) async throws -> String {
guard let knownAccounts = accounts else {
throw StateServiceError.noAccounts
}
if let userId {
guard knownAccounts.contains(where: { $0.profile.userId == userId }) else {
throw StateServiceError.noAccounts
}
return userId
} else {
return try await getActiveAccountId()
}
return try await getActiveAccountId()
}
func getActiveAccountId() async throws -> String {
try getActiveAccount().profile.userId
guard let activeAccount else { throw StateServiceError.noActiveAccount }
return activeAccount.profile.userId
}
func getAddSitePromptShown() async -> Bool {
@ -126,7 +130,7 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
}
func getApproveLoginRequests(userId: String?) async throws -> Bool {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
return approveLoginRequestsByUserId[userId] ?? false
}
@ -135,39 +139,39 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
}
func getAllowSyncOnRefresh(userId: String?) async throws -> Bool {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
return allowSyncOnRefresh[userId] ?? false
}
func getClearClipboardValue(userId: String?) async throws -> ClearClipboardValue {
try clearClipboardResult.get()
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
return clearClipboardValues[userId] ?? .never
}
func getConnectToWatch(userId: String?) async throws -> Bool {
try connectToWatchResult.get()
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
return connectToWatchByUserId[userId] ?? false
}
func getDefaultUriMatchType(userId: String?) async throws -> UriMatchType {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
return defaultUriMatchTypeByUserId[userId] ?? .domain
}
func getDisableAutoTotpCopy(userId: String?) async throws -> Bool {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
return disableAutoTotpCopyByUserId[userId] ?? false
}
func getEnvironmentUrls(userId: String?) async throws -> EnvironmentUrlData? {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
return environmentUrls[userId]
}
func getLastActiveTime(userId: String?) async throws -> Date? {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
return lastActiveTime[userId]
}
@ -176,17 +180,17 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
}
func getMasterPasswordHash(userId: String?) async throws -> String? {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
return masterPasswordHashes[userId]
}
func getNotificationsLastRegistrationDate(userId: String?) async throws -> Date? {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
return notificationsLastRegistrationDates[userId]
}
func getPasswordGenerationOptions(userId: String?) async throws -> PasswordGenerationOptions? {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
return passwordGenerationOptions[userId]
}
@ -199,7 +203,7 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
}
func getTimeoutAction(userId: String?) async throws -> SessionTimeoutAction {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
return timeoutAction[userId] ?? .lock
}
@ -208,37 +212,37 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
}
func getUnsuccessfulUnlockAttempts(userId: String?) async throws -> Int {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
return unsuccessfulUnlockAttempts[userId] ?? 0
}
func getUsernameGenerationOptions(userId: String?) async throws -> UsernameGenerationOptions? {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
return usernameGenerationOptions[userId]
}
func getVaultTimeout(userId: String?) async throws -> SessionTimeoutValue {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
return vaultTimeout[userId] ?? .immediately
}
func logoutAccount(userId: String?) async throws {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
accountsLoggedOut.append(userId)
}
func pinKeyEncryptedUserKey(userId: String?) async throws -> String? {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
return pinKeyEncryptedUserKeyValue[userId] ?? nil
}
func pinProtectedUserKey(userId: String?) async throws -> String? {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
return pinProtectedUserKeyValue[userId] ?? nil
}
func setAccountEncryptionKeys(_ encryptionKeys: AccountEncryptionKeys, userId: String?) async throws {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
accountEncryptionKeys[userId] = encryptionKeys
}
@ -246,7 +250,9 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
guard let accounts,
let match = accounts.first(where: { account in
account.profile.userId == userId
}) else { throw StateServiceError.noAccounts }
}) else {
throw StateServiceError.noAccounts
}
activeAccount = match
}
@ -255,12 +261,12 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
}
func setAllowSyncOnRefresh(_ allowSyncOnRefresh: Bool, userId: String?) async throws {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
self.allowSyncOnRefresh[userId] = allowSyncOnRefresh
}
func setApproveLoginRequests(_ approveLoginRequests: Bool, userId: String?) async throws {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
approveLoginRequestsByUserId[userId] = approveLoginRequests
}
@ -270,33 +276,33 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
func setClearClipboardValue(_ clearClipboardValue: ClearClipboardValue?, userId: String?) async throws {
try clearClipboardResult.get()
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
clearClipboardValues[userId] = clearClipboardValue
}
func setConnectToWatch(_ connectToWatch: Bool, userId: String?) async throws {
try connectToWatchResult.get()
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
connectToWatchByUserId[userId] = connectToWatch
}
func setDefaultUriMatchType(_ defaultUriMatchType: UriMatchType?, userId: String?) async throws {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
defaultUriMatchTypeByUserId[userId] = defaultUriMatchType
}
func setDisableAutoTotpCopy(_ disableAutoTotpCopy: Bool, userId: String?) async throws {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
disableAutoTotpCopyByUserId[userId] = disableAutoTotpCopy
}
func setEncryptedPin(_ pin: String) async throws {
let userId = try getActiveAccount().profile.userId
let userId = try unwrapUserId(nil)
accountVolatileData[userId, default: AccountVolatileData()].pinProtectedUserKey = pin
}
func setEnvironmentUrls(_ environmentUrls: EnvironmentUrlData, userId: String?) async throws {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
self.environmentUrls[userId] = environmentUrls
}
@ -306,12 +312,12 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
}
func setLastActiveTime(_ date: Date?, userId: String?) async throws {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
lastActiveTime[userId] = timeProvider.presentTime
}
func setLastSyncTime(_ date: Date?, userId: String?) async throws {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
lastSyncTimeByUserId[userId] = date
}
@ -324,17 +330,17 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
}
func setMasterPasswordHash(_ hash: String?, userId: String?) async throws {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
masterPasswordHashes[userId] = hash
}
func setNotificationsLastRegistrationDate(_ date: Date?, userId: String?) async throws {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
notificationsLastRegistrationDates[userId] = date
}
func setPasswordGenerationOptions(_ options: PasswordGenerationOptions?, userId: String?) async throws {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
passwordGenerationOptions[userId] = options
}
@ -343,7 +349,7 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
pinProtectedUserKey: String,
requirePasswordAfterRestart: Bool
) async throws {
let userId = try getActiveAccount().profile.userId
let userId = try unwrapUserId(nil)
pinProtectedUserKeyValue[userId] = pinProtectedUserKey
pinKeyEncryptedUserKeyValue[userId] = pinKeyEncryptedUserKey
@ -356,7 +362,7 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
}
func setPinProtectedUserKeyToMemory(_ pin: String) async throws {
let userId = try getActiveAccount().profile.userId
let userId = try unwrapUserId(nil)
accountVolatileData[
userId,
default: AccountVolatileData()
@ -372,7 +378,7 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
}
func setTimeoutAction(action: SessionTimeoutAction, userId: String?) async throws {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
timeoutAction[userId] = action
}
@ -385,20 +391,54 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
}
func setUnsuccessfulUnlockAttempts(_ attempts: Int, userId: String?) async throws {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
unsuccessfulUnlockAttempts[userId] = attempts
}
func setUsernameGenerationOptions(_ options: UsernameGenerationOptions?, userId: String?) async throws {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
usernameGenerationOptions[userId] = options
}
func setVaultTimeout(value: SessionTimeoutValue, userId: String?) async throws {
let userId = try userId ?? getActiveAccount().profile.userId
let userId = try unwrapUserId(userId)
vaultTimeout[userId] = value
}
/// Attempts to convert a possible user id into an account, or returns the active account.
///
/// - Parameter userId: If nil, the active account is returned. Otherwise, retrieve an account for the id.
///
func unwrapAccount(_ userId: String?) throws -> Account {
if let userId,
let activeAccount,
activeAccount.profile.userId == userId {
return activeAccount
} else if let userId,
let match = accounts?.first(where: { userId == $0.profile.userId }) {
return match
} else if let activeAccount,
userId == nil {
return activeAccount
} else {
throw StateServiceError.noAccounts
}
}
/// Attempts to convert a possible user id into a known account id.
///
/// - Parameter userId: If nil, the active account id is returned. Otherwise, validate the id.
///
func unwrapUserId(_ userId: String?) throws -> String {
if let userId {
return userId
} else if let activeAccount {
return activeAccount.profile.userId
} else {
throw StateServiceError.noActiveAccount
}
}
func activeAccountIdPublisher() async -> AnyPublisher<String?, Never> {
activeIdSubject.eraseToAnyPublisher()
}

View File

@ -8,6 +8,7 @@ extension ServiceContainer {
appSettingsStore: AppSettingsStore = MockAppSettingsStore(),
authRepository: AuthRepository = MockAuthRepository(),
authService: AuthService = MockAuthService(),
biometricsRepository: BiometricsRepository = MockBiometricsRepository(),
biometricsService: BiometricsService = MockBiometricsService(),
captchaService: CaptchaService = MockCaptchaService(),
cameraService: CameraService = MockCameraService(),
@ -16,6 +17,8 @@ extension ServiceContainer {
errorReporter: ErrorReporter = MockErrorReporter(),
generatorRepository: GeneratorRepository = MockGeneratorRepository(),
httpClient: HTTPClient = MockHTTPClient(),
keychainRepository: KeychainRepository = MockKeychainRepository(),
keychainService: KeychainService = MockKeychainService(),
notificationService: NotificationService = MockNotificationService(),
pasteboardService: PasteboardService = MockPasteboardService(),
policyService: PolicyService = MockPolicyService(),
@ -42,6 +45,7 @@ extension ServiceContainer {
appSettingsStore: appSettingsStore,
authRepository: authRepository,
authService: authService,
biometricsRepository: biometricsRepository,
biometricsService: biometricsService,
captchaService: captchaService,
cameraService: cameraService,
@ -49,6 +53,8 @@ extension ServiceContainer {
environmentService: environmentService,
errorReporter: errorReporter,
generatorRepository: generatorRepository,
keychainRepository: keychainRepository,
keychainService: keychainService,
notificationCenterService: notificationCenterService,
notificationService: notificationService,
pasteboardService: pasteboardService,

View File

@ -4,7 +4,7 @@ import XCTest
import BitwardenSdk
class SyncServiceTests: BitwardenTestCase {
class SyncServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_length
// MARK: Properties
var cipherService: MockCipherService!

View File

@ -8,6 +8,7 @@ class MockVaultTimeoutService: VaultTimeoutService {
var lastActiveTime = [String: Date]()
var shouldSessionTimeout = [String: Bool]()
var timeProvider = MockTimeProvider(.currentTime)
var sessionTimeoutValueError: Error?
var vaultTimeout = [String: SessionTimeoutValue]()
/// ids set as locked
@ -22,9 +23,10 @@ class MockVaultTimeoutService: VaultTimeoutService {
/// The store of locked status for known accounts
var timeoutStore = [String: Bool]()
func isLocked(userId: String) throws -> Bool {
func isLocked(userId: String) -> Bool {
guard let pair = timeoutStore.first(where: { $0.key == userId }) else {
throw VaultTimeoutServiceError.noAccountFound
timeoutStore[userId] = true
return true
}
return pair.value
}
@ -43,7 +45,7 @@ class MockVaultTimeoutService: VaultTimeoutService {
vaultTimeout[account.profile.userId] = value
}
func shouldSessionTimeout(userId: String) async throws -> Bool {
func hasPassedSessionTimeout(userId: String) async throws -> Bool {
shouldSessionTimeout[userId] ?? false
}
@ -58,4 +60,11 @@ class MockVaultTimeoutService: VaultTimeoutService {
guard let userId else { return }
timeoutStore = timeoutStore.filter { $0.key != userId }
}
func sessionTimeoutValue(userId: String?) async throws -> BitwardenShared.SessionTimeoutValue {
if let sessionTimeoutValueError {
throw sessionTimeoutValueError
}
return vaultTimeout[userId ?? account.profile.userId] ?? .fifteenMinutes
}
}

View File

@ -17,11 +17,17 @@ enum VaultTimeoutServiceError: Error {
protocol VaultTimeoutService: AnyObject {
// MARK: Methods
/// Whether a session timeout should occur.
///
/// - Returns: Whether a session timeout should occur.
///
func hasPassedSessionTimeout(userId: String) async throws -> Bool
/// Checks the locked status of a user vault by user id
/// - Parameter userId: The userId of the account
/// - Returns: A bool, true if locked, false if unlocked.
///
func isLocked(userId: String) throws -> Bool
func isLocked(userId: String) -> Bool
/// Locks the user's vault
///
@ -50,18 +56,19 @@ protocol VaultTimeoutService: AnyObject {
///
func setVaultTimeout(value: SessionTimeoutValue, userId: String?) async throws
/// Whether a session timeout should occur.
///
/// - Returns: Whether a session timeout should occur.
///
func shouldSessionTimeout(userId: String) async throws -> Bool
/// Unlocks the user's vault
///
/// - Parameter userId: The userId of the account to unlock.
/// Defaults to the active account if nil
///
func unlockVault(userId: String?) async
/// Gets the `SessionTimeoutValue` for a user.
///
/// - Parameter userId: The userId of the account.
/// Defaults to the active user if nil.
///
func sessionTimeoutValue(userId: String?) async throws -> SessionTimeoutValue
}
// MARK: - DefaultVaultTimeoutService
@ -75,6 +82,9 @@ class DefaultVaultTimeoutService: VaultTimeoutService {
/// The state service used by this Default Service.
private var stateService: StateService
/// Provides the current time.
private var timeProvider: TimeProvider
/// The store of locked status for known accounts
var timeoutStore = [String: Bool]()
@ -85,17 +95,38 @@ class DefaultVaultTimeoutService: VaultTimeoutService {
/// Creates a new `DefaultVaultTimeoutService`.
///
/// - Parameter stateService: The StateService used by DefaultVaultTimeoutService.
/// - Parameters:
/// - stateService: The StateService used by DefaultVaultTimeoutService.
/// - timeProvider: Provides the current time.
///
init(stateService: StateService) {
init(stateService: StateService, timeProvider: TimeProvider) {
self.stateService = stateService
self.timeProvider = timeProvider
}
// MARK: Methods
func isLocked(userId: String) throws -> Bool {
func hasPassedSessionTimeout(userId: String) async throws -> Bool {
guard let lastActiveTime = try await stateService.getLastActiveTime(userId: userId) else { return true }
let vaultTimeout = try await sessionTimeoutValue(userId: userId)
switch vaultTimeout {
case .never,
.onAppRestart:
// For timeouts of `.never` or `.onAppRestart`, timeouts cannot be calculated.
// In these cases, return false.
return false
default:
// Otherwise, calculate a timeout.
return timeProvider.presentTime.timeIntervalSince(lastActiveTime)
>= TimeInterval(vaultTimeout.rawValue)
}
}
func isLocked(userId: String) -> Bool {
guard let isLocked = timeoutStore[userId] else {
throw VaultTimeoutServiceError.noAccountFound
timeoutStore[userId] = true
return true
}
return isLocked
}
@ -111,30 +142,21 @@ class DefaultVaultTimeoutService: VaultTimeoutService {
}
func setLastActiveTime(userId: String) async throws {
try await stateService.setLastActiveTime(Date(), userId: userId)
try await stateService.setLastActiveTime(timeProvider.presentTime, userId: userId)
}
func setVaultTimeout(value: SessionTimeoutValue, userId: String?) async throws {
try await stateService.setVaultTimeout(value: value, userId: userId)
}
func shouldSessionTimeout(userId: String) async throws -> Bool {
guard let lastActiveTime = try await stateService.getLastActiveTime(userId: userId) else { return true }
let vaultTimeout = try await stateService.getVaultTimeout(userId: userId)
if vaultTimeout == .onAppRestart { return true }
if vaultTimeout == .never { return false }
if Date().timeIntervalSince(lastActiveTime) >= TimeInterval(vaultTimeout.rawValue) {
return true
}
return false
}
func unlockVault(userId: String?) async {
guard let id = try? await stateService.getAccountIdOrActiveId(userId: userId) else { return }
var updatedStore = timeoutStore.mapValues { _ in true }
updatedStore[id] = false
timeoutStore = updatedStore
}
func sessionTimeoutValue(userId: String?) async throws -> SessionTimeoutValue {
try await stateService.getVaultTimeout(userId: userId)
}
}

View File

@ -9,6 +9,7 @@ final class VaultTimeoutServiceTests: BitwardenTestCase { // swiftlint:disable:t
var cancellables: Set<AnyCancellable>!
var stateService: MockStateService!
var subject: DefaultVaultTimeoutService!
var timeProvider: MockTimeProvider!
// MARK: Setup & Teardown
@ -17,7 +18,12 @@ final class VaultTimeoutServiceTests: BitwardenTestCase { // swiftlint:disable:t
cancellables = []
stateService = MockStateService()
subject = DefaultVaultTimeoutService(stateService: stateService)
timeProvider = MockTimeProvider(
.mockTime(
.init(year: 2024, month: 1, day: 1)
)
)
subject = DefaultVaultTimeoutService(stateService: stateService, timeProvider: timeProvider)
}
override func tearDown() {
@ -26,18 +32,49 @@ final class VaultTimeoutServiceTests: BitwardenTestCase { // swiftlint:disable:t
cancellables = nil
subject = nil
stateService = nil
timeProvider = nil
}
// MARK: Tests
/// `.hasPassedSessionTimeout()` returns false if the user should not be timed out.
func test_hasPassedSessionTimeout_false() async throws {
let account = Account.fixture()
stateService.activeAccount = account
stateService.lastActiveTime[account.profile.userId] = Date()
stateService.vaultTimeout[account.profile.userId] = .custom(120)
let shouldTimeout = try await subject.hasPassedSessionTimeout(userId: account.profile.userId)
XCTAssertFalse(shouldTimeout)
}
/// `.hasPassedSessionTimeout()` returns false if the user's vault timeout value is negative.
func test_hasPassedSessionTimeout_never() async throws {
let account = Account.fixture()
stateService.activeAccount = account
stateService.lastActiveTime[account.profile.userId] = Date()
stateService.vaultTimeout[account.profile.userId] = .never
let shouldTimeout = try await subject.hasPassedSessionTimeout(userId: account.profile.userId)
XCTAssertFalse(shouldTimeout)
}
/// `.hasPassedSessionTimeout()` returns true if the user should be timed out.
func test_hasPassedSessionTimeout_true() async throws {
let account = Account.fixture()
stateService.activeAccount = account
stateService.lastActiveTime[account.profile.userId] = .distantPast
stateService.vaultTimeout[account.profile.userId] = .oneMinute
let shouldTimeout = try await subject.hasPassedSessionTimeout(userId: account.profile.userId)
XCTAssertTrue(shouldTimeout)
}
/// `isLocked(userId:)` should return true for a locked account.
func test_isLocked_true() async {
let account = Account.fixtureAccountLogin()
subject.timeoutStore = [
account.profile.userId: true,
]
let isLocked = try? subject.isLocked(userId: account.profile.userId)
XCTAssertTrue(isLocked!)
let isLocked = subject.isLocked(userId: account.profile.userId)
XCTAssertTrue(isLocked)
}
/// `isLocked(userId:)` should return false for an unlocked account.
@ -46,13 +83,13 @@ final class VaultTimeoutServiceTests: BitwardenTestCase { // swiftlint:disable:t
subject.timeoutStore = [
account.profile.userId: false,
]
let isLocked = try? subject.isLocked(userId: account.profile.userId)
XCTAssertFalse(isLocked!)
let isLocked = subject.isLocked(userId: account.profile.userId)
XCTAssertFalse(isLocked)
}
/// `isLocked(userId:)` should throw when no account is found.
/// `isLocked(userId:)` should return true when no account is found.
func test_isLocked_notFound() async {
XCTAssertThrowsError(try subject.isLocked(userId: "123"))
XCTAssertTrue(subject.isLocked(userId: "123"))
}
/// `lockVault(userId: nil)` should lock the active account.
@ -199,46 +236,6 @@ final class VaultTimeoutServiceTests: BitwardenTestCase { // swiftlint:disable:t
XCTAssertEqual(stateService.vaultTimeout[account.profile.userId], .never)
}
/// `.shouldSessionTimeout()` returns false if the user should not be timed out.
func test_shouldSessionTimeout_false() async throws {
let account = Account.fixture()
stateService.activeAccount = account
stateService.lastActiveTime[account.profile.userId] = Date()
stateService.vaultTimeout[account.profile.userId] = .custom(120)
let shouldTimeout = try await subject.shouldSessionTimeout(userId: account.profile.userId)
XCTAssertFalse(shouldTimeout)
}
/// `.shouldSessionTimeout()` returns false if the user's vault timeout value is negative.
func test_shouldSessionTimeout_never() async throws {
let account = Account.fixture()
stateService.activeAccount = account
stateService.lastActiveTime[account.profile.userId] = Date()
stateService.vaultTimeout[account.profile.userId] = .never
let shouldTimeout = try await subject.shouldSessionTimeout(userId: account.profile.userId)
XCTAssertFalse(shouldTimeout)
}
/// `.shouldSessionTimeout()` returns true if the user should be timed out on app restart.
func test_shouldSessionTimeout_true_onAppRestart() async throws {
let account = Account.fixture()
stateService.activeAccount = account
stateService.lastActiveTime[account.profile.userId] = Date()
stateService.vaultTimeout[account.profile.userId] = .onAppRestart
let shouldTimeout = try await subject.shouldSessionTimeout(userId: account.profile.userId)
XCTAssertTrue(shouldTimeout)
}
/// `.shouldSessionTimeout()` returns true if the user should be timed out.
func test_shouldSessionTimeout_true() async throws {
let account = Account.fixture()
stateService.activeAccount = account
stateService.lastActiveTime[account.profile.userId] = .distantPast
stateService.vaultTimeout[account.profile.userId] = .oneMinute
let shouldTimeout = try await subject.shouldSessionTimeout(userId: account.profile.userId)
XCTAssertTrue(shouldTimeout)
}
/// `unlockVault(userId: nil)` should unock the active account.
func test_unlock_nil_active() async {
let account = Account.fixtureAccountLogin()

View File

@ -0,0 +1,27 @@
// MARK: AuthAction
/// An action that may require routing to a new Auth screen.
///
public enum AuthAction: Equatable {
/// When the app should lock an account.
///
/// - Parameter userId: The user Id of the account.
///
case lockVault(userId: String?)
/// When the app should logout an account vault.
///
/// - Parameters:
/// - userId: The user Id of the selected account. Defaults to the active user id if nil.
/// - userInitiated: Did a user action trigger the logout.
///
case logout(userId: String?, userInitiated: Bool)
/// When the app requests an account switch.
///
/// - Parameters:
/// - isAutomatic: Did the system trigger the account switch?
/// - userId: The user Id of the selected account.
///
case switchAccount(isAutomatic: Bool, userId: String)
}

View File

@ -1,4 +1,5 @@
import AuthenticationServices
import OSLog
import SwiftUI
import UIKit
@ -17,16 +18,21 @@ protocol AuthCoordinatorDelegate: AnyObject {
/// A coordinator that manages navigation in the authentication flow.
///
final class AuthCoordinator: NSObject, Coordinator, HasStackNavigator { // swiftlint:disable:this type_body_length
final class AuthCoordinator: NSObject, // swiftlint:disable:this type_body_length
Coordinator,
HasStackNavigator,
HasRouter {
// MARK: Types
typealias Router = AnyRouter<AuthEvent, AuthRoute>
typealias Services = HasAccountAPIService
& HasAppIdService
& HasAppSettingsStore
& HasAuthAPIService
& HasAuthRepository
& HasAuthService
& HasBiometricsService
& HasBiometricsRepository
& HasCaptchaService
& HasClientAuth
& HasDeviceAPIService
@ -50,6 +56,9 @@ final class AuthCoordinator: NSObject, Coordinator, HasStackNavigator { // swift
/// The root navigator used to display this coordinator's interface.
weak var rootNavigator: (any RootNavigator)?
/// The router used by this coordinator.
var router: AnyRouter<AuthEvent, AuthRoute>
/// The services used by this coordinator.
let services: Services
@ -64,6 +73,7 @@ final class AuthCoordinator: NSObject, Coordinator, HasStackNavigator { // swift
/// - appExtensionDelegate: A delegate used to communicate with the app extension.
/// - delegate: The delegate for this coordinator. Used to signal when auth has been completed.
/// - rootNavigator: The root navigator used to display this coordinator's interface.
/// - router: The router used by this coordinator to handle events.
/// - services: The services used by this coordinator.
/// - stackNavigator: The stack navigator that is managed by this coordinator.
///
@ -71,12 +81,14 @@ final class AuthCoordinator: NSObject, Coordinator, HasStackNavigator { // swift
appExtensionDelegate: AppExtensionDelegate?,
delegate: AuthCoordinatorDelegate,
rootNavigator: RootNavigator,
router: AnyRouter<AuthEvent, AuthRoute>,
services: Services,
stackNavigator: StackNavigator
) {
self.appExtensionDelegate = appExtensionDelegate
self.delegate = delegate
self.rootNavigator = rootNavigator
self.router = router
self.services = services
self.stackNavigator = stackNavigator
}
@ -126,8 +138,6 @@ final class AuthCoordinator: NSObject, Coordinator, HasStackNavigator { // swift
state: state,
url: url
)
case let .switchAccount(userId: userId):
selectAccount(for: userId)
case let .twoFactor(email, password, authMethodsData):
showTwoFactorAuth(email: email, password: password, authMethodsData: authMethodsData)
case let .vaultUnlock(
@ -152,29 +162,21 @@ final class AuthCoordinator: NSObject, Coordinator, HasStackNavigator { // swift
// MARK: Private Methods
/// Selects the account for a given userId and navigates to the correct point
/// Configures the app with an active account.
///
/// - Parameter userId: The user id of the selected account.
private func selectAccount(for userId: String) {
Task {
do {
let account = try await services.authRepository.setActiveAccount(userId: userId)
let isLocked = try services.vaultTimeoutService.isLocked(userId: userId)
if isLocked {
showVaultUnlock(
account: account,
animated: false,
attemptAutmaticBiometricUnlock: true,
didSwitchAccountAutomatically: false
)
} else {
delegate?.didCompleteAuth()
}
} catch {
services.errorReporter.log(error: error)
showLanding()
}
/// - Parameter shouldSwitchAutomatically: Should the app switch to the next available account
/// if there is no active account?
/// - Returns: The account model currently set as active.
///
private func configureActiveAccount(shouldSwitchAutomatically: Bool) async throws -> Account {
if let active = try? await services.stateService.getActiveAccount() {
return active
}
guard shouldSwitchAutomatically,
let alternate = try await services.stateService.getAccounts().first else {
throw StateServiceError.noActiveAccount
}
return try await services.authRepository.setActiveAccount(userId: alternate.profile.userId)
}
/// Shows the captcha screen.
@ -412,8 +414,8 @@ final class AuthCoordinator: NSObject, Coordinator, HasStackNavigator { // swift
///
private func showVaultUnlock(
account: Account,
animated: Bool = true,
attemptAutmaticBiometricUnlock: Bool = false,
animated: Bool,
attemptAutmaticBiometricUnlock: Bool,
didSwitchAccountAutomatically: Bool
) {
let processor = VaultUnlockProcessor(

View File

@ -10,8 +10,11 @@ class AuthCoordinatorTests: BitwardenTestCase {
var authDelegate: MockAuthDelegate!
var authRepository: MockAuthRepository!
var authRouter: AuthRouter!
var errorReporter: MockErrorReporter!
var rootNavigator: MockRootNavigator!
var stackNavigator: MockStackNavigator!
var stateService: MockStateService!
var subject: AuthCoordinator!
var vaultTimeoutService: MockVaultTimeoutService!
@ -21,17 +24,24 @@ class AuthCoordinatorTests: BitwardenTestCase {
super.setUp()
authDelegate = MockAuthDelegate()
authRepository = MockAuthRepository()
errorReporter = MockErrorReporter()
rootNavigator = MockRootNavigator()
stackNavigator = MockStackNavigator()
stateService = MockStateService()
vaultTimeoutService = MockVaultTimeoutService()
let services = ServiceContainer.withMocks(
authRepository: authRepository,
errorReporter: errorReporter,
stateService: stateService,
vaultTimeoutService: vaultTimeoutService
)
authRouter = AuthRouter(services: services)
subject = AuthCoordinator(
appExtensionDelegate: MockAppExtensionDelegate(),
delegate: authDelegate,
rootNavigator: rootNavigator,
services: ServiceContainer.withMocks(
authRepository: authRepository,
vaultTimeoutService: vaultTimeoutService
),
router: authRouter.asAnyRouter(),
services: services,
stackNavigator: stackNavigator
)
}
@ -40,8 +50,10 @@ class AuthCoordinatorTests: BitwardenTestCase {
super.tearDown()
authDelegate = nil
authRepository = nil
errorReporter = nil
rootNavigator = nil
stackNavigator = nil
stateService = nil
vaultTimeoutService = nil
subject = nil
}
@ -169,14 +181,15 @@ class AuthCoordinatorTests: BitwardenTestCase {
XCTAssertTrue(navigationController.viewControllers.first is UIHostingController<SelfHostedView>)
}
/// `navigate(to:)` with `.switchAccount` with an locked account navigates to vault unlock
/// `handleEvent()` with `.switchAccount` with an locked account navigates to vault unlock
func test_navigate_switchAccount_locked() {
let account = Account.fixture()
authRepository.setActiveAccountResult = .success(account)
authRepository.altAccounts = [account]
vaultTimeoutService.timeoutStore = [account.profile.userId: true]
stateService.activeAccount = account
let task = Task {
subject.navigate(to: .switchAccount(userId: account.profile.userId))
await subject.handleEvent(.action(.switchAccount(isAutomatic: true, userId: account.profile.userId)))
}
waitFor(stackNavigator.actions.last?.type == .replaced)
task.cancel()
@ -186,11 +199,12 @@ class AuthCoordinatorTests: BitwardenTestCase {
/// `navigate(to:)` with `.switchAccount` with an unlocked account triggers completion
func test_navigate_switchAccount_unlocked() {
let account = Account.fixture()
authRepository.setActiveAccountResult = .success(account)
vaultTimeoutService.timeoutStore = [account.profile.userId: false]
authRepository.altAccounts = [account]
authRepository.isLockedResult = .success(false)
stateService.activeAccount = account
let task = Task {
subject.navigate(to: .switchAccount(userId: account.profile.userId))
await subject.handleEvent(.action(.switchAccount(isAutomatic: true, userId: account.profile.userId)))
}
waitFor(authDelegate.didCompleteAuthCalled)
task.cancel()
@ -198,25 +212,26 @@ class AuthCoordinatorTests: BitwardenTestCase {
XCTAssertTrue(authDelegate.didCompleteAuthCalled)
}
/// `navigate(to:)` with `.switchAccount` with an unknown account triggers completion.
/// `navigate(to:)` with `.switchAccount` with an unknown lock status account navigates to vault unlock.
func test_navigate_switchAccount_unknownLock() {
let account = Account.fixture()
authRepository.setActiveAccountResult = .success(account)
vaultTimeoutService.timeoutStore = [:]
authRepository.altAccounts = [account]
authRepository.isLockedResult = .failure(VaultTimeoutServiceError.noAccountFound)
stateService.activeAccount = account
let task = Task {
subject.navigate(to: .switchAccount(userId: account.profile.userId))
await subject.handleEvent(.action(.switchAccount(isAutomatic: true, userId: account.profile.userId)))
}
waitFor(stackNavigator.actions.last?.view is LandingView)
waitFor(stackNavigator.actions.last?.view is VaultUnlockView)
task.cancel()
XCTAssertTrue(stackNavigator.actions.last?.view is LandingView)
XCTAssertTrue(stackNavigator.actions.last?.view is VaultUnlockView)
}
/// `navigate(to:)` with `.switchAccount` with an invalid account navigates to landing view.
func test_navigate_switchAccount_notFound() {
let account = Account.fixture()
let task = Task {
subject.navigate(to: .switchAccount(userId: account.profile.userId))
await subject.handleEvent(.action(.switchAccount(isAutomatic: true, userId: account.profile.userId)))
}
waitFor(stackNavigator.actions.last?.view is LandingView)
task.cancel()
@ -237,6 +252,8 @@ class AuthCoordinatorTests: BitwardenTestCase {
subject.navigate(
to: .vaultUnlock(
.fixture(),
animated: false,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: false
)
)
@ -251,6 +268,8 @@ class AuthCoordinatorTests: BitwardenTestCase {
subject.navigate(
to: .vaultUnlock(
.fixture(),
animated: false,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: true
)
)
@ -271,6 +290,7 @@ class AuthCoordinatorTests: BitwardenTestCase {
appExtensionDelegate: MockAppExtensionDelegate(),
delegate: authDelegate,
rootNavigator: rootNavigator!,
router: MockRouter(routeForEvent: { _ in .landing }).asAnyRouter(),
services: ServiceContainer.withMocks(),
stackNavigator: stackNavigator
)

View File

@ -0,0 +1,58 @@
// MARK: - AuthEvent
/// An event to be handled by a Router tasked with producing `AuthRoute`s.
///
public enum AuthEvent: Equatable {
/// When the router should check the lock status of an account and propose a route.
///
/// - Parameters:
/// - account: The account to unlock the vault for.
/// - animated: Whether to animate the transition to the view.
/// - attemptAutomaticBiometricUnlock: If `true` and biometric unlock is enabled/available,
/// the processor should attempt an automatic biometric unlock.
/// - didSwitchAccountAutomatically: A flag indicating if the active account was switched automatically.
///
case accountBecameActive(
Account,
animated: Bool,
attemptAutomaticBiometricUnlock: Bool,
didSwitchAccountAutomatically: Bool
)
/// When the router should handle an AuthAction.
///
case action(AuthAction)
/// When the router should check the lock status of an account and propose a route.
///
/// - Parameters:
/// - account: The account to unlock the vault for.
/// - animated: Whether to animate the transition to the view.
/// - attemptAutomaticBiometricUnlock: If `true` and biometric unlock is enabled/available,
/// the processor should attempt an automatic biometric unlock.
/// - didSwitchAccountAutomatically: A flag indicating if the active account was switched automatically.
///
case didLockAccount(
Account,
animated: Bool,
attemptAutomaticBiometricUnlock: Bool,
didSwitchAccountAutomatically: Bool
)
/// When the user deletes an account.
case didDeleteAccount
/// When the user logs out from an account.
///
/// - Parameters:
/// - userId: The userId of the account that was logged out.
/// - isUserInitiated: Did a user action trigger the account switch?
///
case didLogout(userId: String, userInitiated: Bool)
/// When the app starts
case didStart
/// When an account has timed out.
case didTimeout(userId: String)
}

View File

@ -17,7 +17,13 @@ protocol AuthModule {
delegate: AuthCoordinatorDelegate,
rootNavigator: RootNavigator,
stackNavigator: StackNavigator
) -> AnyCoordinator<AuthRoute>
) -> AnyCoordinator<AuthRoute, AuthEvent>
/// Initializes a router for converting AuthEvents into AuthRoutes.
///
/// - Returns: A router that can convert `AuthEvent`s into `AuthRoute`s.
///
func makeAuthRouter() -> AnyRouter<AuthEvent, AuthRoute>
}
// MARK: - DefaultAppModule
@ -27,13 +33,18 @@ extension DefaultAppModule: AuthModule {
delegate: AuthCoordinatorDelegate,
rootNavigator: RootNavigator,
stackNavigator: StackNavigator
) -> AnyCoordinator<AuthRoute> {
) -> AnyCoordinator<AuthRoute, AuthEvent> {
AuthCoordinator(
appExtensionDelegate: appExtensionDelegate,
delegate: delegate,
rootNavigator: rootNavigator,
router: makeAuthRouter(),
services: services,
stackNavigator: stackNavigator
).asAnyCoordinator()
}
func makeAuthRouter() -> AnyRouter<AuthEvent, AuthRoute> {
AuthRouter(services: services).asAnyRouter()
}
}

View File

@ -65,12 +65,6 @@ public enum AuthRoute: Equatable {
///
case singleSignOn(callbackUrlScheme: String, state: String, url: URL)
/// A route to switch accounts.
///
/// - Parameter userId: The user Id of the selected account.
///
case switchAccount(userId: String)
/// A route to the two-factor authentication view.
///
/// - Parameters:
@ -95,8 +89,8 @@ public enum AuthRoute: Equatable {
///
case vaultUnlock(
Account,
animated: Bool = true,
attemptAutomaticBiometricUnlock: Bool = false,
animated: Bool,
attemptAutomaticBiometricUnlock: Bool,
didSwitchAccountAutomatically: Bool
)
}

View File

@ -0,0 +1,106 @@
import Foundation
// MARK: - AuthManager
final class AuthRouter: NSObject, Router {
// MARK: Types
typealias Services = HasAuthRepository
& HasErrorReporter
& HasStateService
& HasVaultTimeoutService
/// The services used by this router.
let services: Services
// MARK: Initialization
/// Creates a new `AuthRouter`.
///
/// - Parameter services: The services used by this router.
///
/// - Parameters:
init(services: Services) {
self.services = services
}
/// Prepare the coordinator asynchronously for a redirected `AuthRoute` based on current state
///
/// - Parameter route: The proposed `AuthRoute`.
/// - Returns: Either the supplied route or a new route if the coordinator state demands a different route.
///
func handleAndRoute(_ event: AuthEvent) async -> AuthRoute {
switch event {
case let .accountBecameActive(
activeAccount,
animated,
attemptAutomaticBiometricUnlock,
didSwitchAccountAutomatically
):
return await vaultUnlockRedirect(
activeAccount,
animated: animated,
attemptAutomaticBiometricUnlock: attemptAutomaticBiometricUnlock,
didSwitchAccountAutomatically: didSwitchAccountAutomatically
)
case let .action(authAction):
return await handleAuthAction(authAction)
case .didDeleteAccount:
return await deleteAccountRedirect()
case let .didLockAccount(
account,
animated,
attemptAutomaticBiometricUnlock,
didSwitchAccountAutomatically
):
guard let active = try? await services.authRepository.getAccount() else {
return .landing
}
guard active.profile.userId == account.profile.userId else {
return await vaultUnlockRedirect(
active,
animated: animated,
attemptAutomaticBiometricUnlock: attemptAutomaticBiometricUnlock,
didSwitchAccountAutomatically: didSwitchAccountAutomatically
)
}
return .vaultUnlock(
account,
animated: animated,
attemptAutomaticBiometricUnlock: attemptAutomaticBiometricUnlock,
didSwitchAccountAutomatically: didSwitchAccountAutomatically
)
case let .didLogout(userId, userInitiated):
return await didLogoutRedirect(
userId: userId,
userInitiated: userInitiated
)
case .didStart:
// Go to the initial auth route redirect.
return await preparedStartRoute()
case let .didTimeout(userId):
return await timeoutRedirect(userId: userId)
}
}
// MARK: - Private
/// Converts an `AuthAction` into an `AuthRoute`
///
/// - Parameter action: The supplied AuthAction.
/// - Returns: The correct `AuthRoute` for the action.
///
private func handleAuthAction(_ action: AuthAction) async -> AuthRoute {
switch action {
case let .lockVault(userId):
return await lockVaultRedirect(userId: userId)
case let .logout(userId, userInitiated):
return await logoutRedirect(userId: userId, userInitiated: userInitiated)
case let .switchAccount(isAutomatic, userId):
return await switchAccountRedirect(
isAutomatic: isAutomatic,
userId: userId
)
}
}
}

View File

@ -0,0 +1,852 @@
import XCTest
// swiftlint:disable file_length
@testable import BitwardenShared
final class AuthRouterTests: BitwardenTestCase { // swiftlint:disable:this type_body_length
// MARK: Properties
var authRepository: MockAuthRepository!
var errorReporter: MockErrorReporter!
var stateService: MockStateService!
var subject: AuthRouter!
var vaultTimeoutService: MockVaultTimeoutService!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
authRepository = MockAuthRepository()
errorReporter = MockErrorReporter()
stateService = MockStateService()
vaultTimeoutService = MockVaultTimeoutService()
subject = AuthRouter(
services: ServiceContainer.withMocks(
authRepository: authRepository,
errorReporter: errorReporter,
stateService: stateService,
vaultTimeoutService: vaultTimeoutService
)
)
}
override func tearDown() {
super.tearDown()
errorReporter = nil
stateService = nil
subject = nil
vaultTimeoutService = nil
}
/// `handleAndRoute(_ :)` redirects `.accountBecameActive()` to `.vaultUnlock`
/// when `unlockVaultWithNeverlockResult` fails.
func test_handleAndRoute_accountBecameActive_neverLock_error() async {
let active = Account.fixture()
stateService.activeAccount = active
authRepository.isLockedResult = .success(true)
vaultTimeoutService.vaultTimeout = [
active.profile.userId: .never,
]
authRepository.unlockVaultWithNeverlockResult = .failure(BitwardenTestError.example)
let initialRoute = AuthEvent.accountBecameActive(
active,
animated: true,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: false
)
let route = await subject.handleAndRoute(
initialRoute
)
XCTAssertEqual(
route,
.vaultUnlock(
active,
animated: true,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: false
)
)
let error = try? XCTUnwrap(errorReporter.errors.first as? BitwardenTestError)
XCTAssertEqual(BitwardenTestError.example, error)
}
/// `handleAndRoute(_ :)` redirects `.accountBecameActive()` to `.complete`
/// when `unlockVaultWithNeverlockResult` succeeds.
func test_handleAndRoute_accountBecameActive_neverLock_success() async {
let active = Account.fixture()
stateService.activeAccount = active
authRepository.isLockedResult = .success(true)
vaultTimeoutService.vaultTimeout = [
active.profile.userId: .never,
]
authRepository.unlockVaultWithNeverlockResult = .success(())
let route = await subject.handleAndRoute(
.accountBecameActive(
active,
animated: true,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: false
)
)
XCTAssertEqual(route, .complete)
}
/// `handleAndRoute(_ :)` redirects `.accountBecameActive()` to `.complete`
/// when the account is unlocked.
func test_handleAndRoute_accountBecameActive_unlocked() async {
let active = Account.fixture()
stateService.activeAccount = active
authRepository.isLockedResult = .success(false)
let route = await subject.handleAndRoute(
.accountBecameActive(
active,
animated: true,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: false
)
)
XCTAssertEqual(route, .complete)
}
/// `handleAndRoute(_ :)` redirects`.didDeleteAccount` to another account
/// when there are more accounts.
func test_handleAndRoute_didDeleteAccount_alternateAccount() {
let alt = Account.fixtureAccountLogin()
stateService.accounts = [
alt,
]
authRepository.altAccounts = [alt]
var route: AuthRoute?
let task = Task {
route = await subject.handleAndRoute(.didDeleteAccount)
}
waitFor(authRepository.setActiveAccountId != nil)
stateService.activeAccount = alt
waitFor(route != nil)
task.cancel()
XCTAssertEqual(
route,
.vaultUnlock(
alt,
animated: false,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: true
)
)
}
/// `handleAndRoute(_ :)` redirects`.didDeleteAccount` to `.landing`
/// when there are no more accounts.
func test_handleAndRoute_didDeleteAccount_noAccounts() async {
let route = await subject.handleAndRoute(.didDeleteAccount)
XCTAssertEqual(route, .landing)
}
/// `handleAndRoute(_ :)` redirects`.didDeleteAccount` to `.landing`
/// when an error occurs setting a new active account.
func test_handleAndRoute_didDeleteAccount_setActiveFail() async {
let alt = Account.fixtureAccountLogin()
stateService.accounts = [
alt,
]
authRepository.setActiveAccountError = BitwardenTestError.example
let route = await subject.handleAndRoute(.didDeleteAccount)
XCTAssertEqual(
route,
.landing
)
}
/// `handleAndRoute(_ :)` delivers the locked active user to `.vaultUnlock`
/// thorugh `.didLockAccount()`.
func test_handleAndRoute_didLockAccount_active() async {
let alt = Account.fixtureAccountLogin()
let active = Account.fixture()
authRepository.activeAccount = active
authRepository.altAccounts = [
alt,
]
stateService.accounts = [
alt,
]
let route = await subject.handleAndRoute(
.didLockAccount(
active,
animated: false,
attemptAutomaticBiometricUnlock: false,
didSwitchAccountAutomatically: false
)
)
XCTAssertEqual(
route,
.vaultUnlock(
active,
animated: false,
attemptAutomaticBiometricUnlock: false,
didSwitchAccountAutomatically: false
)
)
}
/// `handleAndRoute(_ :)` handles `.didLockAccount()`
/// without moving the user from their current position when locking an alternate account.
func test_handleAndRoute_didLockAccount_alternate() async {
let alt = Account.fixtureAccountLogin()
let active = Account.fixture()
authRepository.activeAccount = active
authRepository.altAccounts = [
alt,
]
authRepository.isLockedResult = .success(false)
let route = await subject.handleAndRoute(
.didLockAccount(
alt,
animated: false,
attemptAutomaticBiometricUnlock: false,
didSwitchAccountAutomatically: false
)
)
XCTAssertEqual(
route,
.complete
)
}
/// `handleAndRoute(_ :)` redirects `.didLogout()` to `.landing`
/// when no accounts are present.
func test_handleAndRoute_didLogout_automatic_alternateAccount() async {
let alt = Account.fixtureAccountLogin()
stateService.accounts = [
alt,
]
authRepository.altAccounts = [alt]
let route = await subject.handleAndRoute(.didLogout(userId: alt.profile.userId, userInitiated: false))
XCTAssertEqual(route, .landing)
}
/// `handleAndRoute(_ :)` redirects `.didLogout()` to `.landing`
/// when no accounts are present.
func test_handleAndRoute_didLogout_automatic_noAccounts() async {
let route = await subject.handleAndRoute(.didLogout(userId: "123", userInitiated: false))
XCTAssertEqual(route, .landing)
}
/// `handleAndRoute(_ :)` redirects `.didLogout()` to `.vaultUnlock`
/// when the current account is locked.
func test_handleAndRoute_logout_userInitiated_alternateAccount_locked() async {
let alt = Account.fixtureAccountLogin()
let main = Account.fixture()
authRepository.activeAccount = main
authRepository.isLockedResult = .success(true)
authRepository.altAccounts = [
alt,
]
stateService.accounts = [
main,
]
let route = await subject.handleAndRoute(
.action(
.logout(
userId: alt.profile.userId,
userInitiated: true
)
)
)
XCTAssertEqual(
route,
.vaultUnlock(
main,
animated: false,
attemptAutomaticBiometricUnlock: false,
didSwitchAccountAutomatically: false
)
)
}
/// `handleAndRoute(_ :)` redirects `.didLogout()` to `.complete`
/// when the current account is unlocked.
func test_handleAndRoute_logout_userInitiated_alternateAccount_unlocked() async {
let alt = Account.fixtureAccountLogin()
let main = Account.fixture()
authRepository.activeAccount = main
authRepository.isLockedResult = .success(false)
authRepository.altAccounts = [
alt,
]
stateService.accounts = [
main,
]
let route = await subject.handleAndRoute(
.action(
.logout(
userId: alt.profile.userId,
userInitiated: true
)
)
)
XCTAssertEqual(
route,
.complete
)
}
/// `handleAndRoute(_ :)` redirects `.didLogout()` to `.vaultUnlock`
/// for an alternate account when the logout is user initiated and the alt is locked.
func test_handleAndRoute_logout_userInitiated_lockedAlt() async {
let alt = Account.fixtureAccountLogin()
authRepository.activeAccount = nil
authRepository.isLockedResult = .success(true)
authRepository.altAccounts = [
alt,
]
stateService.accounts = [
alt,
]
let route = await subject.handleAndRoute(
.action(
.logout(
userId: "123",
userInitiated: true
)
)
)
XCTAssertEqual(
route,
.vaultUnlock(
alt,
animated: false,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: true
)
)
}
/// `handleAndRoute(_ :)` redirects `.didLogout()` to `.vaultUnlock`
/// when logging out the active account and the alt is locked.
func test_handleAndRoute_logout_userInitiated_main_locked() async {
let alt = Account.fixtureAccountLogin()
let main = Account.fixture()
authRepository.activeAccount = main
authRepository.isLockedResult = .success(true)
authRepository.altAccounts = [
alt,
]
stateService.accounts = [
alt,
]
let route = await subject.handleAndRoute(
.action(
.logout(
userId: main.profile.userId,
userInitiated: true
)
)
)
XCTAssertEqual(
route,
.vaultUnlock(
alt,
animated: false,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: true
)
)
}
/// `handleAndRoute(_ :)` redirects `.didLogout()` to `.complete`
/// when logging out the active account and the alternate is unlocked.
func test_handleAndRoute_logout_userInitiated_main_unlocked() async {
let alt = Account.fixtureAccountLogin()
let main = Account.fixture()
authRepository.activeAccount = main
authRepository.isLockedResult = .success(false)
authRepository.altAccounts = [
alt,
]
stateService.accounts = [
alt,
]
let route = await subject.handleAndRoute(
.action(
.logout(
userId: main.profile.userId,
userInitiated: true
)
)
)
XCTAssertEqual(
route,
.complete
)
}
/// `handleAndRoute(_ :)` redirects `.didLogout()` to `.landing`
/// when no accounts are present.
func test_handleAndRoute_didLogout_userInitiated_noAccounts() async {
let route = await subject.handleAndRoute(.didLogout(userId: "123", userInitiated: true))
XCTAssertEqual(route, .landing)
}
/// `handleAndRoute(_ :)` redirects `.lockVault()` to `.vaultUnlock`
/// when the active account is locked.
func test_handleAndRoute_lock_active_success() async {
let main = Account.fixture()
authRepository.activeAccount = main
authRepository.isLockedResult = .success(true)
let route = await subject.handleAndRoute(
.action(
.lockVault(userId: main.profile.userId)
)
)
XCTAssertEqual(
route,
.vaultUnlock(
main,
animated: false,
attemptAutomaticBiometricUnlock: false,
didSwitchAccountAutomatically: false
)
)
}
/// `handleAndRoute(_ :)` redirects `.lockVault()` to `.vaultUnlock`
/// when an alternate account is locked but the active is also locked.
func test_handleAndRoute_lock_alternate_activeLocked() async {
let main = Account.fixture()
let alt = Account.fixtureAccountLogin()
authRepository.activeAccount = main
authRepository.altAccounts = [alt]
let route = await subject.handleAndRoute(
.action(
.lockVault(userId: alt.profile.userId)
)
)
XCTAssertEqual(
route,
.vaultUnlock(
main,
animated: false,
attemptAutomaticBiometricUnlock: false,
didSwitchAccountAutomatically: false
)
)
}
/// `handleAndRoute(_ :)` redirects `.lockVault()` to `.landing`
/// when there are no accounts.
func test_handleAndRoute_lock_noAccounts() async {
authRepository.activeAccount = nil
authRepository.altAccounts = []
let route = await subject.handleAndRoute(
.action(
.lockVault(
userId: Account.fixtureAccountLogin().profile.userId
)
)
)
XCTAssertEqual(
route,
.landing
)
}
/// `handleAndRoute(_ :)` redirects `.lockVault()` to `.vaultUnlock`
/// when attempting to lock an unknown alternate account and the active account is locked.
func test_handleAndRoute_lock_unknown() async {
let main = Account.fixture()
authRepository.activeAccount = main
authRepository.altAccounts = []
let route = await subject.handleAndRoute(
.action(
.lockVault(
userId: Account.fixtureAccountLogin().profile.userId
)
)
)
XCTAssertEqual(
route,
.vaultUnlock(
main,
animated: false,
attemptAutomaticBiometricUnlock: false,
didSwitchAccountAutomatically: false
)
)
}
/// `handleAndRoute(_ :)` redirects `.didLogout()` to `.vaultUnlock`
/// for the main account when the event is user initiated, the main is locked,
/// and there is no account found when requesting logout.
func test_handleAndRoute_logout_userInitiated_notFound_locked() async {
let main = Account.fixture()
authRepository.activeAccount = main
authRepository.isLockedResult = .success(true)
authRepository.altAccounts = []
stateService.accounts = [
main,
]
let route = await subject.handleAndRoute(
.action(
.logout(
userId: "123",
userInitiated: true
)
)
)
XCTAssertEqual(
route,
.vaultUnlock(
main,
animated: false,
attemptAutomaticBiometricUnlock: false,
didSwitchAccountAutomatically: false
)
)
}
/// `handleAndRoute(_ :)` redirects `.didLogout()` to `.vaultUnlock`
/// when an error is thrown attempting to log out the active account.
func test_handleAndRoute_logout_system_active_error() async {
let main = Account.fixture()
authRepository.activeAccount = main
authRepository.altAccounts = []
authRepository.logoutResult = .failure(BitwardenTestError.example)
stateService.accounts = []
let route = await subject.handleAndRoute(
.action(
.logout(
userId: main.profile.userId,
userInitiated: false
)
)
)
XCTAssertEqual(
route,
.vaultUnlock(
main,
animated: false,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: false
)
)
}
/// `handleAndRoute(_ :)` redirects `.didLogout()` to `.landing`
/// when the event is system initiated and there is no alternate account.
/// System driven logouts do not trigger an account switch.
func test_handleAndRoute_logout_system_active_noAlt() async {
let main = Account.fixture()
authRepository.activeAccount = main
authRepository.altAccounts = []
stateService.accounts = []
let route = await subject.handleAndRoute(
.action(
.logout(
userId: main.profile.userId,
userInitiated: false
)
)
)
XCTAssertEqual(
route,
.landing
)
}
/// `handleAndRoute(_ :)` redirects `.didLogout()` to `.landing`
/// when an error is thrown attempting to log out an alternate account.
func test_handleAndRoute_logout_system_alternate_error() async {
let alt = Account.fixture()
authRepository.activeAccount = nil
authRepository.altAccounts = [alt]
authRepository.logoutResult = .failure(BitwardenTestError.example)
stateService.accounts = [.fixture()]
let route = await subject.handleAndRoute(
.action(
.logout(
userId: alt.profile.userId,
userInitiated: false
)
)
)
XCTAssertEqual(
route,
.landing
)
}
/// `handleAndRoute(_ :)` redirects `.didLogout()` to `.landing`
/// when the event is system initiated and there are no accounts.
/// System driven logouts do not trigger an account switch.
func test_handleAndRoute_logout_system_noAccounts() async {
let main = Account.fixture()
authRepository.activeAccount = nil
authRepository.isLockedResult = .success(true)
authRepository.altAccounts = []
stateService.accounts = [
main,
]
let route = await subject.handleAndRoute(
.action(
.logout(
userId: "123",
userInitiated: false
)
)
)
XCTAssertEqual(
route,
.landing
)
}
/// `handleAndRoute(_ :)` redirects `.didLogout()` to `.vaultUnlock`
/// by way of an account switch when the logout is user initiated
/// and a locked alternate is available.
func test_handleAndRoute_didLogout_userInitiated_alternateAccount() {
let alt = Account.fixtureAccountLogin()
stateService.accounts = [
alt,
]
authRepository.altAccounts = [alt]
var route: AuthRoute?
let task = Task {
route = await subject.handleAndRoute(
.didLogout(
userId: "123",
userInitiated: true
)
)
}
waitFor(authRepository.setActiveAccountId != nil)
stateService.activeAccount = alt
waitFor(route != nil)
task.cancel()
XCTAssertEqual(
route,
.vaultUnlock(
alt,
animated: false,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: true
)
)
}
/// `handleAndRoute(_ :)` redirects `.didStart` to `.vaultUnlock`
/// when there are only locked accounts.
func test_handleAndRoute_didStart_alternateAccount() async {
let alt = Account.fixtureAccountLogin()
stateService.accounts = [
alt,
]
authRepository.altAccounts = [alt]
let route = await subject.handleAndRoute(.didStart)
XCTAssertEqual(
route,
.vaultUnlock(
alt,
animated: false,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: false
)
)
}
/// `handleAndRoute(_ :)` redirects `.didStart` to `.landing`
/// when there are no accounts.
func test_handleAndRoute_didStart_noAccounts() async {
let route = await subject.handleAndRoute(.didStart)
XCTAssertEqual(route, .landing)
}
/// `handleAndRoute(_ :)` redirects `.didStart` to `.vaultUnlock`
/// when the account is set to timeout on app start with a lock vault action.
func test_handleAndRoute_didStart_timeoutOnAppRestart_lock() async {
let active = Account.fixtureAccountLogin()
authRepository.activeAccount = active
vaultTimeoutService.vaultTimeout = [
active.profile.userId: .onAppRestart,
]
let route = await subject.handleAndRoute(.didStart)
XCTAssertEqual(
route,
.vaultUnlock(
active,
animated: false,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: false
)
)
}
/// `handleAndRoute(_ :)` redirects `.didStart` to `.landing`
/// when the account is set to timeout on app start with a logout action.
/// System driven logouts do not trigger an account switch.
func test_handleAndRoute_didStart_timeoutOnAppRestart_logout() async {
let alt = Account.fixtureAccountLogin()
stateService.accounts = [
alt,
]
stateService.activeAccount = alt
vaultTimeoutService.vaultTimeout = [
alt.profile.userId: .onAppRestart,
]
stateService.timeoutAction = [
alt.profile.userId: .logout,
]
authRepository.logoutResult = .success(())
let route = await subject.handleAndRoute(.didStart)
XCTAssertEqual(
route,
.landing
)
}
/// `handleAndRoute(_ :)` redirects `.didTimeout` to `.complete`
/// if the account has never lock enabled.
func test_handleAndRoute_didTimeout_neverLock() async {
vaultTimeoutService.vaultTimeout = [
"123": .never,
]
let route = await subject.handleAndRoute(.didTimeout(userId: "123"))
XCTAssertEqual(route, .complete)
}
/// `handleAndRoute(_ :)` redirects `.didTimeout` to `.landing`
/// when there are no accounts.
func test_handleAndRoute_didTimeout_noAccounts() async {
let route = await subject.handleAndRoute(.didTimeout(userId: "123"))
XCTAssertEqual(route, .landing)
}
/// `handleAndRoute(_ :)` redirects `.didTimeout` to `.vaultUnlock`
/// if the account session has timed out and the action is lock.
func test_handleAndRoute_didTimeout_sessionExpired_lock() async {
let account = Account.fixture()
authRepository.activeAccount = account
vaultTimeoutService.vaultTimeout = [
account.profile.userId: .fiveMinutes,
]
stateService.timeoutAction = [
account.profile.userId: .lock,
]
authRepository.logoutResult = .success(())
let route = await subject.handleAndRoute(.didTimeout(userId: account.profile.userId))
XCTAssertEqual(
route,
.vaultUnlock(
account,
animated: false,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: false
)
)
}
/// `handleAndRoute(_ :)` redirects `.didTimeout` to `.landing`
/// if the account session has timed out and the action is logout.
func test_handleAndRoute_didTimeout_sessionExpired_logout() async {
let account = Account.fixture()
stateService.accounts = [
account,
]
stateService.activeAccount = account
vaultTimeoutService.vaultTimeout = [
account.profile.userId: .fiveMinutes,
]
stateService.timeoutAction = [
account.profile.userId: .logout,
]
authRepository.logoutResult = .success(())
let route = await subject.handleAndRoute(.didTimeout(userId: account.profile.userId))
XCTAssertEqual(
route,
.landing
)
}
/// `handleAndRoute(_ :)` redirects `.didTimeout` to `.landing`
/// if the account session has timed out, the action is logout,
/// and an error occurs.
func test_handleAndRoute_didTimeout_sessionExpired_logout_error() async {
let account = Account.fixtureAccountLogin()
stateService.accounts = [
account,
]
stateService.activeAccount = account
vaultTimeoutService.vaultTimeout = [
account.profile.userId: .fiveMinutes,
]
stateService.timeoutAction = [
account.profile.userId: .logout,
]
authRepository.logoutResult = .failure(BitwardenTestError.example)
let route = await subject.handleAndRoute(.didTimeout(userId: account.profile.userId))
XCTAssertEqual(
route,
.landing
)
}
/// `handleAndRoute(_ :)` redirects `.switchAccount()` to `.landing`
/// when an error occurs setting the active account.
func test_handleAndRoute_switchAccount_error() async {
let active = Account.fixture()
authRepository.activeAccount = active
authRepository.altAccounts = [.fixture(profile: .fixture(userId: "2"))]
authRepository.isLockedResult = .success(false)
authRepository.setActiveAccountError = BitwardenTestError.example
let route = await subject.handleAndRoute(.action(.switchAccount(isAutomatic: true, userId: "2")))
XCTAssertEqual(route, .landing)
}
/// `handleAndRoute(_ :)` redirects `.switchAccount()` to `.complete`
/// when that account is already active.
func test_handleAndRoute_switchAccount_toActive() async {
let active = Account.fixture()
authRepository.activeAccount = active
authRepository.isLockedResult = .success(false)
let route = await subject.handleAndRoute(
.action(
.switchAccount(isAutomatic: true, userId: active.profile.userId)
)
)
XCTAssertEqual(route, .complete)
}
}

View File

@ -45,7 +45,7 @@ class CreateAccountProcessor: StateProcessor<CreateAccountState, CreateAccountAc
// MARK: Private Properties
/// The coordinator that handles navigation.
private let coordinator: AnyCoordinator<AuthRoute>
private let coordinator: AnyCoordinator<AuthRoute, AuthEvent>
/// The services used by the processor.
private let services: Services
@ -60,7 +60,7 @@ class CreateAccountProcessor: StateProcessor<CreateAccountState, CreateAccountAc
/// - state: The initial state of the processor.
///
init(
coordinator: AnyCoordinator<AuthRoute>,
coordinator: AnyCoordinator<AuthRoute, AuthEvent>,
services: Services,
state: CreateAccountState
) {

View File

@ -13,7 +13,7 @@ class CreateAccountProcessorTests: BitwardenTestCase {
var captchaService: MockCaptchaService!
var client: MockHTTPClient!
var clientAuth: MockClientAuth!
var coordinator: MockCoordinator<AuthRoute>!
var coordinator: MockCoordinator<AuthRoute, AuthEvent>!
var subject: CreateAccountProcessor!
// MARK: Setup & Teardown
@ -24,7 +24,7 @@ class CreateAccountProcessorTests: BitwardenTestCase {
captchaService = MockCaptchaService()
client = MockHTTPClient()
clientAuth = MockClientAuth()
coordinator = MockCoordinator<AuthRoute>()
coordinator = MockCoordinator<AuthRoute, AuthEvent>()
subject = CreateAccountProcessor(
coordinator: coordinator.asAnyCoordinator(),
services: ServiceContainer.withMocks(

View File

@ -6,7 +6,7 @@ class AlertAuthTests: BitwardenTestCase {
/// `accountOptions(_:lockAction:logoutAction:)`
func test_accountOptions() {
let subject = Alert.accountOptions(
.init(email: "test@example.com", isUnlocked: true),
.fixture(email: "test@example.com", isUnlocked: true),
lockAction: {},
logoutAction: {}
)

View File

@ -0,0 +1,345 @@
// MARK: AuthRouterRedirects
extension AuthRouter {
/// Configures the app with an active account.
///
/// - Parameter shouldSwitchAutomatically: Should the app switch to the next available account
/// if there is no active account?
/// - Returns: The account model currently set as active.
///
func configureActiveAccount(shouldSwitchAutomatically: Bool) async throws -> Account {
if let active = try? await services.authRepository.getAccount() {
return active
}
guard shouldSwitchAutomatically,
let alternate = try await services.stateService.getAccounts().first else {
throw StateServiceError.noActiveAccount
}
return try await services.authRepository.setActiveAccount(userId: alternate.profile.userId)
}
/// Handles the `.didDeleteAccount`route and redirects the user to the correct screen
/// based on alternate accounts state. If the user has an alternate account,
/// they will go to the unlock sequence for that account.
/// Otherwise, the user will be directed to the landing screen.
///
/// - Returns: A redirect to either `.landing` or `prepareAndRedirect(.vaultUnlock)`.
///
func deleteAccountRedirect() async -> AuthRoute {
// Ensure that the active account id is nil, otherwise, handle a failed account deletion by directing
// The user to the unlock flow.
let oldActiveId = try? await services.stateService.getActiveAccountId()
// Try to set the next available account.
guard let activeAccount = try? await configureActiveAccount(shouldSwitchAutomatically: true) else {
// If no other accounts are available, go to landing.
return .landing
}
// Setup the unlock route for the newly active account.
let event = AuthEvent.accountBecameActive(
activeAccount,
animated: false,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: oldActiveId != activeAccount.profile.userId
)
// Handle any vault unlock redirects for this active account.
return await handleAndRoute(event)
}
/// Handles the `.didLogout()`route and redirects the user to the correct screen
/// based on whether the user initiated this logout. If the user initiated the logout has an alternate account,
/// they will be switched to the alternate and go to the unlock sequence for that account.
/// Otherwise, the user will be directed to the landing screen.
///
/// - Parameters:
/// - userId: The id of the user that was logged out.
/// - userInitiated: Did a user action initiate this logout?
/// If `true`, the app should attempt to switch to the next available account.
/// - Returns: A redirect to either `.landing` or `prepareAndRedirect(.vaultUnlock)`.
///
func didLogoutRedirect(userId: String, userInitiated: Bool) async -> AuthRoute {
// Try to get/set the available account. If `userInitiated`, attempt to switch to the next available account.
guard let activeAccount = try? await configureActiveAccount(shouldSwitchAutomatically: userInitiated) else {
return .landing
}
// Setup the unlock route for the newly active account.
let event = AuthEvent.accountBecameActive(
activeAccount,
animated: false,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: userId != activeAccount.profile.userId
)
// Handle any vault unlock redirects for this active account.
return await handleAndRoute(event)
}
/// Handles the `.lockVault()`action and redirects the user to the correct screen.
///
/// - Parameter userId: The id of the user that should be locked.
/// - Returns: A redirect to either `.landing` or `prepareAndRedirect(.vaultUnlock)`.
///
func lockVaultRedirect(userId: String?) async -> AuthRoute {
let activeAccount = try? await services.authRepository.getAccount(for: nil)
guard let accountToLock = try? await services.authRepository.getAccount(for: userId) else {
if let activeAccount {
return await handleAndRoute(
.accountBecameActive(
activeAccount,
animated: false,
attemptAutomaticBiometricUnlock: false,
didSwitchAccountAutomatically: false
)
)
} else {
return .landing
}
}
await services.authRepository.lockVault(userId: userId)
guard let activeAccount else { return .landing }
guard activeAccount.profile.userId == accountToLock.profile.userId else {
return await handleAndRoute(
.accountBecameActive(
activeAccount,
animated: false,
attemptAutomaticBiometricUnlock: false,
didSwitchAccountAutomatically: false
)
)
}
return await handleAndRoute(
.didLockAccount(
activeAccount,
animated: false,
attemptAutomaticBiometricUnlock: false,
didSwitchAccountAutomatically: false
)
)
}
/// Handles the `.logout()`action and redirects the user to the correct screen.
///
/// - Parameter userId: The id of the user that should be logged out.
/// - Returns: A redirect to either `.landing` or `prepareAndRedirect(.vaultUnlock)`.
///
func logoutRedirect( // swiftlint:disable:this function_body_length
userId: String?,
userInitiated: Bool
) async -> AuthRoute {
let previouslyActiveAccount = try? await services.authRepository.getAccount(for: nil)
guard let accountToLogOut = try? await services.authRepository.getAccount(for: userId) else {
if let previouslyActiveAccount {
return await handleAndRoute(
.accountBecameActive(
previouslyActiveAccount,
animated: false,
attemptAutomaticBiometricUnlock: false,
didSwitchAccountAutomatically: false
)
)
} else if userInitiated,
let accounts = try? await services.stateService.getAccounts(),
let next = accounts.first {
return await switchAccountRedirect(isAutomatic: true, userId: next.profile.userId)
} else {
return .landing
}
}
do {
try await services.authRepository.logout(userId: accountToLogOut.profile.userId)
if let previouslyActiveAccount,
accountToLogOut.profile.userId != previouslyActiveAccount.profile.userId {
return await handleAndRoute(
.accountBecameActive(
previouslyActiveAccount,
animated: false,
attemptAutomaticBiometricUnlock: false,
didSwitchAccountAutomatically: false
)
)
}
if userInitiated,
let accounts = try? await services.stateService.getAccounts(),
let next = accounts.first {
return await switchAccountRedirect(isAutomatic: true, userId: next.profile.userId)
} else {
return .landing
}
} catch {
services.errorReporter.log(error: error)
if let previouslyActiveAccount {
return await handleAndRoute(
.accountBecameActive(
previouslyActiveAccount,
animated: false,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: false
)
)
} else {
return .landing
}
}
}
/// Handles the `.didStart`route and redirects the user to the correct screen based on active account state.
///
/// - Returns: A redirect to either `.landing`, `prepareAndRedirect(.didTimeout())`,
/// or `prepareAndRedirect(.vaultUnlock())`.
///
func preparedStartRoute() async -> AuthRoute {
guard let activeAccount = try? await configureActiveAccount(shouldSwitchAutomatically: true) else {
// If no account can be set to active, go to the landing screen.
return .landing
}
// Check for the `onAppRestart` timeout condition.
let vaultTimeout = try? await services.vaultTimeoutService
.sessionTimeoutValue(userId: activeAccount.profile.userId)
if vaultTimeout == .onAppRestart {
return await handleAndRoute(.didTimeout(userId: activeAccount.profile.userId))
}
// Setup the unlock route for the active account.
let event = AuthEvent.accountBecameActive(
activeAccount,
animated: false,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: false
)
// Redirect the vault unlock screen if needed.
return await handleAndRoute(event)
}
/// Handles the `.didTimeout`route and redirects the user to the correct screen based on active account state.
///
/// - Returns: A redirect to either `.didTimeout()`, `.landing`, or `prepareAndRedirect(.vaultUnlock())`.
///
func timeoutRedirect(userId: String) async -> Route {
do {
// Ensure the timeout interval isn't `.never` and that the user has a timeout action.
let vaultTimeoutInterval = try await services.vaultTimeoutService.sessionTimeoutValue(userId: userId)
guard vaultTimeoutInterval != .never,
let action = try? await services.stateService.getTimeoutAction(userId: userId) else {
// If we have timed out a user with `.never` as a timeout or no timeout action,
// no redirect is needed.
return .complete
}
// Check the timeout action for the user.
switch action {
case .lock:
// If there is a timeout and the user has a lock vault action,
// return `.vaultUnlock`.
await services.authRepository.lockVault(userId: userId)
guard let activeAccount = try? await services.authRepository.getAccount() else {
return .landing
}
// Setup the check route for the active account.
let event = AuthEvent.accountBecameActive(
activeAccount,
animated: false,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: false
)
return await handleAndRoute(event)
case .logout:
// If there is a timeout and the user has a logout vault action,
// log out the user.
try await services.authRepository.logout(userId: userId)
// Go to landing.
return .landing
}
} catch {
services.errorReporter.log(error: error)
// Go to landing.
return .landing
}
}
/// Configures state and suggests a redirect for the switch accounts route.
///
/// - Parameters:
/// - isUserInitiated: Did the user trigger the account switch?
/// - userId: The user Id of the selected account.
/// - Returns: A suggested route for the active account with state pre-configured.
///
func switchAccountRedirect(isAutomatic: Bool, userId: String) async -> AuthRoute {
if let account = try? await services.authRepository.getAccount(),
userId == account.profile.userId {
return await handleAndRoute(
.accountBecameActive(
account,
animated: false,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: false
)
)
}
do {
let activeAccount = try await services.authRepository.setActiveAccount(userId: userId)
// Setup the unlock route for the active account.
let event = AuthEvent.accountBecameActive(
activeAccount,
animated: false,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: isAutomatic
)
return await handleAndRoute(event)
} catch {
services.errorReporter.log(error: error)
return .landing
}
}
/// Configures state and suggests a redirect for the `.vaultUnlock` route.
///
/// - Parameters:
/// - activeAccount: The active account.
/// - animated: If the suggested route can be animated, use this value.
/// - shouldAttemptAutomaticBiometricUnlock: If the route uses automatic bioemtrics unlock,
/// this value enables or disables the feature.
/// - shouldAttemptAccountSwitch: Should the application automatically switch accounts for the user?
/// - Returns: A suggested route for the active account with state pre-configured.
///
func vaultUnlockRedirect(
_ activeAccount: Account,
animated: Bool,
attemptAutomaticBiometricUnlock: Bool,
didSwitchAccountAutomatically: Bool
) async -> AuthRoute {
let userId = activeAccount.profile.userId
do {
// Check for Never Lock.
let isLocked = try? await services.authRepository.isLocked(userId: userId)
let vaultTimeout = try? await services.vaultTimeoutService.sessionTimeoutValue(userId: userId)
switch (vaultTimeout, isLocked) {
case (.never, true):
// If the user has enabled Never Lock, but the vault is locked,
// unlock the vault and return `.complete`.
try await services.authRepository.unlockVaultWithNeverlockKey()
return .complete
case (_, false):
// If the vault is unlocked, return `.complete`.
return .complete
default:
// Otherwise, return `.vaultUnlock`.
return .vaultUnlock(
activeAccount,
animated: animated,
attemptAutomaticBiometricUnlock: attemptAutomaticBiometricUnlock,
didSwitchAccountAutomatically: didSwitchAccountAutomatically
)
}
} catch {
// In case of an error, go to `.vaultUnlock` for the active user.
services.errorReporter.log(error: error)
return .vaultUnlock(
activeAccount,
animated: animated,
attemptAutomaticBiometricUnlock: attemptAutomaticBiometricUnlock,
didSwitchAccountAutomatically: didSwitchAccountAutomatically
)
}
}
}

View File

@ -17,7 +17,7 @@ class LandingProcessor: StateProcessor<LandingState, LandingAction, LandingEffec
// MARK: Private Properties
/// The coordinator that handles navigation.
private let coordinator: AnyCoordinator<AuthRoute>
private let coordinator: AnyCoordinator<AuthRoute, AuthEvent>
/// The services required by this processor.
private let services: Services
@ -32,7 +32,7 @@ class LandingProcessor: StateProcessor<LandingState, LandingAction, LandingEffec
/// - state: The initial state of the processor.
///
init(
coordinator: AnyCoordinator<AuthRoute>,
coordinator: AnyCoordinator<AuthRoute, AuthEvent>,
services: Services,
state: LandingState
) {
@ -113,10 +113,10 @@ class LandingProcessor: StateProcessor<LandingState, LandingAction, LandingEffec
do {
// Lock the vault of the selected account.
let activeAccountId = try await self.services.authRepository.getActiveAccount().userId
await self.services.authRepository.lockVault(userId: account.userId)
await self.coordinator.handleEvent(.action(.lockVault(userId: account.userId)))
// No navigation is necessary, since the user is already on the landing view
// view, but if it was the non-active account, display a success toast
// No navigation is necessary, since the user is already on the unlock
// vault view, but if it was the non-active account, display a success toast
// and update the profile switcher view.
if account.userId != activeAccountId {
self.state.toast = Toast(text: Localizations.accountLockedSuccessfully)
@ -127,19 +127,23 @@ class LandingProcessor: StateProcessor<LandingState, LandingAction, LandingEffec
}
}, logoutAction: {
// Confirm logging out.
self.coordinator.showAlert(.logoutConfirmation {
self.coordinator.showAlert(.logoutConfirmation { [weak self] in
guard let self else { return }
do {
let activeAccountId = try await self.services.authRepository.getActiveAccount().userId
try await self.services.authRepository.logout(userId: account.userId)
// Log out of the selected account.
let activeAccountId = try await services.authRepository.getActiveAccount().userId
await coordinator.handleEvent(.action(.logout(userId: account.userId, userInitiated: true)))
// No navigation is necessary, since the user is already on the landing view, but if it was the
// non-active account, display a success toast and update the profile switcher view.
if activeAccountId != account.userId {
self.state.toast = Toast(text: Localizations.accountLoggedOutSuccessfully)
await self.refreshProfileState()
// If that account was not active,
// show a toast that the account was logged out successfully.
if account.userId != activeAccountId {
state.toast = Toast(text: Localizations.accountLoggedOutSuccessfully)
// Update the profile switcher view.
await refreshProfileState()
}
} catch {
self.services.errorReporter.log(error: error)
services.errorReporter.log(error: error)
}
})
}))
@ -150,9 +154,17 @@ class LandingProcessor: StateProcessor<LandingState, LandingAction, LandingEffec
///
private func didTapProfileSwitcherItem(_ selectedAccount: ProfileSwitcherItem) {
defer { state.profileSwitcherState.isVisible = false }
coordinator.navigate(
to: .switchAccount(userId: selectedAccount.userId)
)
guard selectedAccount.userId != state.profileSwitcherState.activeAccountId else { return }
Task {
await coordinator.handleEvent(
.action(
.switchAccount(
isAutomatic: false,
userId: selectedAccount.userId
)
)
)
}
}
/// Sets the region to the last used region.

View File

@ -9,7 +9,7 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
var appSettingsStore: MockAppSettingsStore!
var authRepository: MockAuthRepository!
var coordinator: MockCoordinator<AuthRoute>!
var coordinator: MockCoordinator<AuthRoute, AuthEvent>!
var environmentService: MockEnvironmentService!
var errorReporter: MockErrorReporter!
var subject: LandingProcessor!
@ -22,7 +22,7 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
appSettingsStore = MockAppSettingsStore()
authRepository = MockAuthRepository()
coordinator = MockCoordinator<AuthRoute>()
coordinator = MockCoordinator<AuthRoute, AuthEvent>()
environmentService = MockEnvironmentService()
errorReporter = MockErrorReporter()
stateService = MockStateService()
@ -113,9 +113,9 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
/// `perform(.appeared)` with an active account and accounts should yield a profile switcher state.
func test_perform_appeared_profiles_single_active() async {
let profile = ProfileSwitcherItem()
authRepository.accountsResult = .success([profile])
authRepository.activeAccountResult = .success(profile)
let profile = ProfileSwitcherItem.fixture()
authRepository.profileSwitcherItemsResult = .success([profile])
authRepository.activeProfileSwitcherItemResult = .success(profile)
await subject.perform(.appeared)
XCTAssertEqual([], subject.state.profileSwitcherState.alternateAccounts)
@ -126,9 +126,9 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
/// `perform(.appeared)`
/// Mismatched active account and accounts should yield an empty profile switcher state.
func test_perform_appeared_mismatch() async {
let profile = ProfileSwitcherItem()
authRepository.accountsResult = .success([])
authRepository.activeAccountResult = .success(profile)
let profile = ProfileSwitcherItem.fixture()
authRepository.profileSwitcherItemsResult = .success([])
authRepository.activeProfileSwitcherItemResult = .success(profile)
await subject.perform(.appeared)
XCTAssertEqual(
@ -149,9 +149,9 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
/// `perform(.appeared)` with an active account and accounts should yield a profile switcher state.
func test_perform_appeared_single_active() async {
let profile = ProfileSwitcherItem()
authRepository.accountsResult = .success([profile])
authRepository.activeAccountResult = .success(profile)
let profile = ProfileSwitcherItem.fixture()
authRepository.profileSwitcherItemsResult = .success([profile])
authRepository.activeProfileSwitcherItemResult = .success(profile)
await subject.perform(.appeared)
XCTAssertEqual([], subject.state.profileSwitcherState.alternateAccounts)
@ -162,8 +162,8 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
/// `perform(.appeared)`
/// No active account and accounts should yield a profile switcher state without an active account.
func test_perform_refresh_profiles_single_notActive() async {
let profile = ProfileSwitcherItem()
authRepository.accountsResult = .success([profile])
let profile = ProfileSwitcherItem.fixture()
authRepository.profileSwitcherItemsResult = .success([profile])
await subject.perform(.appeared)
XCTAssertEqual(
@ -180,10 +180,10 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
/// `perform(.appeared)`:
/// An active account and multiple accounts should yield a profile switcher state.
func test_perform_refresh_profiles_single_multiAccount() async {
let profile = ProfileSwitcherItem()
let alternate = ProfileSwitcherItem()
authRepository.accountsResult = .success([profile, alternate])
authRepository.activeAccountResult = .success(profile)
let profile = ProfileSwitcherItem.fixture()
let alternate = ProfileSwitcherItem.fixture()
authRepository.profileSwitcherItemsResult = .success([profile, alternate])
authRepository.activeProfileSwitcherItemResult = .success(profile)
await subject.perform(.appeared)
XCTAssertEqual([alternate], subject.state.profileSwitcherState.alternateAccounts)
@ -193,8 +193,8 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
/// `perform(.profileSwitcher(.rowAppeared))` should not update the state for add Account
func test_perform_rowAppeared_add() async {
let profile = ProfileSwitcherItem()
let alternate = ProfileSwitcherItem()
let profile = ProfileSwitcherItem.fixture()
let alternate = ProfileSwitcherItem.fixture()
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [profile, alternate],
activeAccountId: profile.userId,
@ -208,8 +208,8 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
/// `perform(.profileSwitcher(.rowAppeared))` should not update the state for alternate account
func test_perform_rowAppeared_alternate() async {
let profile = ProfileSwitcherItem()
let alternate = ProfileSwitcherItem()
let profile = ProfileSwitcherItem.fixture()
let alternate = ProfileSwitcherItem.fixture()
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [profile, alternate],
activeAccountId: profile.userId,
@ -223,8 +223,8 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
/// `perform(.profileSwitcher(.rowAppeared))` should update the state for active account
func test_perform_rowAppeared_active() {
let profile = ProfileSwitcherItem()
let alternate = ProfileSwitcherItem()
let profile = ProfileSwitcherItem.fixture()
let alternate = ProfileSwitcherItem.fixture()
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [profile, alternate],
activeAccountId: profile.userId,
@ -417,14 +417,14 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
/// lock the selected account.
func test_receive_accountLongPressed_lock() async throws {
// Set up the mock data.
let activeProfile = ProfileSwitcherItem()
let otherProfile = ProfileSwitcherItem(isUnlocked: true, userId: "42")
let activeProfile = ProfileSwitcherItem.fixture()
let otherProfile = ProfileSwitcherItem.fixture(isUnlocked: true, userId: "42")
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [otherProfile, activeProfile],
activeAccountId: activeProfile.userId,
isVisible: true
)
authRepository.activeAccountResult = .success(activeProfile)
authRepository.activeProfileSwitcherItemResult = .success(activeProfile)
subject.receive(.profileSwitcherAction(.accountLongPressed(otherProfile)))
XCTAssertFalse(subject.state.profileSwitcherState.isVisible)
@ -434,21 +434,24 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
await lockAction.handler?(lockAction, [])
// Verify the results.
XCTAssertEqual(authRepository.lockVaultUserId, otherProfile.userId)
XCTAssertEqual(
coordinator.events.last,
.action(.lockVault(userId: otherProfile.userId))
)
XCTAssertEqual(subject.state.toast?.text, Localizations.accountLockedSuccessfully)
}
/// `receive(_:)` with `.profileSwitcherAction(.accountLongPressed)` records any errors from locking the account.
func test_receive_accountLongPressed_lock_error() async throws {
// Set up the mock data.
let activeProfile = ProfileSwitcherItem()
let otherProfile = ProfileSwitcherItem(isUnlocked: true, userId: "42")
let activeProfile = ProfileSwitcherItem.fixture()
let otherProfile = ProfileSwitcherItem.fixture(isUnlocked: true, userId: "42")
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [otherProfile, activeProfile],
activeAccountId: activeProfile.userId,
isVisible: true
)
authRepository.activeAccountResult = .failure(BitwardenTestError.example)
authRepository.activeProfileSwitcherItemResult = .failure(BitwardenTestError.example)
subject.receive(.profileSwitcherAction(.accountLongPressed(otherProfile)))
XCTAssertFalse(subject.state.profileSwitcherState.isVisible)
@ -465,14 +468,14 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
/// log out of the selected account.
func test_receive_accountLongPressed_logout() async throws {
// Set up the mock data.
let activeProfile = ProfileSwitcherItem()
let otherProfile = ProfileSwitcherItem(userId: "42")
let activeProfile = ProfileSwitcherItem.fixture()
let otherProfile = ProfileSwitcherItem.fixture(userId: "42")
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [otherProfile, activeProfile],
activeAccountId: activeProfile.userId,
isVisible: true
)
authRepository.activeAccountResult = .success(activeProfile)
authRepository.activeProfileSwitcherItemResult = .success(activeProfile)
subject.receive(.profileSwitcherAction(.accountLongPressed(otherProfile)))
XCTAssertFalse(subject.state.profileSwitcherState.isVisible)
@ -486,22 +489,25 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
await confirmAction.handler?(confirmAction, [])
// Verify the results.
XCTAssertEqual(authRepository.logoutUserId, otherProfile.userId)
XCTAssertEqual(subject.state.toast?.text, Localizations.accountLoggedOutSuccessfully)
XCTAssertEqual(
coordinator.events.last,
.action(.logout(userId: otherProfile.userId, userInitiated: true))
)
}
/// `receive(_:)` with `.profileSwitcherAction(.accountLongPressed)` records any errors from logging out the
/// account.
func test_receive_accountLongPressed_logout_error() async throws {
// Set up the mock data.
let activeProfile = ProfileSwitcherItem()
let otherProfile = ProfileSwitcherItem(userId: "42")
let activeProfile = ProfileSwitcherItem.fixture()
let otherProfile = ProfileSwitcherItem.fixture(userId: "42")
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [otherProfile, activeProfile],
activeAccountId: activeProfile.userId,
isVisible: true
)
authRepository.activeAccountResult = .failure(BitwardenTestError.example)
authRepository.activeProfileSwitcherItemResult = .failure(BitwardenTestError.example)
subject.receive(.profileSwitcherAction(.accountLongPressed(otherProfile)))
XCTAssertFalse(subject.state.profileSwitcherState.isVisible)
@ -518,11 +524,12 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
XCTAssertEqual(errorReporter.errors.last as? BitwardenTestError, .example)
}
/// `receive(_:)` with `.profileSwitcherAction(.accountPressed)` updates the state to reflect the changes.
/// `receive(_:)` with `.profileSwitcherAction(.accountPressed)` with the active account
/// dismisses the profile switcher.
func test_receive_accountPressed_active_unlocked() {
let profile = ProfileSwitcherItem()
authRepository.accountsResult = .success([profile])
authRepository.activeAccountResult = .success(profile)
let profile = ProfileSwitcherItem.fixture()
authRepository.profileSwitcherItemsResult = .success([profile])
authRepository.activeProfileSwitcherItemResult = .success(profile)
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [profile],
activeAccountId: profile.userId,
@ -538,19 +545,20 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
XCTAssertNotNil(subject.state.profileSwitcherState)
XCTAssertFalse(subject.state.profileSwitcherState.isVisible)
XCTAssertEqual(
coordinator.routes,
[.switchAccount(userId: profile.userId)]
coordinator.events,
[]
)
}
/// `receive(_:)` with `.profileSwitcherAction(.accountPressed)` updates the state to reflect the changes.
/// `receive(_:)` with `.profileSwitcherAction(.accountPressed)` with the active account
/// dismisses the profile switcher.
func test_receive_accountPressed_active_locked() {
let profile = ProfileSwitcherItem(isUnlocked: false)
let profile = ProfileSwitcherItem.fixture(isUnlocked: false)
let account = Account.fixture(profile: .fixture(
userId: profile.userId
))
authRepository.accountsResult = .success([profile])
authRepository.activeAccountResult = .success(profile)
authRepository.profileSwitcherItemsResult = .success([profile])
authRepository.activeProfileSwitcherItemResult = .success(profile)
authRepository.accountForItemResult = .success(account)
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [profile],
@ -567,19 +575,19 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
XCTAssertNotNil(subject.state.profileSwitcherState)
XCTAssertFalse(subject.state.profileSwitcherState.isVisible)
XCTAssertEqual(
coordinator.routes,
[.switchAccount(userId: profile.userId)]
coordinator.events,
[]
)
}
/// `receive(_:)` with `.profileSwitcherAction(.accountPressed)` updates the state to reflect the changes.
func test_receive_accountPressed_alternateUnlocked() {
let profile = ProfileSwitcherItem()
let active = ProfileSwitcherItem()
let profile = ProfileSwitcherItem.fixture()
let active = ProfileSwitcherItem.fixture()
let account = Account.fixture(profile: .fixture(
userId: profile.userId
))
authRepository.accountsResult = .success([active, profile])
authRepository.profileSwitcherItemsResult = .success([active, profile])
authRepository.accountForItemResult = .success(account)
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [profile, active],
@ -590,25 +598,25 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
let task = Task {
subject.receive(.profileSwitcherAction(.accountPressed(profile)))
}
waitFor(!subject.state.profileSwitcherState.isVisible)
waitFor(!coordinator.events.isEmpty)
task.cancel()
XCTAssertNotNil(subject.state.profileSwitcherState)
XCTAssertFalse(subject.state.profileSwitcherState.isVisible)
XCTAssertEqual(
coordinator.routes,
[.switchAccount(userId: profile.userId)]
coordinator.events,
[.action(.switchAccount(isAutomatic: false, userId: profile.userId))]
)
}
/// `receive(_:)` with `.profileSwitcherAction(.accountPressed)` updates the state to reflect the changes.
func test_receive_accountPressed_alternateLocked() {
let profile = ProfileSwitcherItem(isUnlocked: false)
let active = ProfileSwitcherItem()
let profile = ProfileSwitcherItem.fixture(isUnlocked: false)
let active = ProfileSwitcherItem.fixture()
let account = Account.fixture(profile: .fixture(
userId: profile.userId
))
authRepository.accountsResult = .success([active, profile])
authRepository.profileSwitcherItemsResult = .success([active, profile])
authRepository.accountForItemResult = .success(account)
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [profile, active],
@ -619,22 +627,22 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
let task = Task {
subject.receive(.profileSwitcherAction(.accountPressed(profile)))
}
waitFor(!subject.state.profileSwitcherState.isVisible)
waitFor(!coordinator.events.isEmpty)
task.cancel()
XCTAssertNotNil(subject.state.profileSwitcherState)
XCTAssertFalse(subject.state.profileSwitcherState.isVisible)
XCTAssertEqual(
coordinator.routes,
[.switchAccount(userId: profile.userId)]
coordinator.events,
[.action(.switchAccount(isAutomatic: false, userId: profile.userId))]
)
}
/// `receive(_:)` with `.profileSwitcherAction(.accountPressed)` updates the state to reflect the changes.
func test_receive_accountPressed_noMatch() {
let profile = ProfileSwitcherItem()
let active = ProfileSwitcherItem()
authRepository.accountsResult = .success([active])
let profile = ProfileSwitcherItem.fixture()
let active = ProfileSwitcherItem.fixture()
authRepository.profileSwitcherItemsResult = .success([active])
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [profile, active],
activeAccountId: active.userId,
@ -644,20 +652,20 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
let task = Task {
subject.receive(.profileSwitcherAction(.accountPressed(profile)))
}
waitFor(!subject.state.profileSwitcherState.isVisible)
waitFor(!coordinator.events.isEmpty)
task.cancel()
XCTAssertNotNil(subject.state.profileSwitcherState)
XCTAssertFalse(subject.state.profileSwitcherState.isVisible)
XCTAssertEqual(
coordinator.routes,
[.switchAccount(userId: profile.userId)]
coordinator.events,
[.action(.switchAccount(isAutomatic: false, userId: profile.userId))]
)
}
/// `receive(_:)` with `.profileSwitcherAction(.addAccountPressed)` updates the state to reflect the changes.
func test_receive_addAccountPressed() {
let active = ProfileSwitcherItem()
let active = ProfileSwitcherItem.fixture()
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [active],
activeAccountId: active.userId,
@ -677,7 +685,7 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
/// `receive(_:)` with `.profileSwitcherAction(.backgroundPressed)` updates the state to reflect the changes.
func test_receive_backgroundPressed() {
let active = ProfileSwitcherItem()
let active = ProfileSwitcherItem.fixture()
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [active],
activeAccountId: active.userId,
@ -697,7 +705,7 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
/// `receive(_:)` with `.profileSwitcherAction(.scrollOffset)` updates the state to reflect the changes.
func test_receive_scrollOffset() {
let active = ProfileSwitcherItem()
let active = ProfileSwitcherItem.fixture()
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [active],
activeAccountId: active.userId,

View File

@ -183,6 +183,7 @@ struct LandingView_Previews: PreviewProvider {
accounts: [
ProfileSwitcherItem(
email: "max.protecc@bitwarden.com",
isUnlocked: false,
userId: "123",
userInitials: "MP"
),
@ -208,6 +209,7 @@ struct LandingView_Previews: PreviewProvider {
accounts: [
ProfileSwitcherItem(
email: "max.protecc@bitwarden.com",
isUnlocked: false,
userId: "123",
userInitials: "MP"
),

View File

@ -106,7 +106,7 @@ class LandingViewTests: BitwardenTestCase {
/// Check the snapshot for the profiles visible
func test_snapshot_profilesVisible() {
let account = ProfileSwitcherItem(
let account = ProfileSwitcherItem.fixture(
email: "extra.warden@bitwarden.com",
userInitials: "EW"
)
@ -122,7 +122,7 @@ class LandingViewTests: BitwardenTestCase {
/// Check the snapshot for the profiles closed
func test_snapshot_profilesClosed() {
let account = ProfileSwitcherItem(
let account = ProfileSwitcherItem.fixture(
email: "extra.warden@bitwarden.com",
userInitials: "EW"
)

View File

@ -18,7 +18,7 @@ final class SelfHostedProcessor: StateProcessor<SelfHostedState, SelfHostedActio
// MARK: Private Properties
/// The coordinator that handles navigation.
private let coordinator: AnyCoordinator<AuthRoute>
private let coordinator: AnyCoordinator<AuthRoute, AuthEvent>
/// The delegate for the processor that is notified when the user saves their environment settings.
private weak var delegate: SelfHostedProcessorDelegate?
@ -34,7 +34,7 @@ final class SelfHostedProcessor: StateProcessor<SelfHostedState, SelfHostedActio
/// - state: The initial state of the processor.
///
init(
coordinator: AnyCoordinator<AuthRoute>,
coordinator: AnyCoordinator<AuthRoute, AuthEvent>,
delegate: SelfHostedProcessorDelegate?,
state: SelfHostedState
) {

View File

@ -5,14 +5,14 @@ import XCTest
class SelfHostedProcessorTests: BitwardenTestCase {
// MARK: Properties
var coordinator: MockCoordinator<AuthRoute>!
var coordinator: MockCoordinator<AuthRoute, AuthEvent>!
var delegate: MockSelfHostedProcessorDelegate!
var subject: SelfHostedProcessor!
// MARK: Setup and Teardown
override func setUp() {
coordinator = MockCoordinator<AuthRoute>()
coordinator = MockCoordinator<AuthRoute, AuthEvent>()
delegate = MockSelfHostedProcessorDelegate()
subject = SelfHostedProcessor(
coordinator: coordinator.asAnyCoordinator(),

View File

@ -36,7 +36,7 @@ class LoginProcessor: StateProcessor<LoginState, LoginAction, LoginEffect> {
// MARK: Private Properties
/// The `Coordinator` that handles navigation.
private var coordinator: AnyCoordinator<AuthRoute>
private var coordinator: AnyCoordinator<AuthRoute, AuthEvent>
/// A flag indicating if this is the first time that the view has appeared.
///
@ -56,7 +56,7 @@ class LoginProcessor: StateProcessor<LoginState, LoginAction, LoginEffect> {
/// - state: The initial state of the processor.
///
init(
coordinator: AnyCoordinator<AuthRoute>,
coordinator: AnyCoordinator<AuthRoute, AuthEvent>,
services: Services,
state: LoginState
) {

View File

@ -13,7 +13,7 @@ class LoginProcessorTests: BitwardenTestCase {
var authService: MockAuthService!
var captchaService: MockCaptchaService!
var client: MockHTTPClient!
var coordinator: MockCoordinator<AuthRoute>!
var coordinator: MockCoordinator<AuthRoute, AuthEvent>!
var errorReporter: MockErrorReporter!
var subject: LoginProcessor!

View File

@ -15,7 +15,7 @@ final class LoginWithDeviceProcessor: StateProcessor<
// MARK: Properties
/// The coordinator used for navigation.
private let coordinator: AnyCoordinator<AuthRoute>
private let coordinator: AnyCoordinator<AuthRoute, AuthEvent>
/// The services used by this processor.
let services: Services
@ -30,7 +30,7 @@ final class LoginWithDeviceProcessor: StateProcessor<
/// - state: The initial state of the processor.
///
init(
coordinator: AnyCoordinator<AuthRoute>,
coordinator: AnyCoordinator<AuthRoute, AuthEvent>,
services: Services,
state: LoginWithDeviceState
) {

View File

@ -6,7 +6,7 @@ class LoginWithDeviceProcessorTests: BitwardenTestCase {
// MARK: Properties
var authService: MockAuthService!
var coordinator: MockCoordinator<AuthRoute>!
var coordinator: MockCoordinator<AuthRoute, AuthEvent>!
var errorReporter: MockErrorReporter!
var subject: LoginWithDeviceProcessor!
@ -16,7 +16,7 @@ class LoginWithDeviceProcessorTests: BitwardenTestCase {
super.setUp()
authService = MockAuthService()
coordinator = MockCoordinator<AuthRoute>()
coordinator = MockCoordinator<AuthRoute, AuthEvent>()
errorReporter = MockErrorReporter()
subject = LoginWithDeviceProcessor(

View File

@ -34,7 +34,7 @@ final class SingleSignOnProcessor: StateProcessor<SingleSignOnState, SingleSignO
// MARK: Properties
/// The coordinator used to manage navigation.
private let coordinator: AnyCoordinator<AuthRoute>
private let coordinator: AnyCoordinator<AuthRoute, AuthEvent>
/// The services used by this processor.
private let services: Services
@ -49,7 +49,7 @@ final class SingleSignOnProcessor: StateProcessor<SingleSignOnState, SingleSignO
/// - state: The initial state of the processor.
///
init(
coordinator: AnyCoordinator<AuthRoute>,
coordinator: AnyCoordinator<AuthRoute, AuthEvent>,
services: Services,
state: SingleSignOnState
) {

View File

@ -7,7 +7,7 @@ class SingleSignOnProcessorTests: BitwardenTestCase {
var authService: MockAuthService!
var client: MockHTTPClient!
var coordinator: MockCoordinator<AuthRoute>!
var coordinator: MockCoordinator<AuthRoute, AuthEvent>!
var errorReporter: MockErrorReporter!
var stateService: MockStateService!
var subject: SingleSignOnProcessor!
@ -19,7 +19,7 @@ class SingleSignOnProcessorTests: BitwardenTestCase {
authService = MockAuthService()
client = MockHTTPClient()
coordinator = MockCoordinator<AuthRoute>()
coordinator = MockCoordinator<AuthRoute, AuthEvent>()
errorReporter = MockErrorReporter()
stateService = MockStateService()
let services = ServiceContainer.withMocks(

View File

@ -15,7 +15,7 @@ final class TwoFactorAuthProcessor: StateProcessor<TwoFactorAuthState, TwoFactor
// MARK: Properties
/// The `Coordinator` that handles navigation.
private let coordinator: AnyCoordinator<AuthRoute>
private let coordinator: AnyCoordinator<AuthRoute, AuthEvent>
/// The services used by the processor.
private let services: Services
@ -30,7 +30,7 @@ final class TwoFactorAuthProcessor: StateProcessor<TwoFactorAuthState, TwoFactor
/// - state: The initial state of the processor.
///
init(
coordinator: AnyCoordinator<AuthRoute>,
coordinator: AnyCoordinator<AuthRoute, AuthEvent>,
services: Services,
state: TwoFactorAuthState
) {
@ -104,7 +104,7 @@ final class TwoFactorAuthProcessor: StateProcessor<TwoFactorAuthState, TwoFactor
}
/// Attempt to login.
private func login(captchaToken: String? = nil) async {
private func login(captchaToken: String? = nil) async { // swiftlint:disable:this function_body_length
// Hide the loading overlay when exiting this method, in case it hasn't been hidden yet.
defer { coordinator.hideLoadingOverlay() }
@ -141,6 +141,7 @@ final class TwoFactorAuthProcessor: StateProcessor<TwoFactorAuthState, TwoFactor
to: .vaultUnlock(
account,
animated: false,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: false
)
)

View File

@ -11,7 +11,7 @@ class TwoFactorAuthProcessorTests: BitwardenTestCase {
var authRepository: MockAuthRepository!
var authService: MockAuthService!
var captchaService: MockCaptchaService!
var coordinator: MockCoordinator<AuthRoute>!
var coordinator: MockCoordinator<AuthRoute, AuthEvent>!
var errorReporter: MockErrorReporter!
var subject: TwoFactorAuthProcessor!
@ -23,7 +23,7 @@ class TwoFactorAuthProcessorTests: BitwardenTestCase {
authRepository = MockAuthRepository()
authService = MockAuthService()
captchaService = MockCaptchaService()
coordinator = MockCoordinator<AuthRoute>()
coordinator = MockCoordinator<AuthRoute, AuthEvent>()
errorReporter = MockErrorReporter()
subject = TwoFactorAuthProcessor(

View File

@ -10,7 +10,7 @@ class PasswordHintProcessor: StateProcessor<PasswordHintState, PasswordHintActio
// MARK: Private Properties
/// The coordinator that handles navigation.
private let coordinator: AnyCoordinator<AuthRoute>
private let coordinator: AnyCoordinator<AuthRoute, AuthEvent>
/// The services required by this processor.
private let services: Services
@ -24,7 +24,7 @@ class PasswordHintProcessor: StateProcessor<PasswordHintState, PasswordHintActio
/// - state: The initial state of the processor.
///
init(
coordinator: AnyCoordinator<AuthRoute>,
coordinator: AnyCoordinator<AuthRoute, AuthEvent>,
services: Services,
state: PasswordHintState
) {

View File

@ -8,7 +8,7 @@ class PasswordHintProcessorTests: BitwardenTestCase {
// MARK: Properties
var httpClient: MockHTTPClient!
var coordinator: MockCoordinator<AuthRoute>!
var coordinator: MockCoordinator<AuthRoute, AuthEvent>!
var subject: PasswordHintProcessor!
// MARK: Setup & Teardown

View File

@ -1,30 +1,50 @@
import SwiftUI
@testable import BitwardenShared
extension ProfileSwitcherItem {
static let anneAccount = ProfileSwitcherItem(
static let anneAccount = ProfileSwitcherItem.fixture(
color: .purple,
email: "anne.account@bitwarden.com",
userInitials: "AA"
)
}
extension ProfileSwitcherItem {
static func fixture(
color: Color = .purple,
email: String = "",
isUnlocked: Bool = false,
userId: String = UUID().uuidString,
userInitials: String = ".."
) -> ProfileSwitcherItem {
ProfileSwitcherItem(
color: color,
email: email,
isUnlocked: isUnlocked,
userId: userId,
userInitials: userInitials
)
}
}
extension ProfileSwitcherState {
static let subMaximumAccounts = ProfileSwitcherState(
accounts: [
.anneAccount,
ProfileSwitcherItem(
.fixture(
color: .yellow,
email: "bonus.bridge@bitwarden.com",
isUnlocked: true,
userInitials: "BB"
),
ProfileSwitcherItem(
.fixture(
color: .teal,
email: "concurrent.claim@bitarden.com",
isUnlocked: true,
userInitials: "CC"
),
ProfileSwitcherItem(
.fixture(
color: .indigo,
email: "double.dip@bitwarde.com",
isUnlocked: true,
@ -38,25 +58,25 @@ extension ProfileSwitcherState {
static let maximumAccounts = ProfileSwitcherState(
accounts: [
.anneAccount,
ProfileSwitcherItem(
.fixture(
color: .yellow,
email: "bonus.bridge@bitwarden.com",
isUnlocked: true,
userInitials: "BB"
),
ProfileSwitcherItem(
.fixture(
color: .teal,
email: "concurrent.claim@bitarden.com",
isUnlocked: true,
userInitials: "CC"
),
ProfileSwitcherItem(
.fixture(
color: .indigo,
email: "double.dip@bitwarde.com",
isUnlocked: true,
userInitials: "DD"
),
ProfileSwitcherItem(
.fixture(
color: .green,
email: "extra.edition@bitwarden.com",
isUnlocked: true,

View File

@ -5,18 +5,28 @@ import SwiftUI
/// An object that defines account profile information relevant to account switching
/// Part of `ProfileSwitcherState`.
struct ProfileSwitcherItem: Equatable, Hashable {
/// A placeholder empty item.
static var empty: ProfileSwitcherItem {
ProfileSwitcherItem(
email: "",
isUnlocked: false,
userId: "",
userInitials: ".."
)
}
/// The color associated with the profile
var color = Color.purple
/// The account's email.
var email = ""
var email: String
/// The the locked state of an account profile
var isUnlocked = false
var isUnlocked: Bool
/// The user's identifier
var userId = UUID().uuidString
var userId: String
/// The user's initials.
var userInitials = ".."
var userInitials: String
}

View File

@ -191,6 +191,7 @@ struct ProfileSwitcherRow_Previews: PreviewProvider {
color: .purple,
email: "anne.account@bitwarden.com",
isUnlocked: true,
userId: "1",
userInitials: "AA"
)
@ -198,6 +199,7 @@ struct ProfileSwitcherRow_Previews: PreviewProvider {
color: .purple,
email: "anne.account@bitwarden.com",
isUnlocked: false,
userId: "2",
userInitials: "AA"
)

View File

@ -12,6 +12,7 @@ final class ProfileSwitcherRowTests: BitwardenTestCase {
color: .purple,
email: "anne.account@bitwarden.com",
isUnlocked: true,
userId: "1",
userInitials: "AA"
)
@ -19,6 +20,7 @@ final class ProfileSwitcherRowTests: BitwardenTestCase {
color: .purple,
email: "anne.account@bitwarden.com",
isUnlocked: false,
userId: "2",
userInitials: "AA"
)

View File

@ -41,7 +41,7 @@ final class ProfileSwitcherStateTests: BitwardenTestCase {
/// Setting the alternate accounts should succeed
func test_empty_setAlternates_alternatesMatch() {
let newAlternates = [
ProfileSwitcherItem(),
ProfileSwitcherItem.fixture(),
]
subject.accounts = newAlternates
@ -50,7 +50,7 @@ final class ProfileSwitcherStateTests: BitwardenTestCase {
/// Setting the active account id should yield an active account if the id matches an account
func test_empty_setActiveAccountId_found() {
let alternate = ProfileSwitcherItem()
let alternate = ProfileSwitcherItem.fixture()
let newAccounts = [
alternate,
]
@ -70,7 +70,7 @@ final class ProfileSwitcherStateTests: BitwardenTestCase {
/// Tests the current account initials when current account known
func test_currentAccount_userInitials_nonEmpty() {
let alternate = ProfileSwitcherItem(
let alternate = ProfileSwitcherItem.fixture(
userInitials: "TC"
)
let newAccounts = [
@ -94,7 +94,7 @@ final class ProfileSwitcherStateTests: BitwardenTestCase {
/// Passing an account with no active id yields no active account
func test_init_accountsWithoutActive() {
let account = ProfileSwitcherItem()
let account = ProfileSwitcherItem.fixture()
subject = ProfileSwitcherState(
accounts: [account],
activeAccountId: nil,
@ -106,7 +106,7 @@ final class ProfileSwitcherStateTests: BitwardenTestCase {
/// Passing an account and a matching active id yields an active account
func test_init_accountsWithCurrent_accountsMatch() {
let account = ProfileSwitcherItem()
let account = ProfileSwitcherItem.fixture()
subject = ProfileSwitcherState(
accounts: [account],
activeAccountId: account.userId,
@ -120,8 +120,8 @@ final class ProfileSwitcherStateTests: BitwardenTestCase {
/// Tests the init succeeds with current account matching
func test_init_accountsWithCurrent_currentProfilesMatch() {
let account = ProfileSwitcherItem()
let alternate = ProfileSwitcherItem(isUnlocked: true)
let account = ProfileSwitcherItem.fixture()
let alternate = ProfileSwitcherItem.fixture(isUnlocked: true)
let accounts = [
account,
alternate,
@ -150,8 +150,8 @@ final class ProfileSwitcherStateTests: BitwardenTestCase {
/// Tests `shouldSetAccessibilityFocus(for: )` responds to state and row type
func test_shouldSetAccessibilityFocus_addAccount() {
let account = ProfileSwitcherItem()
let alternate = ProfileSwitcherItem(isUnlocked: false)
let account = ProfileSwitcherItem.fixture()
let alternate = ProfileSwitcherItem.fixture(isUnlocked: false)
let alternates = [
alternate,
]
@ -167,8 +167,8 @@ final class ProfileSwitcherStateTests: BitwardenTestCase {
/// Tests `shouldSetAccessibilityFocus(for: )` responds to state and row type
func test_shouldSetAccessibilityFocus_alternate() {
let account = ProfileSwitcherItem()
let alternate = ProfileSwitcherItem(isUnlocked: false)
let account = ProfileSwitcherItem.fixture()
let alternate = ProfileSwitcherItem.fixture(isUnlocked: false)
let alternates = [
alternate,
]
@ -184,8 +184,8 @@ final class ProfileSwitcherStateTests: BitwardenTestCase {
/// Tests `shouldSetAccessibilityFocus(for: )` responds to state and row type
func test_shouldSetAccessibilityFocus_active_visibleAndHasNotSet() {
let active = ProfileSwitcherItem()
let alternate = ProfileSwitcherItem(isUnlocked: false)
let active = ProfileSwitcherItem.fixture()
let alternate = ProfileSwitcherItem.fixture(isUnlocked: false)
let alternates = [
alternate,
]
@ -199,8 +199,8 @@ final class ProfileSwitcherStateTests: BitwardenTestCase {
/// Tests `shouldSetAccessibilityFocus(for: )` responds to state and row type
func test_shouldSetAccessibilityFocus_active_notVisibleAndHasNotSet() {
let active = ProfileSwitcherItem()
let alternate = ProfileSwitcherItem(isUnlocked: false)
let active = ProfileSwitcherItem.fixture()
let alternate = ProfileSwitcherItem.fixture(isUnlocked: false)
let alternates = [
alternate,
]
@ -213,8 +213,8 @@ final class ProfileSwitcherStateTests: BitwardenTestCase {
/// Tests `shouldSetAccessibilityFocus(for: )` responds to state and row type
func test_shouldSetAccessibilityFocus_active_visibleAndHasSet() {
let active = ProfileSwitcherItem()
let alternate = ProfileSwitcherItem(isUnlocked: false)
let active = ProfileSwitcherItem.fixture()
let alternate = ProfileSwitcherItem.fixture(isUnlocked: false)
let alternates = [
alternate,
]

View File

@ -48,6 +48,7 @@ struct ProfileSwitcherToolbarView_Previews: PreviewProvider {
static let selectedAccount = ProfileSwitcherItem(
color: .purple,
email: "anne.account@bitwarden.com",
isUnlocked: true,
userId: "1",
userInitials: "AA"
)

View File

@ -89,26 +89,28 @@ struct ProfileSwitcherView: View {
/// - Parameter accountProfile: A `ProfileSwitcherItem` to display in row format
///
private var selectedProfileSwitcherRow: some View {
ProfileSwitcherRow(store: store.child(
state: { state in
ProfileSwitcherRowState(
shouldTakeAccessibilityFocus: state.isVisible,
showDivider: state.showsAddAccount,
rowType: .active(
state.activeAccountProfile ?? ProfileSwitcherItem()
ProfileSwitcherRow(
store: store.child(
state: { state in
ProfileSwitcherRowState(
shouldTakeAccessibilityFocus: state.isVisible,
showDivider: state.showsAddAccount,
rowType: .active(
state.activeAccountProfile ?? .empty
)
)
)
},
mapAction: { action in
switch action {
case .longPressed:
.accountLongPressed(store.state.activeAccountProfile ?? ProfileSwitcherItem())
case .pressed:
.accountPressed(store.state.activeAccountProfile ?? ProfileSwitcherItem())
}
},
mapEffect: nil
))
},
mapAction: { action in
switch action {
case .longPressed:
.accountLongPressed(store.state.activeAccountProfile ?? .empty)
case .pressed:
.accountPressed(store.state.activeAccountProfile ?? .empty)
}
},
mapEffect: nil
)
)
}
// MARK: Private Methods
@ -150,6 +152,7 @@ struct ProfileSwitcherView_Previews: PreviewProvider {
static let selectedAccount = ProfileSwitcherItem(
color: .purple,
email: "anne.account@bitwarden.com",
isUnlocked: true,
userId: "1",
userInitials: "AA"
)
@ -183,6 +186,7 @@ struct ProfileSwitcherView_Previews: PreviewProvider {
color: .green,
email: "bonus.bridge@bitwarde.com",
isUnlocked: true,
userId: "2",
userInitials: "BB"
),
],
@ -206,18 +210,21 @@ struct ProfileSwitcherView_Previews: PreviewProvider {
color: .yellow,
email: "bonus.bridge@bitwarden.com",
isUnlocked: true,
userId: "2",
userInitials: "BB"
),
ProfileSwitcherItem(
color: .teal,
email: "concurrent.claim@bitarden.com",
isUnlocked: true,
userId: "3",
userInitials: "CC"
),
ProfileSwitcherItem(
color: .indigo,
email: "double.dip@bitwarde.com",
isUnlocked: true,
userId: "4",
userInitials: "DD"
),
],
@ -241,24 +248,28 @@ struct ProfileSwitcherView_Previews: PreviewProvider {
color: .yellow,
email: "bonus.bridge@bitwarden.com",
isUnlocked: true,
userId: "2",
userInitials: "BB"
),
ProfileSwitcherItem(
color: .teal,
email: "concurrent.claim@bitarden.com",
isUnlocked: true,
userId: "3",
userInitials: "CC"
),
ProfileSwitcherItem(
color: .indigo,
email: "double.dip@bitwarde.com",
isUnlocked: true,
userId: "4",
userInitials: "DD"
),
ProfileSwitcherItem(
color: .green,
email: "extra.edition@bitwarden.com",
isUnlocked: false,
userId: "5",
userInitials: "EE"
),
],

View File

@ -65,7 +65,7 @@ class ProfileSwitcherViewTests: BitwardenTestCase {
/// Long pressing an alternative profile row dispatches the `.accountLongPressed` action.
func test_alternateAccountRow_longPress_alternateAccount() throws {
let alternate = ProfileSwitcherItem(
let alternate = ProfileSwitcherItem.fixture(
email: "alternate@bitwarden.com",
userInitials: "NA"
)
@ -86,7 +86,7 @@ class ProfileSwitcherViewTests: BitwardenTestCase {
/// Tapping an alternative profile row dispatches the `.accountPressed` action.
func test_alternateAccountRow_tap_alternateAccount() throws {
let alternate = ProfileSwitcherItem(
let alternate = ProfileSwitcherItem.fixture(
email: "alternate@bitwarden.com",
userInitials: "NA"
)
@ -107,12 +107,12 @@ class ProfileSwitcherViewTests: BitwardenTestCase {
/// Tapping an alternative profile row dispatches the `.accountPressed` action.
func test_alternateAccountRows_tap_alternateEmptyAccount() throws {
let alternate = ProfileSwitcherItem(
let alternate = ProfileSwitcherItem.fixture(
email: "locked@bitwarden.com",
isUnlocked: false,
userInitials: "LA"
)
let secondAlternate = ProfileSwitcherItem()
let secondAlternate = ProfileSwitcherItem.fixture()
let alternateAccounts = [
alternate,
secondAlternate,
@ -149,19 +149,19 @@ class ProfileSwitcherViewTests: BitwardenTestCase {
let state = ProfileSwitcherState(
accounts: [
ProfileSwitcherItem.anneAccount,
ProfileSwitcherItem(
ProfileSwitcherItem.fixture(
color: .yellow,
email: "bonus.bridge@bitwarden.com",
isUnlocked: true,
userInitials: "BB"
),
ProfileSwitcherItem(
ProfileSwitcherItem.fixture(
color: .teal,
email: "concurrent.claim@bitarden.com",
isUnlocked: true,
userInitials: "CC"
),
ProfileSwitcherItem(
ProfileSwitcherItem.fixture(
color: .indigo,
email: "double.dip@bitwarde.com",
isUnlocked: true,
@ -192,19 +192,19 @@ class ProfileSwitcherViewTests: BitwardenTestCase {
processor.state = ProfileSwitcherState(
accounts: [
ProfileSwitcherItem.anneAccount,
ProfileSwitcherItem(
ProfileSwitcherItem.fixture(
color: .yellow,
email: "bonus.bridge@bitwarden.com",
isUnlocked: true,
userInitials: "BB"
),
ProfileSwitcherItem(
ProfileSwitcherItem.fixture(
color: .teal,
email: "concurrent.claim@bitarden.com",
isUnlocked: true,
userInitials: "CC"
),
ProfileSwitcherItem(
ProfileSwitcherItem.fixture(
color: .indigo,
email: "double.dip@bitwarde.com",
isUnlocked: true,
@ -230,20 +230,20 @@ class ProfileSwitcherViewTests: BitwardenTestCase {
func test_snapshot_multiAccount_locked_belowMaximum() {
processor.state = ProfileSwitcherState(
accounts: [
ProfileSwitcherItem(
ProfileSwitcherItem.fixture(
color: .yellow,
email: "bonus.bridge@bitwarden.com",
isUnlocked: false,
userInitials: "BB"
),
ProfileSwitcherItem(
ProfileSwitcherItem.fixture(
color: .teal,
email: "concurrent.claim@bitarden.com",
isUnlocked: false,
userInitials: "CC"
),
ProfileSwitcherItem.anneAccount,
ProfileSwitcherItem(
ProfileSwitcherItem.fixture(
color: .indigo,
email: "double.dip@bitwarde.com",
isUnlocked: false,
@ -259,26 +259,26 @@ class ProfileSwitcherViewTests: BitwardenTestCase {
func test_snapshot_multiAccount_locked_atMaximum() {
processor.state = ProfileSwitcherState(
accounts: [
ProfileSwitcherItem(
ProfileSwitcherItem.fixture(
color: .yellow,
email: "bonus.bridge@bitwarden.com",
isUnlocked: false,
userInitials: "BB"
),
ProfileSwitcherItem(
ProfileSwitcherItem.fixture(
color: .teal,
email: "concurrent.claim@bitarden.com",
isUnlocked: false,
userInitials: "CC"
),
.anneAccount,
ProfileSwitcherItem(
ProfileSwitcherItem.fixture(
color: .indigo,
email: "double.dip@bitwarde.com",
isUnlocked: false,
userInitials: "DD"
),
ProfileSwitcherItem(
ProfileSwitcherItem.fixture(
color: .green,
email: "extra.edition@bitwarden.com",
isUnlocked: false,

View File

@ -19,7 +19,7 @@ class UpdateMasterPasswordProcessor: StateProcessor<
// MARK: Private Properties
/// The `Coordinator` that handles navigation.
private var coordinator: AnyCoordinator<VaultRoute>
private var coordinator: AnyCoordinator<VaultRoute, Void>
/// The services used by this processor.
private var services: Services
@ -34,7 +34,7 @@ class UpdateMasterPasswordProcessor: StateProcessor<
/// - state: The initial state of the processor.
///
init(
coordinator: AnyCoordinator<VaultRoute>,
coordinator: AnyCoordinator<VaultRoute, Void>,
services: Services,
state: UpdateMasterPasswordState
) {

View File

@ -8,7 +8,7 @@ class UpdateMasterPasswordProcessorTests: BitwardenTestCase {
// MARK: Properties
var httpClient: MockHTTPClient!
var coordinator: MockCoordinator<VaultRoute>!
var coordinator: MockCoordinator<VaultRoute, Void>!
var subject: UpdateMasterPasswordProcessor!
// MARK: Setup & Teardown

View File

@ -10,7 +10,7 @@ class VaultUnlockProcessor: StateProcessor<// swiftlint:disable:this type_body_l
// MARK: Types
typealias Services = HasAuthRepository
& HasBiometricsService
& HasBiometricsRepository
& HasErrorReporter
& HasStateService
@ -20,7 +20,7 @@ class VaultUnlockProcessor: StateProcessor<// swiftlint:disable:this type_body_l
private weak var appExtensionDelegate: AppExtensionDelegate?
/// The `Coordinator` that handles navigation.
private var coordinator: AnyCoordinator<AuthRoute>
private var coordinator: AnyCoordinator<AuthRoute, AuthEvent>
/// A flag indicating if the processor should attempt automatic biometric unlock
var shouldAttemptAutomaticBiometricUnlock = false
@ -40,7 +40,7 @@ class VaultUnlockProcessor: StateProcessor<// swiftlint:disable:this type_body_l
///
init(
appExtensionDelegate: AppExtensionDelegate?,
coordinator: AnyCoordinator<AuthRoute>,
coordinator: AnyCoordinator<AuthRoute, AuthEvent>,
services: Services,
state: VaultUnlockState
) {
@ -135,40 +135,28 @@ class VaultUnlockProcessor: StateProcessor<// swiftlint:disable:this type_body_l
}
}
/// Navigates to the appropriate location following a logout.
/// Navigates to the appropriate location following a logout event.
///
/// - Parameters:
/// - accountId: The id of the account that was logged out.
/// - userInitiated: Did the user initiate this logout?
///
private func navigateFollowingLogout(
accountId: String?,
animated: Bool = true,
attemptAutomaticBiometricUnlock: Bool = true,
accountId: String,
userInitiated: Bool
) async {
if userInitiated,
let accounts = try? await services.stateService.getAccounts(),
let nextAccount = accounts.first,
accountId != nextAccount.profile.userId {
do {
let selected = try await services.authRepository.setActiveAccount(userId: nextAccount.profile.userId)
coordinator.navigate(
to: .vaultUnlock(
selected,
animated: animated,
attemptAutomaticBiometricUnlock: attemptAutomaticBiometricUnlock,
didSwitchAccountAutomatically: true
)
)
} catch {
coordinator.navigate(to: .landing)
}
} else {
coordinator.navigate(to: .landing)
}
await coordinator.handleEvent(
.didLogout(
userId: accountId,
userInitiated: userInitiated
)
)
}
/// Loads the async state data for the view
///
private func loadData() async {
state.biometricUnlockStatus = await (try? services.biometricsService.getBiometricUnlockStatus())
state.biometricUnlockStatus = await (try? services.biometricsRepository.getBiometricUnlockStatus())
?? .notAvailable
state.unsuccessfulUnlockAttemptsCount = await services.stateService.getUnsuccessfulUnlockAttempts()
state.isInAppExtension = appExtensionDelegate?.isInAppExtension ?? false
@ -190,18 +178,18 @@ class VaultUnlockProcessor: StateProcessor<// swiftlint:disable:this type_body_l
/// - userInitiated: A Bool indicating if the logout is initiated by a user action.
///
private func logoutUser(resetAttempts: Bool = false, userInitiated: Bool) async {
let accountId = try? await services.stateService.getActiveAccountId()
do {
if resetAttempts {
state.unsuccessfulUnlockAttemptsCount = 0
await services.stateService.setUnsuccessfulUnlockAttempts(0)
}
try await services.authRepository.logout()
await navigateFollowingLogout(accountId: accountId, userInitiated: userInitiated)
} catch {
services.errorReporter.log(error: BitwardenError.logoutError(error: error))
await navigateFollowingLogout(accountId: accountId, userInitiated: userInitiated)
if resetAttempts {
state.unsuccessfulUnlockAttemptsCount = 0
await services.stateService.setUnsuccessfulUnlockAttempts(0)
}
await coordinator.handleEvent(
.action(
.logout(
userId: nil,
userInitiated: userInitiated
)
)
)
}
/// Handles a long press of an account in the profile switcher.
@ -214,7 +202,7 @@ class VaultUnlockProcessor: StateProcessor<// swiftlint:disable:this type_body_l
do {
// Lock the vault of the selected account.
let activeAccountId = try await self.services.authRepository.getActiveAccount().userId
await self.services.authRepository.lockVault(userId: account.userId)
await self.coordinator.handleEvent(.action(.lockVault(userId: account.userId)))
// No navigation is necessary, since the user is already on the unlock
// vault view, but if it was the non-active account, display a success toast
@ -233,14 +221,11 @@ class VaultUnlockProcessor: StateProcessor<// swiftlint:disable:this type_body_l
do {
// Log out of the selected account.
let activeAccountId = try await services.authRepository.getActiveAccount().userId
try await services.authRepository.logout(userId: account.userId)
await coordinator.handleEvent(.action(.logout(userId: account.userId, userInitiated: true)))
// If the selected item was the currently active account,
// switch to the next account or go to langing.
if account.userId == activeAccountId {
await navigateFollowingLogout(accountId: account.userId, userInitiated: true)
} else {
// Otherwise, show the toast that the account was logged out successfully.
// If that account was not active,
// show a toast that the account was logged out successfully.
if account.userId != activeAccountId {
state.toast = Toast(text: Localizations.accountLoggedOutSuccessfully)
// Update the profile switcher view.
@ -258,7 +243,18 @@ class VaultUnlockProcessor: StateProcessor<// swiftlint:disable:this type_body_l
/// - Parameter selectedAccount: The `ProfileSwitcherItem` selected by the user.
///
private func didTapProfileSwitcherItem(_ selectedAccount: ProfileSwitcherItem) {
coordinator.navigate(to: .switchAccount(userId: selectedAccount.userId))
defer { state.profileSwitcherState.isVisible = false }
guard selectedAccount.userId != state.profileSwitcherState.activeAccountId else { return }
Task {
await coordinator.handleEvent(
.action(
.switchAccount(
isAutomatic: false,
userId: selectedAccount.userId
)
)
)
}
state.profileSwitcherState.isVisible = false
}
@ -331,7 +327,7 @@ class VaultUnlockProcessor: StateProcessor<// swiftlint:disable:this type_body_l
/// Attempts to unlock the vault with the user's biometrics
///
private func unlockWithBiometrics() async {
let status = try? await services.biometricsService.getBiometricUnlockStatus()
let status = try? await services.biometricsRepository.getBiometricUnlockStatus()
guard case let .available(_, enabled: enabled, hasValidIntegrity) = status,
enabled,
hasValidIntegrity else {
@ -351,9 +347,13 @@ class VaultUnlockProcessor: StateProcessor<// swiftlint:disable:this type_body_l
await logoutUser(userInitiated: true)
return
}
if case .biometryCancelled = error {
// Do nothing if the user cancels.
return
}
// There is no biometric auth key stored, set user preference to false.
if case .getAuthKeyFailed = error {
try? await services.authRepository.allowBioMetricUnlock(false, userId: nil)
try? await services.authRepository.allowBioMetricUnlock(false)
}
await loadData()
} catch let error as StateServiceError {

View File

@ -1,3 +1,4 @@
import SwiftUI
import XCTest
@testable import BitwardenShared
@ -7,10 +8,10 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
var appExtensionDelegate: MockAppExtensionDelegate!
var authRepository: MockAuthRepository!
var biometricsService: MockBiometricsService!
var biometricsRepository: MockBiometricsRepository!
var errorReporter: MockErrorReporter!
var stateService: MockStateService!
var coordinator: MockCoordinator<AuthRoute>!
var coordinator: MockCoordinator<AuthRoute, AuthEvent>!
var subject: VaultUnlockProcessor!
// MARK: Setup & Teardown
@ -20,7 +21,7 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
appExtensionDelegate = MockAppExtensionDelegate()
authRepository = MockAuthRepository()
biometricsService = MockBiometricsService()
biometricsRepository = MockBiometricsRepository()
coordinator = MockCoordinator()
errorReporter = MockErrorReporter()
stateService = MockStateService()
@ -30,7 +31,7 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
coordinator: coordinator.asAnyCoordinator(),
services: ServiceContainer.withMocks(
authRepository: authRepository,
biometricsService: biometricsService,
biometricsRepository: biometricsRepository,
errorReporter: errorReporter,
stateService: stateService
),
@ -43,7 +44,7 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
appExtensionDelegate = nil
authRepository = nil
biometricsService = nil
biometricsRepository = nil
coordinator = nil
subject = nil
}
@ -54,7 +55,7 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
func test_perform_appeared_biometricUnlockStatus_error() async {
stateService.activeAccount = .fixture()
struct FetchError: Error {}
biometricsService.biometricUnlockStatus = .failure(FetchError())
biometricsRepository.biometricUnlockStatus = .failure(FetchError())
await subject.perform(.appeared)
XCTAssertEqual([], subject.state.profileSwitcherState.alternateAccounts)
@ -67,7 +68,7 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
stateService.activeAccount = .fixture()
struct FetchError: Error {}
let expectedStatus = BiometricsUnlockStatus.available(.touchID, enabled: true, hasValidIntegrity: false)
biometricsService.biometricUnlockStatus = .success(expectedStatus)
biometricsRepository.biometricUnlockStatus = .success(expectedStatus)
await subject.perform(.appeared)
XCTAssertEqual([], subject.state.profileSwitcherState.alternateAccounts)
@ -88,9 +89,9 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
/// `perform(.appeared)`
/// Mismatched active account and accounts should yield an empty profile switcher state.
func test_perform_appeared_profiles_mismatch() async {
let profile = ProfileSwitcherItem()
authRepository.accountsResult = .success([])
authRepository.activeAccountResult = .success(profile)
let profile = ProfileSwitcherItem.fixture()
authRepository.profileSwitcherItemsResult = .success([])
authRepository.activeProfileSwitcherItemResult = .success(profile)
await subject.perform(.appeared)
XCTAssertEqual(
@ -101,9 +102,9 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
/// `perform(.appeared)` with an active account and accounts should yield a profile switcher state.
func test_perform_appeared_profiles_single_active() async {
let profile = ProfileSwitcherItem()
authRepository.accountsResult = .success([profile])
authRepository.activeAccountResult = .success(profile)
let profile = ProfileSwitcherItem.fixture()
authRepository.profileSwitcherItemsResult = .success([profile])
authRepository.activeProfileSwitcherItemResult = .success(profile)
await subject.perform(.appeared)
XCTAssertEqual([], subject.state.profileSwitcherState.alternateAccounts)
@ -114,8 +115,8 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
/// `perform(.appeared)` refreshes the profile switcher and disables add account when running
/// in the app extension.
func test_perform_appeared_refreshProfile_inAppExtension() async {
let profile = ProfileSwitcherItem()
authRepository.accountsResult = .success([profile])
let profile = ProfileSwitcherItem.fixture()
authRepository.profileSwitcherItemsResult = .success([profile])
appExtensionDelegate.isInAppExtension = true
await subject.perform(.appeared)
@ -154,8 +155,8 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
/// `perform(.appeared)`
/// No active account and accounts should yield a profile switcher state without an active account.
func test_perform_refresh_profiles_single_notActive() async {
let profile = ProfileSwitcherItem()
authRepository.accountsResult = .success([profile])
let profile = ProfileSwitcherItem.fixture()
authRepository.profileSwitcherItemsResult = .success([profile])
await subject.perform(.appeared)
XCTAssertEqual(
@ -171,10 +172,10 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
/// `perform(.appeared)`:
/// An active account and multiple accounts should yield a profile switcher state.
func test_perform_refresh_profiles_single_multiAccount() async {
let profile = ProfileSwitcherItem()
let alternate = ProfileSwitcherItem()
authRepository.accountsResult = .success([profile, alternate])
authRepository.activeAccountResult = .success(profile)
let profile = ProfileSwitcherItem.fixture()
let alternate = ProfileSwitcherItem.fixture()
authRepository.profileSwitcherItemsResult = .success([profile, alternate])
authRepository.activeProfileSwitcherItemResult = .success(profile)
await subject.perform(.appeared)
XCTAssertEqual([alternate], subject.state.profileSwitcherState.alternateAccounts)
@ -184,8 +185,8 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
/// `perform(.profileSwitcher(.rowAppeared))` should not update the state for add Account
func test_perform_rowAppeared_add() async {
let profile = ProfileSwitcherItem()
let alternate = ProfileSwitcherItem()
let profile = ProfileSwitcherItem.fixture()
let alternate = ProfileSwitcherItem.fixture()
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [profile, alternate],
activeAccountId: profile.userId,
@ -199,8 +200,8 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
/// `perform(.profileSwitcher(.rowAppeared))` should not update the state for alternate account
func test_perform_rowAppeared_alternate() async {
let profile = ProfileSwitcherItem()
let alternate = ProfileSwitcherItem()
let profile = ProfileSwitcherItem.fixture()
let alternate = ProfileSwitcherItem.fixture()
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [profile, alternate],
activeAccountId: profile.userId,
@ -214,8 +215,8 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
/// `perform(.profileSwitcher(.rowAppeared))` should update the state for active account
func test_perform_rowAppeared_active() {
let profile = ProfileSwitcherItem()
let alternate = ProfileSwitcherItem()
let profile = ProfileSwitcherItem.fixture()
let alternate = ProfileSwitcherItem.fixture()
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [profile, alternate],
activeAccountId: profile.userId,
@ -290,7 +291,7 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
/// `perform(_:)` with `.unlockVault` displays an alert a maximum of 5 times if the master password was incorrect.
/// After the 5th attempt, it logs the user out.
func test_perform_unlockVault_invalidPassword_logout() async throws {
func test_perform_unlockVault_invalidPassword_logout() async throws { // swiftlint:disable:this function_body_length
subject.state.masterPassword = "password"
stateService.activeAccount = .fixture()
XCTAssertEqual(subject.state.unsuccessfulUnlockAttemptsCount, 0)
@ -348,12 +349,16 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
await alert.alertActions[0].handler?(alert.alertActions[0], [])
attemptsInUserDefaults = await stateService.getUnsuccessfulUnlockAttempts()
XCTAssertEqual(attemptsInUserDefaults, 0)
XCTAssertTrue(authRepository.logoutCalled)
XCTAssertEqual(coordinator.routes.last, .landing)
XCTAssertEqual(
coordinator.events.last,
.action(
.logout(userId: nil, userInitiated: true)
)
)
}
/// `perform(_:)` with `.unlockVault` logs error if force logout fails after the 5th unsuccessful attempts.
func test_perform_unlockVault_invalidPassword_logoutError() async throws {
func test_perform_unlockVault_invalidPassword() async throws {
subject.state.masterPassword = "password"
stateService.activeAccount = .fixtureAccountLogin()
subject.state.unsuccessfulUnlockAttemptsCount = 4
@ -361,15 +366,16 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
XCTAssertEqual(subject.state.unsuccessfulUnlockAttemptsCount, 4)
struct VaultUnlockError: Error {}
authRepository.unlockWithPasswordResult = .failure(VaultUnlockError())
struct LogoutError: Error, Equatable {}
authRepository.logoutResult = .failure(LogoutError())
// 5th unsuccessful attempts
await subject.perform(.unlockVault)
XCTAssertTrue(authRepository.logoutCalled)
XCTAssertEqual(errorReporter.errors.last as? NSError, BitwardenError.logoutError(error: LogoutError()))
XCTAssertEqual(coordinator.routes.last, .landing)
XCTAssertEqual(
coordinator.events.last,
.action(
.logout(userId: nil, userInitiated: true)
)
)
}
/// `perform(_:)` with `.unlockVault` successful unlocking vault resets the `unsuccessfulUnlockAttemptsCount`.
@ -400,7 +406,7 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
/// `perform(_:)` with `.unlockWithBiometrics` requires a set user preference.
func test_perform_unlockWithBiometrics_noAccount() async throws {
biometricsService.biometricUnlockStatus = .success(
biometricsRepository.biometricUnlockStatus = .success(
.available(.faceID, enabled: true, hasValidIntegrity: true)
)
authRepository.unlockVaultWithBiometricsResult = .failure(StateServiceError.noActiveAccount)
@ -413,7 +419,7 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
/// `perform(_:)` with `.unlockWithBiometrics` requires a set user preference.
func test_perform_unlockWithBiometrics_notAvailable() async throws {
biometricsService.biometricUnlockStatus = .success(.notAvailable)
biometricsRepository.biometricUnlockStatus = .success(.notAvailable)
authRepository.unlockVaultWithBiometricsResult = .success(())
subject.state.biometricUnlockStatus = .available(.touchID, enabled: true, hasValidIntegrity: true)
@ -423,7 +429,7 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
/// `perform(_:)` with `.unlockWithBiometrics` requires a set user preference.
func test_perform_unlockWithBiometrics_notEnabled() async throws {
biometricsService.biometricUnlockStatus = .success(
biometricsRepository.biometricUnlockStatus = .success(
.available(.touchID, enabled: false, hasValidIntegrity: true)
)
authRepository.unlockVaultWithBiometricsResult = .success(())
@ -435,7 +441,7 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
/// `perform(_:)` with `.unlockWithBiometrics` requires a set user preference.
func test_perform_unlockWithBiometrics_invalidIntegrity() async throws {
biometricsService.biometricUnlockStatus = .success(
biometricsRepository.biometricUnlockStatus = .success(
.available(.touchID, enabled: true, hasValidIntegrity: false)
)
authRepository.unlockVaultWithBiometricsResult = .success(())
@ -448,7 +454,9 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
/// `perform(_:)` with `.unlockWithBiometrics` requires successful biometrics.
func test_perform_unlockWithBiometrics_authRepoError() async throws {
stateService.activeAccount = .fixture()
biometricsService.biometricUnlockStatus = .success(.available(.touchID, enabled: true, hasValidIntegrity: true))
biometricsRepository.biometricUnlockStatus = .success(
.available(.touchID, enabled: true, hasValidIntegrity: true)
)
struct BiometricsError: Error {}
authRepository.unlockVaultWithBiometricsResult = .failure(BiometricsError())
@ -461,20 +469,27 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
func test_perform_unlockWithBiometrics_authRepoError_maxAttempts() async throws {
stateService.activeAccount = .fixture()
subject.state.unsuccessfulUnlockAttemptsCount = 4
biometricsService.biometricUnlockStatus = .success(.available(.touchID, enabled: true, hasValidIntegrity: true))
biometricsRepository.biometricUnlockStatus = .success(
.available(.touchID, enabled: true, hasValidIntegrity: true)
)
struct BiometricsError: Error {}
authRepository.unlockVaultWithBiometricsResult = .failure(BiometricsError())
await subject.perform(.unlockVaultWithBiometrics)
XCTAssertEqual(0, subject.state.unsuccessfulUnlockAttemptsCount)
XCTAssertTrue(authRepository.logoutCalled)
let route = try XCTUnwrap(coordinator.routes.last)
XCTAssertEqual(route, .landing)
XCTAssertEqual(
coordinator.events.last,
.action(
.logout(userId: nil, userInitiated: true)
)
)
}
/// `perform(_:)` with `.unlockWithBiometrics` requires successful biometrics.
func test_perform_unlockWithBiometrics_authRepoError_getAuthKeyFailed() async throws {
biometricsService.biometricUnlockStatus = .success(.available(.touchID, enabled: true, hasValidIntegrity: true))
biometricsRepository.biometricUnlockStatus = .success(
.available(.touchID, enabled: true, hasValidIntegrity: true)
)
authRepository.unlockVaultWithBiometricsResult = .failure(BiometricsServiceError.getAuthKeyFailed)
authRepository.allowBiometricUnlockResult = .success(())
@ -483,10 +498,23 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
XCTAssertNil(coordinator.routes.last)
}
/// `perform(_:)` with `.unlockWithBiometrics` handles user cancellation.
func test_perform_unlockWithBiometrics_userCancelled() async throws {
biometricsRepository.biometricUnlockStatus = .success(
.available(.touchID, enabled: true, hasValidIntegrity: true)
)
authRepository.unlockVaultWithBiometricsResult = .failure(BiometricsServiceError.biometryCancelled)
authRepository.allowBiometricUnlockResult = .success(())
await subject.perform(.unlockVaultWithBiometrics)
XCTAssertNil(authRepository.allowBiometricUnlock)
XCTAssertNil(coordinator.routes.last)
}
/// `perform(_:)` with `.unlockWithBiometrics` requires successful biometrics.
func test_perform_unlockWithBiometrics_success() async throws {
subject.state.unsuccessfulUnlockAttemptsCount = 3
biometricsService.biometricUnlockStatus = .success(
biometricsRepository.biometricUnlockStatus = .success(
.available(.faceID, enabled: true, hasValidIntegrity: true)
)
authRepository.unlockVaultWithBiometricsResult = .success(())
@ -530,8 +558,12 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
await logoutConfirmationAlert.alertActions[0].handler?(optionsAlert.alertActions[0], [])
XCTAssertTrue(authRepository.logoutCalled)
XCTAssertEqual(coordinator.routes.last, .landing)
XCTAssertEqual(
coordinator.events.last,
.action(
.logout(userId: nil, userInitiated: true)
)
)
}
/// `receive(_:)` with `.morePressed` navigates to the login options screen and allows the user
@ -564,8 +596,12 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
await logoutConfirmationAlert.alertActions[0].handler?(optionsAlert.alertActions[0], [])
XCTAssertTrue(authRepository.logoutCalled)
XCTAssertEqual(coordinator.routes.last, .landing)
XCTAssertEqual(
coordinator.events.last,
.action(
.logout(userId: nil, userInitiated: true)
)
)
}
/// `receive(_:)` with `.revealMasterPasswordFieldPressed` updates the state to reflect the changes.
@ -582,14 +618,14 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
/// lock the selected account.
func test_receive_accountLongPressed_lock() async throws {
// Set up the mock data.
let activeProfile = ProfileSwitcherItem()
let otherProfile = ProfileSwitcherItem(isUnlocked: true, userId: "42")
let activeProfile = ProfileSwitcherItem.fixture()
let otherProfile = ProfileSwitcherItem.fixture(isUnlocked: true, userId: "42")
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [otherProfile, activeProfile],
activeAccountId: activeProfile.userId,
isVisible: true
)
authRepository.activeAccountResult = .success(activeProfile)
authRepository.activeProfileSwitcherItemResult = .success(activeProfile)
subject.receive(.profileSwitcherAction(.accountLongPressed(otherProfile)))
XCTAssertFalse(subject.state.profileSwitcherState.isVisible)
@ -599,21 +635,24 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
await lockAction.handler?(lockAction, [])
// Verify the results.
XCTAssertEqual(authRepository.lockVaultUserId, otherProfile.userId)
XCTAssertEqual(
coordinator.events.last,
.action(.lockVault(userId: otherProfile.userId))
)
XCTAssertEqual(subject.state.toast?.text, Localizations.accountLockedSuccessfully)
}
/// `receive(_:)` with `.profileSwitcherAction(.accountLongPressed)` records any errors from locking the account.
func test_receive_accountLongPressed_lock_error() async throws {
// Set up the mock data.
let activeProfile = ProfileSwitcherItem()
let otherProfile = ProfileSwitcherItem(isUnlocked: true, userId: "42")
let activeProfile = ProfileSwitcherItem.fixture()
let otherProfile = ProfileSwitcherItem.fixture(isUnlocked: true, userId: "42")
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [otherProfile, activeProfile],
activeAccountId: activeProfile.userId,
isVisible: true
)
authRepository.activeAccountResult = .failure(BitwardenTestError.example)
authRepository.activeProfileSwitcherItemResult = .failure(BitwardenTestError.example)
subject.receive(.profileSwitcherAction(.accountLongPressed(otherProfile)))
XCTAssertFalse(subject.state.profileSwitcherState.isVisible)
@ -630,14 +669,14 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
/// log out of the selected account, which navigates back to the landing page for the active account.
func test_receive_accountLongPressed_logout_activeAccount() async throws {
// Set up the mock data.
let activeProfile = ProfileSwitcherItem()
let otherProfile = ProfileSwitcherItem(userId: "42")
let activeProfile = ProfileSwitcherItem.fixture()
let otherProfile = ProfileSwitcherItem.fixture(userId: "42")
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [otherProfile, activeProfile],
activeAccountId: activeProfile.userId,
isVisible: true
)
authRepository.activeAccountResult = .success(activeProfile)
authRepository.activeProfileSwitcherItemResult = .success(activeProfile)
subject.receive(.profileSwitcherAction(.accountLongPressed(activeProfile)))
XCTAssertFalse(subject.state.profileSwitcherState.isVisible)
@ -651,22 +690,26 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
await confirmAction.handler?(confirmAction, [])
// Verify the results.
XCTAssertEqual(authRepository.logoutUserId, activeProfile.userId)
XCTAssertEqual(coordinator.routes.last, .landing)
XCTAssertEqual(
coordinator.events.last,
.action(
.logout(userId: activeProfile.userId, userInitiated: true)
)
)
}
/// `receive(_:)` with `.profileSwitcherAction(.accountLongPressed)` shows the alert and allows the user to
/// log out of the selected account, which navigates back to the landing page for the active account.
/// log out of the selected account, which triggers an account switch.
func test_receive_accountLongPressed_logout_activeAccount_withAlternate() async throws {
// Set up the mock data.
let activeProfile = ProfileSwitcherItem()
let otherProfile = ProfileSwitcherItem(userId: "42")
let activeProfile = ProfileSwitcherItem.fixture()
let otherProfile = ProfileSwitcherItem.fixture(userId: "42")
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [otherProfile, activeProfile],
activeAccountId: activeProfile.userId,
isVisible: true
)
authRepository.activeAccountResult = .success(activeProfile)
authRepository.activeProfileSwitcherItemResult = .success(activeProfile)
stateService.accounts = [
.fixture(
profile: .fixture(
@ -687,22 +730,26 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
await confirmAction.handler?(confirmAction, [])
// Verify the results.
XCTAssertEqual(authRepository.logoutUserId, activeProfile.userId)
XCTAssertEqual(coordinator.routes.last, .landing)
XCTAssertEqual(
coordinator.events.last,
.action(
.logout(userId: activeProfile.userId, userInitiated: true)
)
)
}
/// `receive(_:)` with `.profileSwitcherAction(.accountLongPressed)` shows the alert and allows the user to
/// log out of the selected account, which displays a toast.
func test_receive_accountLongPressed_logout_otherAccount() async throws {
// Set up the mock data.
let activeProfile = ProfileSwitcherItem()
let otherProfile = ProfileSwitcherItem(userId: "42")
let activeProfile = ProfileSwitcherItem.fixture()
let otherProfile = ProfileSwitcherItem.fixture(userId: "42")
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [otherProfile, activeProfile],
activeAccountId: activeProfile.userId,
isVisible: true
)
authRepository.activeAccountResult = .success(activeProfile)
authRepository.activeProfileSwitcherItemResult = .success(activeProfile)
subject.receive(.profileSwitcherAction(.accountLongPressed(otherProfile)))
XCTAssertFalse(subject.state.profileSwitcherState.isVisible)
@ -716,7 +763,10 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
await confirmAction.handler?(confirmAction, [])
// Verify the results.
XCTAssertEqual(authRepository.logoutUserId, otherProfile.userId)
XCTAssertEqual(
coordinator.events.last,
.action(.logout(userId: otherProfile.userId, userInitiated: true))
)
XCTAssertEqual(subject.state.toast?.text, Localizations.accountLoggedOutSuccessfully)
}
@ -724,14 +774,14 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
/// account.
func test_receive_accountLongPressed_logout_error() async throws {
// Set up the mock data.
let activeProfile = ProfileSwitcherItem()
let otherProfile = ProfileSwitcherItem(userId: "42")
let activeProfile = ProfileSwitcherItem.fixture()
let otherProfile = ProfileSwitcherItem.fixture(userId: "42")
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [otherProfile, activeProfile],
activeAccountId: activeProfile.userId,
isVisible: true
)
authRepository.activeAccountResult = .failure(BitwardenTestError.example)
authRepository.activeProfileSwitcherItemResult = .failure(BitwardenTestError.example)
subject.receive(.profileSwitcherAction(.accountLongPressed(otherProfile)))
XCTAssertFalse(subject.state.profileSwitcherState.isVisible)
@ -750,9 +800,9 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
/// `receive(_:)` with `.profileSwitcherAction(.accountPressed)` updates the state to reflect the changes.
func test_receive_accountPressed_active_unlocked() {
let profile = ProfileSwitcherItem()
authRepository.accountsResult = .success([profile])
authRepository.activeAccountResult = .success(profile)
let profile = ProfileSwitcherItem.fixture()
authRepository.profileSwitcherItemsResult = .success([profile])
authRepository.activeProfileSwitcherItemResult = .success(profile)
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [profile],
activeAccountId: profile.userId,
@ -767,17 +817,17 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
XCTAssertNotNil(subject.state.profileSwitcherState)
XCTAssertFalse(subject.state.profileSwitcherState.isVisible)
XCTAssertEqual(coordinator.routes, [.switchAccount(userId: profile.userId)])
XCTAssertEqual(coordinator.events, [])
}
/// `receive(_:)` with `.profileSwitcherAction(.accountPressed)` updates the state to reflect the changes.
func test_receive_accountPressed_active_locked() {
let profile = ProfileSwitcherItem(isUnlocked: false)
let profile = ProfileSwitcherItem.fixture(isUnlocked: false)
let account = Account.fixture(profile: .fixture(
userId: profile.userId
))
authRepository.accountsResult = .success([profile])
authRepository.activeAccountResult = .success(profile)
authRepository.profileSwitcherItemsResult = .success([profile])
authRepository.activeProfileSwitcherItemResult = .success(profile)
authRepository.accountForItemResult = .success(account)
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [profile],
@ -793,17 +843,17 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
XCTAssertNotNil(subject.state.profileSwitcherState)
XCTAssertFalse(subject.state.profileSwitcherState.isVisible)
XCTAssertEqual(coordinator.routes, [.switchAccount(userId: profile.userId)])
XCTAssertEqual(coordinator.events, [])
}
/// `receive(_:)` with `.profileSwitcherAction(.accountPressed)` updates the state to reflect the changes.
func test_receive_accountPressed_alternateUnlocked() {
let profile = ProfileSwitcherItem(isUnlocked: true)
let active = ProfileSwitcherItem()
let profile = ProfileSwitcherItem.fixture(isUnlocked: true)
let active = ProfileSwitcherItem.fixture()
let account = Account.fixture(profile: .fixture(
userId: profile.userId
))
authRepository.accountsResult = .success([active, profile])
authRepository.profileSwitcherItemsResult = .success([active, profile])
authRepository.accountForItemResult = .success(account)
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [profile, active],
@ -814,22 +864,22 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
let task = Task {
subject.receive(.profileSwitcherAction(.accountPressed(profile)))
}
waitFor(!subject.state.profileSwitcherState.isVisible)
waitFor(!coordinator.events.isEmpty)
task.cancel()
XCTAssertNotNil(subject.state.profileSwitcherState)
XCTAssertFalse(subject.state.profileSwitcherState.isVisible)
XCTAssertEqual(coordinator.routes, [.switchAccount(userId: profile.userId)])
XCTAssertEqual(coordinator.events, [.action(.switchAccount(isAutomatic: false, userId: profile.userId))])
}
/// `receive(_:)` with `.profileSwitcherAction(.accountPressed)` updates the state to reflect the changes.
func test_receive_accountPressed_alternateLocked() {
let profile = ProfileSwitcherItem(isUnlocked: false)
let active = ProfileSwitcherItem()
let profile = ProfileSwitcherItem.fixture(isUnlocked: false)
let active = ProfileSwitcherItem.fixture()
let account = Account.fixture(profile: .fixture(
userId: profile.userId
))
authRepository.accountsResult = .success([active, profile])
authRepository.profileSwitcherItemsResult = .success([active, profile])
authRepository.accountForItemResult = .success(account)
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [profile, active],
@ -840,19 +890,19 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
let task = Task {
subject.receive(.profileSwitcherAction(.accountPressed(profile)))
}
waitFor(!subject.state.profileSwitcherState.isVisible)
waitFor(!coordinator.events.isEmpty)
task.cancel()
XCTAssertNotNil(subject.state.profileSwitcherState)
XCTAssertFalse(subject.state.profileSwitcherState.isVisible)
XCTAssertEqual(coordinator.routes, [.switchAccount(userId: profile.userId)])
XCTAssertEqual(coordinator.events, [.action(.switchAccount(isAutomatic: false, userId: profile.userId))])
}
/// `receive(_:)` with `.profileSwitcherAction(.accountPressed)` updates the state to reflect the changes.
func test_receive_accountPressed_noMatch() {
let profile = ProfileSwitcherItem()
let active = ProfileSwitcherItem()
authRepository.accountsResult = .success([active])
let profile = ProfileSwitcherItem.fixture()
let active = ProfileSwitcherItem.fixture()
authRepository.profileSwitcherItemsResult = .success([active])
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [profile, active],
activeAccountId: active.userId,
@ -862,17 +912,17 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
let task = Task {
subject.receive(.profileSwitcherAction(.accountPressed(profile)))
}
waitFor(!subject.state.profileSwitcherState.isVisible)
waitFor(!coordinator.events.isEmpty)
task.cancel()
XCTAssertNotNil(subject.state.profileSwitcherState)
XCTAssertFalse(subject.state.profileSwitcherState.isVisible)
XCTAssertEqual(coordinator.routes, [.switchAccount(userId: profile.userId)])
XCTAssertEqual(coordinator.events, [.action(.switchAccount(isAutomatic: false, userId: profile.userId))])
}
/// `receive(_:)` with `.profileSwitcherAction(.addAccountPressed)` updates the state to reflect the changes.
func test_receive_addAccountPressed() {
let active = ProfileSwitcherItem()
let active = ProfileSwitcherItem.fixture()
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [active],
activeAccountId: active.userId,
@ -892,7 +942,7 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
/// `receive(_:)` with `.profileSwitcherAction(.backgroundPressed)` updates the state to reflect the changes.
func test_receive_backgroundPressed() {
let active = ProfileSwitcherItem()
let active = ProfileSwitcherItem.fixture()
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [active],
activeAccountId: active.userId,
@ -919,7 +969,7 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
/// `receive(_:)` with `.requestedProfileSwitcher(visible:)` updates the state to reflect the changes.
func test_receive_requestedProfileSwitcherVisible_false() {
let active = ProfileSwitcherItem()
let active = ProfileSwitcherItem.fixture()
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [active],
activeAccountId: active.userId,
@ -938,7 +988,7 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
/// `receive(_:)` with `.requestedProfileSwitcher(visible:)` updates the state to reflect the changes.
func test_receive_requestedProfileSwitcherVisible_true() {
let active = ProfileSwitcherItem()
let active = ProfileSwitcherItem.fixture()
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [active],
activeAccountId: active.userId,
@ -957,7 +1007,7 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
/// `receive(_:)` with `.profileSwitcherAction(.scrollOffset)` updates the state to reflect the changes.
func test_receive_scrollOffset() {
let active = ProfileSwitcherItem()
let active = ProfileSwitcherItem.fixture()
subject.state.profileSwitcherState = ProfileSwitcherState(
accounts: [active],
activeAccountId: active.userId,

View File

@ -201,6 +201,7 @@ struct UnlockVaultView_Previews: PreviewProvider {
accounts: [
ProfileSwitcherItem(
email: "max.protecc@bitwarden.com",
isUnlocked: false,
userId: "123",
userInitials: "MP"
),
@ -227,6 +228,7 @@ struct UnlockVaultView_Previews: PreviewProvider {
accounts: [
ProfileSwitcherItem(
email: "max.protecc@bitwarden.com",
isUnlocked: false,
userId: "123",
userInitials: "MP"
),

View File

@ -162,7 +162,7 @@ class VaultUnlockViewTests: BitwardenTestCase {
/// Check the snapshot for the profiles visible
func test_snapshot_profilesVisible() {
let account = ProfileSwitcherItem(
let account = ProfileSwitcherItem.fixture(
email: "extra.warden@bitwarden.com",
userInitials: "EW"
)
@ -190,7 +190,7 @@ class VaultUnlockViewTests: BitwardenTestCase {
/// Check the snapshot for the profiles closed
func test_snapshot_profilesClosed() {
let account = ProfileSwitcherItem(
let account = ProfileSwitcherItem.fixture(
email: "extra.warden@bitwarden.com",
userInitials: "EW"
)

View File

@ -61,10 +61,19 @@ class AppCoordinator: Coordinator, HasRootNavigator {
// MARK: Methods
func handleEvent(_ event: AppEvent, context: AnyObject?) async {
switch event {
case .didStart:
await handleAuthEvent(.didStart)
case let .didTimeout(userId):
await handleAuthEvent(.didTimeout(userId: userId))
}
}
func navigate(to route: AppRoute, context _: AnyObject?) {
switch route {
case let .auth(authRoute):
showAuth(route: authRoute)
showAuth(authRoute)
case let .extensionSetup(extensionSetupRoute):
showExtensionSetup(route: extensionSetupRoute)
case let .loginRequest(loginRequest):
@ -85,13 +94,23 @@ class AppCoordinator: Coordinator, HasRootNavigator {
// MARK: Private Methods
/// Handle an auth event.
///
/// - Parameter event: The auth event to handle.
///
private func handleAuthEvent(_ authEvent: AuthEvent) async {
let router = module.makeAuthRouter()
let route = await router.handleAndRoute(authEvent)
showAuth(route)
}
/// Shows the auth route.
///
/// - Parameter route: The auth route to show.
///
private func showAuth(route: AuthRoute) {
if let coordinator = childCoordinator as? AnyCoordinator<AuthRoute> {
coordinator.navigate(to: route)
private func showAuth(_ authRoute: AuthRoute) {
if let coordinator = childCoordinator as? AnyCoordinator<AuthRoute, AuthEvent> {
coordinator.navigate(to: authRoute)
} else {
guard let rootNavigator else { return }
let navigationController = UINavigationController()
@ -100,9 +119,10 @@ class AppCoordinator: Coordinator, HasRootNavigator {
rootNavigator: rootNavigator,
stackNavigator: navigationController
)
coordinator.start()
coordinator.navigate(to: route)
childCoordinator = coordinator
coordinator.navigate(to: authRoute)
}
}
@ -111,7 +131,7 @@ class AppCoordinator: Coordinator, HasRootNavigator {
/// - Parameter route: The extension setup route to show.
///
private func showExtensionSetup(route: ExtensionSetupRoute) {
if let coordinator = childCoordinator as? AnyCoordinator<ExtensionSetupRoute> {
if let coordinator = childCoordinator as? AnyCoordinator<ExtensionSetupRoute, Void> {
coordinator.navigate(to: route)
} else {
let stackNavigator = UINavigationController()
@ -130,7 +150,7 @@ class AppCoordinator: Coordinator, HasRootNavigator {
/// - Parameter route: The `SendItemRoute` to show.
///
private func showSendItem(route: SendItemRoute) {
if let coordinator = childCoordinator as? AnyCoordinator<SendItemRoute> {
if let coordinator = childCoordinator as? AnyCoordinator<SendItemRoute, Void> {
coordinator.navigate(to: route)
} else {
let stackNavigator = UINavigationController()
@ -150,7 +170,7 @@ class AppCoordinator: Coordinator, HasRootNavigator {
/// - Parameter route: The tab route to show.
///
private func showTab(route: TabRoute) {
if let coordinator = childCoordinator as? AnyCoordinator<TabRoute> {
if let coordinator = childCoordinator as? AnyCoordinator<TabRoute, Void> {
coordinator.navigate(to: route)
} else {
guard let rootNavigator else { return }
@ -174,7 +194,7 @@ class AppCoordinator: Coordinator, HasRootNavigator {
private func showLoginRequest(_ loginRequest: LoginRequest) {
DispatchQueue.main.async {
// Make sure that the user is authenticated and not currently viewing the login request view.
guard self.childCoordinator is AnyCoordinator<TabRoute> else { return }
guard self.childCoordinator is AnyCoordinator<TabRoute, Void> else { return }
let currentView = self.rootNavigator?.rootViewController?.topmostViewController()
guard !(currentView is UIHostingController<LoginRequestView>) else { return }
@ -197,7 +217,7 @@ class AppCoordinator: Coordinator, HasRootNavigator {
/// - Parameter route: The vault route to show.
///
private func showVault(route: VaultRoute) {
if let coordinator = childCoordinator as? AnyCoordinator<VaultRoute> {
if let coordinator = childCoordinator as? AnyCoordinator<VaultRoute, AuthAction> {
coordinator.navigate(to: route)
} else {
let stackNavigator = UINavigationController()
@ -258,36 +278,42 @@ extension AppCoordinator: SendItemDelegate {
// MARK: - SettingsCoordinatorDelegate
extension AppCoordinator: SettingsCoordinatorDelegate {
func didDeleteAccount(otherAccounts: [Account]?) {
if let account = otherAccounts?.first {
showAuth(
route: .vaultUnlock(
account,
didSwitchAccountAutomatically: true
)
)
} else {
showAuth(route: .landing)
func didDeleteAccount() {
Task {
await handleAuthEvent(.didDeleteAccount)
}
showAuth(route: .alert(.accountDeletedAlert()))
}
func didLockVault(account: Account) {
showAuth(route: .vaultUnlock(account, didSwitchAccountAutomatically: false))
}
func didLogout(userInitiated: Bool, otherAccounts: [Account]?) {
if userInitiated,
let account = otherAccounts?.first {
showAuth(
route: .vaultUnlock(
account,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: true
func lockVault(userId: String?) {
Task {
await handleAuthEvent(
.action(
.lockVault(userId: userId)
)
)
}
}
func logout(userId: String?, userInitiated: Bool) {
Task {
await handleAuthEvent(
.action(
.logout(userId: userId, userInitiated: userInitiated)
)
)
}
}
func switchAccount(isAutomatic: Bool, userId: String) {
Task {
await handleAuthEvent(
.action(
.switchAccount(
isAutomatic: isAutomatic,
userId: userId
)
)
)
} else {
showAuth(route: .landing)
}
}
}
@ -295,12 +321,34 @@ extension AppCoordinator: SettingsCoordinatorDelegate {
// MARK: - VaultCoordinatorDelegate
extension AppCoordinator: VaultCoordinatorDelegate {
func switchAccount(userId: String, isAutomatic: Bool) {
Task {
await handleAuthEvent(
.action(
.switchAccount(
isAutomatic: isAutomatic,
userId: userId
)
)
)
}
}
func didTapAddAccount() {
showAuth(route: .landing)
showAuth(.landing)
}
func didTapAccount(userId: String) {
showAuth(route: .switchAccount(userId: userId))
Task {
await handleAuthEvent(
.action(
.switchAccount(
isAutomatic: false,
userId: userId
)
)
)
}
}
func presentLoginRequest(_ loginRequest: LoginRequest) {

View File

@ -11,6 +11,7 @@ class AppCoordinatorTests: BitwardenTestCase {
var appExtensionDelegate: MockAppExtensionDelegate!
var module: MockAppModule!
var rootNavigator: MockRootNavigator!
var router: MockRouter<AuthEvent, AuthRoute>!
var subject: AppCoordinator!
// MARK: Setup & Teardown
@ -19,7 +20,9 @@ class AppCoordinatorTests: BitwardenTestCase {
super.setUp()
appExtensionDelegate = MockAppExtensionDelegate()
router = MockRouter(routeForEvent: { _ in .landing })
module = MockAppModule()
module.authRouter = router
rootNavigator = MockRootNavigator()
subject = AppCoordinator(
@ -68,119 +71,81 @@ class AppCoordinatorTests: BitwardenTestCase {
XCTAssertEqual(module.vaultCoordinator.routes, [.autofillList])
}
/// `didDeleteAccount(otherAccounts:)` navigates to the landing screen
/// and presents an alert notifying the user that they deleted their account.
func test_didDeleteAccount_noOtherAccounts() {
subject.didDeleteAccount(otherAccounts: [])
XCTAssertEqual(module.authCoordinator.routes, [.landing, .alert(.accountDeletedAlert())])
}
/// `didDeleteAccount(otherAccounts:)` navigates to the vault unlock screen
/// and presents an alert notifying the user that they deleted their account.
func test_didDeleteAccount_otherAccounts() {
let account: Account = .fixtureAccountLogin()
subject.didDeleteAccount(otherAccounts: [account])
/// `didDeleteAccount(otherAccounts:)` navigates to the `didDeleteAccount` route.
func test_didDeleteAccount() {
subject.didDeleteAccount()
waitFor(!router.events.isEmpty)
XCTAssertEqual(
module.authCoordinator.routes,
router.events,
[
.vaultUnlock(
account,
didSwitchAccountAutomatically: true
),
.alert(.accountDeletedAlert()),
.didDeleteAccount,
]
)
}
/// `didLockVault(_:, _:, _:)` starts the auth coordinator and navigates to the login route.
/// `lockVault(_:)` passes the lock event to the router.
func test_didLockVault() {
let account: Account = .fixtureAccountLogin()
subject.didLockVault(account: .fixtureAccountLogin())
subject.lockVault(userId: account.profile.userId)
XCTAssertTrue(module.authCoordinator.isStarted)
waitFor(module.authCoordinator.isStarted)
waitFor(!router.events.isEmpty)
XCTAssertEqual(
module.authCoordinator.routes,
router.events,
[
.vaultUnlock(
account,
didSwitchAccountAutomatically: false
),
.action(.lockVault(userId: account.profile.userId)),
]
)
}
/// `didLogout()` starts the auth coordinator and navigates to the landing route.
func test_didLogout_automatic_nilAccounts() {
subject.didLogout(userInitiated: false, otherAccounts: nil)
XCTAssertTrue(module.authCoordinator.isStarted)
XCTAssertEqual(module.authCoordinator.routes, [.landing])
/// `logout()` passes the event to the router.
func test_didLogout_automatic() {
subject.logout(userId: "123", userInitiated: false)
waitFor(module.authCoordinator.isStarted)
XCTAssertEqual(router.events, [.action(.logout(userId: "123", userInitiated: false))])
}
/// `didLogout()` starts the auth coordinator and navigates to the landing route.
func test_didLogout_automatic_noAccounts() {
subject.didLogout(userInitiated: false, otherAccounts: [])
XCTAssertTrue(module.authCoordinator.isStarted)
XCTAssertEqual(module.authCoordinator.routes, [.landing])
}
/// `didLogout()` starts the auth coordinator and navigates to the landing route.
func test_didLogout_automatic_withAccount() {
subject.didLogout(userInitiated: false, otherAccounts: [.fixtureAccountLogin()])
XCTAssertTrue(module.authCoordinator.isStarted)
XCTAssertEqual(module.authCoordinator.routes, [.landing])
}
/// `didLogout()` starts the auth coordinator and navigates to the landing route.
func test_didLogout_userInitiated_nilAccounts() {
subject.didLogout(userInitiated: true, otherAccounts: nil)
XCTAssertTrue(module.authCoordinator.isStarted)
XCTAssertEqual(module.authCoordinator.routes, [.landing])
}
/// `didLogout()` starts the auth coordinator and navigates to the landing route.
func test_didLogout_userInitiated_noAccounts() {
subject.didLogout(userInitiated: true, otherAccounts: [])
XCTAssertTrue(module.authCoordinator.isStarted)
XCTAssertEqual(module.authCoordinator.routes, [.landing])
}
/// `didLogout()` starts the auth coordinator and navigates to the landing route.
func test_didLogout_userInitiated_withAccount() {
let altAccount = Account.fixtureAccountLogin()
let expectedRoute = AuthRoute.vaultUnlock(
altAccount,
animated: true,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: true
)
subject.didLogout(userInitiated: true, otherAccounts: [altAccount])
XCTAssertTrue(module.authCoordinator.isStarted)
/// `didLogout()` starts the auth coordinator and navigates to the `.didLogout` route.
func test_didLogout_userInitiated() {
let expectedEvent = AuthEvent.action(.logout(userId: "123", userInitiated: true))
subject.logout(userId: "123", userInitiated: true)
waitFor(module.authCoordinator.isStarted)
XCTAssertEqual(
module.authCoordinator.routes,
[expectedRoute]
router.events,
[expectedEvent]
)
}
/// `didTapAccount(:)` triggers the switch account action.
func test_didTapAccount() {
subject.didTapAccount(userId: "123")
waitFor(module.authCoordinator.isStarted)
XCTAssertEqual(
router.events,
[
.action(
.switchAccount(
isAutomatic: false,
userId: "123"
)
),
]
)
}
/// `didTapAddAccount()` triggers the login sequence from the landing page
func test_didTapAddAccount() {
subject.didTapAddAccount()
XCTAssertTrue(module.authCoordinator.isStarted)
waitFor(module.authCoordinator.isStarted)
XCTAssertEqual(module.authCoordinator.routes, [.landing])
}
/// `didTapAccount()` switches accounts.
func test_didTapAccount() {
subject.didTapAccount(userId: "2")
XCTAssertTrue(module.authCoordinator.isStarted)
XCTAssertEqual(module.authCoordinator.routes, [.switchAccount(userId: "2")])
}
/// `navigate(to:)` with `.onboarding` starts the auth coordinator and navigates to the proper auth route.
func test_navigateTo_auth() throws {
subject.navigate(to: .auth(.landing))
XCTAssertTrue(module.authCoordinator.isStarted)
waitFor(module.authCoordinator.isStarted)
XCTAssertEqual(module.authCoordinator.routes, [.landing])
}
@ -189,6 +154,7 @@ class AppCoordinatorTests: BitwardenTestCase {
subject.navigate(to: .auth(.landing))
subject.navigate(to: .auth(.landing))
waitFor(module.authCoordinator.routes.count > 1)
XCTAssertEqual(module.authCoordinator.routes, [.landing, .landing])
}

View File

@ -13,7 +13,7 @@ public protocol AppModule: AnyObject {
func makeAppCoordinator(
appContext: AppContext,
navigator: RootNavigator
) -> AnyCoordinator<AppRoute>
) -> AnyCoordinator<AppRoute, AppEvent>
}
// MARK: - DefaultAppModule
@ -50,7 +50,7 @@ extension DefaultAppModule: AppModule {
public func makeAppCoordinator(
appContext: AppContext,
navigator: RootNavigator
) -> AnyCoordinator<AppRoute> {
) -> AnyCoordinator<AppRoute, AppEvent> {
AppCoordinator(
appContext: appContext,
appExtensionDelegate: appExtensionDelegate,

View File

@ -6,6 +6,7 @@ import XCTest
class AppModuleTests: BitwardenTestCase {
// MARK: Properties
var rootViewController: RootViewController!
var subject: DefaultAppModule!
// MARK: Setup & Teardown
@ -13,12 +14,14 @@ class AppModuleTests: BitwardenTestCase {
override func setUp() {
super.setUp()
rootViewController = RootViewController()
subject = DefaultAppModule(services: .withMocks())
}
override func tearDown() {
super.tearDown()
rootViewController = nil
subject = nil
}
@ -26,15 +29,16 @@ class AppModuleTests: BitwardenTestCase {
/// `makeAppCoordinator` builds the app coordinator.
func test_makeAppCoordinator() {
let rootViewController = RootViewController()
let coordinator = subject.makeAppCoordinator(appContext: .mainApp, navigator: rootViewController)
coordinator.navigate(to: .auth(.landing), context: nil)
XCTAssertNotNil(rootViewController.childViewController)
let task = Task {
coordinator.navigate(to: .auth(.landing), context: nil)
}
waitFor(rootViewController.childViewController != nil)
task.cancel()
}
/// `makeAuthCoordinator` builds the auth coordinator.
func test_makeAuthCoordinator() {
let rootViewController = RootViewController()
let navigationController = UINavigationController()
let coordinator = subject.makeAuthCoordinator(
delegate: MockAuthDelegate(),
@ -83,7 +87,6 @@ class AppModuleTests: BitwardenTestCase {
/// `makeTabCoordinator` builds the tab coordinator.
func test_makeTabCoordinator() {
let rootViewController = RootViewController()
let tabBarController = UITabBarController()
let settingsDelegate = MockSettingsCoordinatorDelegate()
let vaultDelegate = MockVaultCoordinatorDelegate()

View File

@ -13,7 +13,7 @@ public class AppProcessor {
let appModule: AppModule
/// The root coordinator of the app.
var coordinator: AnyCoordinator<AppRoute>?
var coordinator: AnyCoordinator<AppRoute, AppEvent>?
/// The services used by the app.
let services: ServiceContainer
@ -41,9 +41,10 @@ public class AppProcessor {
Task {
for await _ in services.notificationCenterService.willEnterForegroundPublisher() {
let userId = try await self.services.stateService.getActiveAccountId()
let shouldTimeout = try await services.vaultTimeoutService.shouldSessionTimeout(userId: userId)
let shouldTimeout = try await services.vaultTimeoutService.hasPassedSessionTimeout(userId: userId)
if shouldTimeout {
navigatePostTimeout()
// Allow the AuthCoordinator to handle the timeout.
await coordinator?.handleEvent(.didTimeout(userId: userId))
}
}
}
@ -89,43 +90,11 @@ public class AppProcessor {
if let initialRoute {
coordinator.navigate(to: initialRoute)
} else if let activeAccount = services.appSettingsStore.state?.activeAccount {
let vaultTimeout = services.appSettingsStore.vaultTimeout(userId: activeAccount.profile.userId)
if vaultTimeout == SessionTimeoutValue.onAppRestart.rawValue {
navigatePostTimeout()
} else {
coordinator.navigate(
to: .auth(
.vaultUnlock(
activeAccount,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: false
)
)
)
}
} else {
coordinator.navigate(to: .auth(.landing))
}
}
// MARK: Private methods
/// Navigates when a session timeout occurs.
///
private func navigatePostTimeout() {
guard let account = services.appSettingsStore.state?.activeAccount else { return }
guard let action = services.appSettingsStore.timeoutAction(userId: account.profile.userId) else { return }
switch action {
case SessionTimeoutAction.lock.rawValue:
coordinator?.navigate(to: .auth(.vaultUnlock(account, didSwitchAccountAutomatically: false)))
case SessionTimeoutAction.logout.rawValue:
// Navigate to the .didStart rotue
Task {
try await services.stateService.logoutAccount(userId: account.profile.userId)
await coordinator.handleEvent(.didStart)
}
coordinator?.navigate(to: .auth(.landing))
default:
break
}
}

View File

@ -8,10 +8,11 @@ class AppProcessorTests: BitwardenTestCase {
var appModule: MockAppModule!
var appSettingStore: MockAppSettingsStore!
var coordinator: MockCoordinator<AppRoute>!
var coordinator: MockCoordinator<AppRoute, AppEvent>!
var errorReporter: MockErrorReporter!
var notificationCenterService: MockNotificationCenterService!
var notificationService: MockNotificationService!
var router: MockRouter<AuthEvent, AuthRoute>!
var stateService: MockStateService!
var subject: AppProcessor!
var syncService: MockSyncService!
@ -23,9 +24,12 @@ class AppProcessorTests: BitwardenTestCase {
override func setUp() {
super.setUp()
router = MockRouter(routeForEvent: { _ in .landing })
appModule = MockAppModule()
coordinator = MockCoordinator()
appModule.authRouter = router
appModule.appCoordinator = coordinator
appSettingStore = MockAppSettingsStore()
coordinator = MockCoordinator<AppRoute>()
errorReporter = MockErrorReporter()
notificationCenterService = MockNotificationCenterService()
notificationService = MockNotificationService()
@ -108,31 +112,8 @@ class AppProcessorTests: BitwardenTestCase {
XCTAssertEqual(notificationService.messageReceivedMessage?.keys.first, "knock knock")
}
/// Upon a session timeout on app foreground, the user should be navigated to the landing screen.
func test_shouldSessionTimeout_navigateTo_landing() async throws {
let rootNavigator = MockRootNavigator()
let account: Account = .fixture()
appSettingStore.timeoutAction[account.profile.userId] = SessionTimeoutAction.logout.rawValue
appSettingStore.state = State(
accounts: [account.profile.userId: account],
activeUserId: account.profile.userId
)
stateService.activeAccount = account
stateService.accounts = [account]
appSettingStore.vaultTimeout[account.profile.userId] = SessionTimeoutValue.onAppRestart.rawValue
vaultTimeoutService.shouldSessionTimeout[account.profile.userId] = true
subject.start(appContext: .mainApp, navigator: rootNavigator, window: nil)
notificationCenterService.willEnterForegroundSubject.send()
waitFor(vaultTimeoutService.shouldSessionTimeout[account.profile.userId] == true)
XCTAssertEqual(appModule.appCoordinator.routes.last, .auth(.landing))
}
/// Upon a session timeout on app foreground, the user should be navigated to the vault unlock screen.
func test_shouldSessionTimeout_navigateTo_vaultUnlock() async throws {
/// Upon a session timeout on app foreground, send the user to the `.didTimeout` route.
func test_shouldSessionTimeout_navigateTo_didTimeout() throws {
let rootNavigator = MockRootNavigator()
let account: Account = .fixture()
@ -150,13 +131,10 @@ class AppProcessorTests: BitwardenTestCase {
notificationCenterService.willEnterForegroundSubject.send()
waitFor(vaultTimeoutService.shouldSessionTimeout[account.profile.userId] == true)
waitFor(coordinator.events.count > 1)
XCTAssertEqual(
appModule.appCoordinator.routes.last,
.auth(.vaultUnlock(
.fixture(),
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: false
))
coordinator.events.last,
.didTimeout(userId: account.profile.userId)
)
}
@ -166,26 +144,6 @@ class AppProcessorTests: BitwardenTestCase {
XCTAssertEqual(coordinator.routes.last, .loginRequest(.fixture()))
}
/// `start(navigator:)` builds the AppCoordinator and navigates to vault unlock if there's an
/// active account.
func test_start_activeAccount() async throws {
appSettingStore.state = State.fixture()
appSettingStore.vaultTimeout = [Account.fixture().profile.userId: 60]
let rootNavigator = MockRootNavigator()
subject.start(appContext: .mainApp, navigator: rootNavigator, window: nil)
XCTAssertTrue(appModule.appCoordinator.isStarted)
XCTAssertEqual(
appModule.appCoordinator.routes.last,
.auth(.vaultUnlock(
.fixture(),
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: false
))
)
}
/// `start(navigator:)` builds the AppCoordinator and navigates to the initial route if provided.
func test_start_initialRoute() {
let rootNavigator = MockRootNavigator()
@ -204,14 +162,15 @@ class AppProcessorTests: BitwardenTestCase {
)
}
/// `start(navigator:)` builds the AppCoordinator and navigates to the landing view if there
/// isn't an active account.
func test_start_noActiveAccount() {
/// `start(navigator:)` builds the AppCoordinator and navigates to the `.didStart` route.
func test_start_authRoute() {
let rootNavigator = MockRootNavigator()
subject.start(appContext: .mainApp, navigator: rootNavigator, window: nil)
waitFor(!coordinator.events.isEmpty)
XCTAssertTrue(appModule.appCoordinator.isStarted)
XCTAssertEqual(appModule.appCoordinator.routes, [.auth(.landing)])
XCTAssertEqual(appModule.appCoordinator.events, [.didStart])
}
}

View File

@ -21,3 +21,11 @@ public enum AppRoute: Equatable {
/// A route to the vault interface.
case vault(VaultRoute)
}
public enum AppEvent: Equatable {
/// When the app has started.
case didStart
/// When an account has timed out.
case didTimeout(userId: String)
}

View File

@ -2,18 +2,18 @@
/// A type erased wrapper for a coordinator.
///
open class AnyCoordinator<Route>: Coordinator {
open class AnyCoordinator<Route, Event>: Coordinator {
// MARK: Properties
/// A closure that wraps the `handleEvent(_:,_:)` method.
private let doHandleEvent: (Event, AnyObject?) async -> Void
/// A closure that wraps the `hideLoadingOverlay()` method.
private let doHideLoadingOverlay: () -> Void
/// A closure that wraps the `navigate(to:)` method.
private let doNavigate: (Route, AnyObject?) -> Void
/// A closure that wraps the `navigate(asyncTo:)` method.
private let doAsyncNavigate: (Route, AnyObject?) async -> Void
/// A closure that wraps the `showAlert(_:)` method.
private let doShowAlert: (Alert) -> Void
@ -32,10 +32,12 @@ open class AnyCoordinator<Route>: Coordinator {
///
/// - Parameter coordinator: The coordinator to wrap.
///
public init<C: Coordinator>(_ coordinator: C) where C.Route == Route {
public init<C: Coordinator>(_ coordinator: C)
where C.Event == Event,
C.Route == Route {
doHideLoadingOverlay = { coordinator.hideLoadingOverlay() }
doAsyncNavigate = { route, context in
await coordinator.navigate(asyncTo: route, context: context)
doHandleEvent = { event, context in
await coordinator.handleEvent(event, context: context)
}
doNavigate = { route, context in
coordinator.navigate(to: route, context: context)
@ -48,12 +50,12 @@ open class AnyCoordinator<Route>: Coordinator {
// MARK: Coordinator
open func navigate(to route: Route, context: AnyObject?) {
doNavigate(route, context)
open func handleEvent(_ event: Event, context: AnyObject?) async {
await doHandleEvent(event, context)
}
open func navigate(asyncTo route: Route, context: AnyObject?) async {
await doAsyncNavigate(route, context)
open func navigate(to route: Route, context: AnyObject?) {
doNavigate(route, context)
}
open func showAlert(_ alert: Alert) {
@ -87,7 +89,7 @@ public extension Coordinator {
/// Wraps this coordinator in an instance of `AnyCoordinator`.
///
/// - Returns: An `AnyCoordinator` instance wrapping this coordinator.
func asAnyCoordinator() -> AnyCoordinator<Route> {
func asAnyCoordinator() -> AnyCoordinator<Route, Event> {
AnyCoordinator(self)
}
}

View File

@ -8,14 +8,14 @@ import XCTest
class AnyCoordinatorTests: BitwardenTestCase {
// MARK: Properties
var coordinator: MockCoordinator<AppRoute>!
var subject: AnyCoordinator<AppRoute>!
var coordinator: MockCoordinator<AppRoute, AppEvent>!
var subject: AnyCoordinator<AppRoute, AppEvent>!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
coordinator = MockCoordinator<AppRoute>()
coordinator = MockCoordinator<AppRoute, AppEvent>()
subject = AnyCoordinator(coordinator)
}

View File

@ -0,0 +1,39 @@
// MARK: - AnyRouter
/// A type erased wrapper for a router.
///
open class AnyRouter<Event, Route>: Router {
// MARK: Properties
/// A closure that wraps the `handleAndRoute()` method.
private let doHandleAndRoute: (Event) async -> Route
// MARK: Initialization
/// Initializes an `AnyRouter`.
///
/// - Parameter router: The router to wrap.
///
public init<R: Router>(_ router: R) where R.Route == Route, R.Event == Event {
doHandleAndRoute = { event in
await router.handleAndRoute(event)
}
}
// MARK: Router
open func handleAndRoute(_ event: Event) async -> Route {
await doHandleAndRoute(event)
}
}
// MARK: - Router Extensions
public extension Router {
/// Wraps this router in an instance of `AnyRouter`.
///
/// - Returns: An `AnyRouter` instance wrapping this router.
func asAnyRouter() -> AnyRouter<Event, Route> {
AnyRouter(self)
}
}

View File

@ -0,0 +1,43 @@
import XCTest
@testable import BitwardenShared
// MARK: - AnyRouterTests
@MainActor
class AnyRouterTests: BitwardenTestCase {
// MARK: Properties
var router: MockRouter<AuthEvent, AuthRoute>!
var subject: AnyRouter<AuthEvent, AuthRoute>!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
router = MockRouter(routeForEvent: { _ in .landing })
subject = router.asAnyRouter()
}
override func tearDown() {
super.tearDown()
router = nil
subject = nil
}
// MARK: Tests
/// `handleAndRoute()` calls the `handleAndRoute()` method on the wrapped router.
func test_handleAndRoute() async {
var didStart = false
router.routeForEvent = { event in
guard case .didStart = event else { return .landing }
didStart = true
return .complete
}
let route = await subject.handleAndRoute(.didStart)
XCTAssertEqual(router.events, [.didStart])
XCTAssertEqual(route, .complete)
XCTAssertTrue(didStart)
}
}

View File

@ -1,8 +1,21 @@
/// A protocol for an object that performs navigation via routes.
@MainActor
public protocol Coordinator<Route>: AnyObject {
public protocol Coordinator<Route, Event>: AnyObject {
// MARK: Types
associatedtype Event
associatedtype Route
// MARK: Methods
/// Handles events that may require asynchronous management.
///
/// - Parameters:
/// - event: The event for which the coordinator handle.
/// - context: The context for the event.
///
func handleEvent(_ event: Event, context: AnyObject?) async
/// Hides the loading overlay view.
///
func hideLoadingOverlay()
@ -15,14 +28,6 @@ public protocol Coordinator<Route>: AnyObject {
///
func navigate(to route: Route, context: AnyObject?)
/// Navigate to the screen associated with the given `AsyncRoute` when the route may be async.
///
/// - Parameters:
/// - route: Navigate to this `Route` with delay.
/// - context: An object representing the context where the navigation occurred.
///
func navigate(asyncTo route: Route, context: AnyObject?) async
/// Shows the provided alert on the `stackNavigator`.
///
/// - Parameter alert: The alert to show.
@ -84,9 +89,26 @@ protocol HasRootNavigator: HasNavigator {
var rootNavigator: RootNavigator? { get }
}
/// A protocol for an object that has a `Router`.
///
protocol HasRouter<Event, Route> {
associatedtype Event
associatedtype Route
var router: AnyRouter<Event, Route> { get }
}
// MARK: Extensions
public extension Coordinator {
/// Handles events that may require asynchronous management.
///
/// - Parameter event: The event for which the coordinator handle.
///
func handleEvent(_ event: Event) async {
await handleEvent(event, context: nil)
}
/// Navigate to the screen associated with the given `Route` without context.
///
/// - Parameters:
@ -96,20 +118,13 @@ public extension Coordinator {
func navigate(to route: Route) {
navigate(to: route, context: nil)
}
}
/// Navigate to the screen associated with the given `Route` asynchronously without context.
extension Coordinator where Self.Event == Void {
/// Provide a default No-Op when a coodrinator does not use events.
///
/// - Parameters:
/// - route: The specific `Route` to navigate to.
///
func navigate(asyncTo route: Route) async {
await navigate(asyncTo: route, context: nil)
}
/// Default to synchronous navigation
///
func navigate(asyncTo route: Route, context: AnyObject?) async {
navigate(to: route, context: context)
func handleEvent(_ event: Void, context: AnyObject?) async {
// No-Op
}
}
@ -145,6 +160,18 @@ extension Coordinator where Self: HasNavigator {
}
}
extension Coordinator where Self: HasRouter {
/// Passes an `Event` to the router, which prepares a route
/// that the coordinator uses for navigation.
///
/// - Parameter event: The event to pass to the router.
///
func handleEvent(_ event: Event, context: AnyObject?) async {
let route = await router.handleAndRoute(event)
navigate(to: route, context: context)
}
}
extension HasStackNavigator {
/// The stack navigator.
var navigator: Navigator? { stackNavigator }

View File

@ -0,0 +1,15 @@
// MARK: - Router
/// A protocol for an object that configures state for a given event and outputs a redirected route.
@MainActor
public protocol Router<Event, Route>: AnyObject {
associatedtype Event
associatedtype Route
/// Prepare the coordinator for a given route and redirect if needed.
///
/// - Parameter route: The route for which the coordinator should prepare itself.
/// - Returns: A redirected route for which the Coordinator is prepared.
///
func handleAndRoute(_ event: Event) async -> Route
}

View File

@ -12,7 +12,7 @@ protocol ExtensionSetupModule {
///
func makeExtensionSetupCoordinator(
stackNavigator: StackNavigator
) -> AnyCoordinator<ExtensionSetupRoute>
) -> AnyCoordinator<ExtensionSetupRoute, Void>
}
// MARK: - DefaultAppModule
@ -20,7 +20,7 @@ protocol ExtensionSetupModule {
extension DefaultAppModule: ExtensionSetupModule {
func makeExtensionSetupCoordinator(
stackNavigator: StackNavigator
) -> AnyCoordinator<ExtensionSetupRoute> {
) -> AnyCoordinator<ExtensionSetupRoute, Void> {
ExtensionSetupCoordinator(
appExtensionDelegate: appExtensionDelegate,
stackNavigator: stackNavigator

View File

@ -14,14 +14,14 @@ protocol FileSelectionModule {
func makeFileSelectionCoordinator(
delegate: FileSelectionDelegate,
stackNavigator: StackNavigator
) -> AnyCoordinator<FileSelectionRoute>
) -> AnyCoordinator<FileSelectionRoute, Void>
}
extension DefaultAppModule: FileSelectionModule {
func makeFileSelectionCoordinator(
delegate: FileSelectionDelegate,
stackNavigator: StackNavigator
) -> AnyCoordinator<FileSelectionRoute> {
) -> AnyCoordinator<FileSelectionRoute, Void> {
FileSelectionCoordinator(
delegate: delegate,
services: services,

View File

@ -13,13 +13,13 @@ protocol LoginRequestModule {
///
func makeLoginRequestCoordinator(
stackNavigator: StackNavigator
) -> AnyCoordinator<LoginRequestRoute>
) -> AnyCoordinator<LoginRequestRoute, Void>
}
extension DefaultAppModule: LoginRequestModule {
func makeLoginRequestCoordinator(
stackNavigator: StackNavigator
) -> AnyCoordinator<LoginRequestRoute> {
) -> AnyCoordinator<LoginRequestRoute, Void> {
LoginRequestCoordinator(
services: services,
stackNavigator: stackNavigator

View File

@ -26,7 +26,7 @@ final class LoginRequestProcessor: StateProcessor<LoginRequestState, LoginReques
// MARK: Properties
/// The `Coordinator` that handles navigation.
private let coordinator: AnyCoordinator<LoginRequestRoute>
private let coordinator: AnyCoordinator<LoginRequestRoute, Void>
/// The delegate that is notified when login requests have been answered.
private weak var delegate: LoginRequestDelegate?
@ -48,7 +48,7 @@ final class LoginRequestProcessor: StateProcessor<LoginRequestState, LoginReques
/// - state: The initial state of the processor.
///
init(
coordinator: AnyCoordinator<LoginRequestRoute>,
coordinator: AnyCoordinator<LoginRequestRoute, Void>,
delegate: LoginRequestDelegate?,
services: Services,
state: LoginRequestState

View File

@ -6,7 +6,7 @@ class LoginRequestProcessorTests: BitwardenTestCase {
// MARK: Properties
var authService: MockAuthService!
var coordinator: MockCoordinator<LoginRequestRoute>!
var coordinator: MockCoordinator<LoginRequestRoute, Void>!
var delegate: MockLoginRequestDelegate!
var errorReporter: MockErrorReporter!
var stateService: MockStateService!
@ -18,7 +18,7 @@ class LoginRequestProcessorTests: BitwardenTestCase {
super.setUp()
authService = MockAuthService()
coordinator = MockCoordinator<LoginRequestRoute>()
coordinator = MockCoordinator<LoginRequestRoute, Void>()
delegate = MockLoginRequestDelegate()
errorReporter = MockErrorReporter()
stateService = MockStateService()

View File

@ -11,7 +11,7 @@ final class AboutProcessor: StateProcessor<AboutState, AboutAction, Void> {
// MARK: Properties
/// The coordinator used to manage navigation.
private let coordinator: AnyCoordinator<SettingsRoute>
private let coordinator: AnyCoordinator<SettingsRoute, SettingsEvent>
/// The services used by this processor.
private let services: Services
@ -26,7 +26,7 @@ final class AboutProcessor: StateProcessor<AboutState, AboutAction, Void> {
/// - state: The initial state of the processor.
///
init(
coordinator: AnyCoordinator<SettingsRoute>,
coordinator: AnyCoordinator<SettingsRoute, SettingsEvent>,
services: Services,
state: AboutState
) {

View File

@ -5,7 +5,7 @@ import XCTest
class AboutProcessorTests: BitwardenTestCase {
// MARK: Properties
var coordinator: MockCoordinator<SettingsRoute>!
var coordinator: MockCoordinator<SettingsRoute, SettingsEvent>!
var errorReporter: MockErrorReporter!
var pasteboardService: MockPasteboardService!
var subject: AboutProcessor!
@ -15,7 +15,7 @@ class AboutProcessorTests: BitwardenTestCase {
override func setUp() {
super.setUp()
coordinator = MockCoordinator<SettingsRoute>()
coordinator = MockCoordinator<SettingsRoute, SettingsEvent>()
errorReporter = MockErrorReporter()
pasteboardService = MockPasteboardService()

View File

@ -12,11 +12,9 @@ enum AccountSecurityEffect: Equatable {
/// Any initial data for the view should be loaded.
case loadData
/// The user's vault was locked.
/// The user's vault should be locked.
///
/// - Parameter userInitiated: Did a user action trigger this lock event.
///
case lockVault(userInitiated: Bool)
case lockVault
/// Unlock with Biometrics was toggled.
case toggleUnlockWithBiometrics(Bool)

View File

@ -13,19 +13,18 @@ final class AccountSecurityProcessor: StateProcessor<
// MARK: Types
typealias Services = HasAuthRepository
& HasBiometricsService
& HasBiometricsRepository
& HasClientAuth
& HasErrorReporter
& HasSettingsRepository
& HasStateService
& HasTimeProvider
& HasTwoStepLoginService
& HasVaultTimeoutService
// MARK: Private Properties
/// The `Coordinator` that handles navigation.
private let coordinator: AnyCoordinator<SettingsRoute>
private let coordinator: AnyCoordinator<SettingsRoute, SettingsEvent>
/// The services used by this processor.
private var services: Services
@ -40,7 +39,7 @@ final class AccountSecurityProcessor: StateProcessor<
/// - state: The initial state of the processor.
///
init(
coordinator: AnyCoordinator<SettingsRoute>,
coordinator: AnyCoordinator<SettingsRoute, SettingsEvent>,
services: Services,
state: AccountSecurityState
) {
@ -59,8 +58,14 @@ final class AccountSecurityProcessor: StateProcessor<
await appeared()
case .loadData:
await loadData()
case let .lockVault(userIntiated):
await lockVault(userInitiated: userIntiated)
case .lockVault:
await coordinator.handleEvent(
.authAction(
.lockVault(
userId: nil
)
)
)
case let .toggleUnlockWithBiometrics(isOn):
await setBioMetricAuth(isOn)
}
@ -143,7 +148,7 @@ final class AccountSecurityProcessor: StateProcessor<
///
private func loadBiometricUnlockPreference() async -> BiometricsUnlockStatus {
do {
let biometricsStatus = try await services.biometricsService.getBiometricUnlockStatus()
let biometricsStatus = try await services.biometricsRepository.getBiometricUnlockStatus()
return biometricsStatus
} catch {
Logger.application.debug("Error loading biometric preferences: \(error)")
@ -151,21 +156,6 @@ final class AccountSecurityProcessor: StateProcessor<
}
}
/// Locks the user's vault
///
///
///
private func lockVault(userInitiated: Bool) async {
do {
let account = try await services.stateService.getActiveAccount()
await services.authRepository.lockVault(userId: account.profile.userId)
coordinator.navigate(to: .lockVault(account: account, userInitiated: userInitiated))
} catch {
coordinator.navigate(to: .logout(userInitiated: userInitiated))
services.errorReporter.log(error: error)
}
}
/// Sets the session timeout action.
///
/// - Parameter action: The action that occurs upon a session timeout.
@ -198,7 +188,7 @@ final class AccountSecurityProcessor: StateProcessor<
Task {
do {
state.sessionTimeoutValue = value
try await services.vaultTimeoutService.setVaultTimeout(value: value, userId: nil)
try await services.authRepository.setVaultTimeout(value: value)
} catch {
self.coordinator.navigate(to: .alert(.defaultAlert(title: Localizations.anErrorHasOccurred)))
self.services.errorReporter.log(error: error)
@ -225,12 +215,11 @@ final class AccountSecurityProcessor: StateProcessor<
/// Shows an alert asking the user to confirm that they want to logout.
private func showLogoutConfirmation() {
let alert = Alert.logoutConfirmation {
do {
try await self.services.authRepository.logout()
} catch {
self.services.errorReporter.log(error: error)
}
self.coordinator.navigate(to: .logout(userInitiated: true))
await self.coordinator.handleEvent(
.authAction(
.logout(userId: nil, userInitiated: true)
)
)
}
coordinator.navigate(to: .alert(alert))
}
@ -248,12 +237,12 @@ final class AccountSecurityProcessor: StateProcessor<
///
private func setBioMetricAuth(_ enabled: Bool) async {
do {
try await services.authRepository.allowBioMetricUnlock(enabled, userId: nil)
state.biometricUnlockStatus = try await services.biometricsService.getBiometricUnlockStatus()
try await services.authRepository.allowBioMetricUnlock(enabled)
state.biometricUnlockStatus = try await services.biometricsRepository.getBiometricUnlockStatus()
// Set biometric integrity if needed.
if case .available(_, true, false) = state.biometricUnlockStatus {
try await services.biometricsService.configureBiometricIntegrity()
state.biometricUnlockStatus = try await services.biometricsService.getBiometricUnlockStatus()
try await services.biometricsRepository.configureBiometricIntegrity()
state.biometricUnlockStatus = try await services.biometricsRepository.getBiometricUnlockStatus()
}
} catch {
services.errorReporter.log(error: error)

View File

@ -7,8 +7,8 @@ class AccountSecurityProcessorTests: BitwardenTestCase { // swiftlint:disable:th
var appSettingsStore: MockAppSettingsStore!
var authRepository: MockAuthRepository!
var biometricsService: MockBiometricsService!
var coordinator: MockCoordinator<SettingsRoute>!
var biometricsRepository: MockBiometricsRepository!
var coordinator: MockCoordinator<SettingsRoute, SettingsEvent>!
var errorReporter: MockErrorReporter!
var settingsRepository: MockSettingsRepository!
var stateService: MockStateService!
@ -22,8 +22,8 @@ class AccountSecurityProcessorTests: BitwardenTestCase { // swiftlint:disable:th
appSettingsStore = MockAppSettingsStore()
authRepository = MockAuthRepository()
biometricsService = MockBiometricsService()
coordinator = MockCoordinator<SettingsRoute>()
biometricsRepository = MockBiometricsRepository()
coordinator = MockCoordinator<SettingsRoute, SettingsEvent>()
errorReporter = MockErrorReporter()
settingsRepository = MockSettingsRepository()
stateService = MockStateService()
@ -33,7 +33,7 @@ class AccountSecurityProcessorTests: BitwardenTestCase { // swiftlint:disable:th
coordinator: coordinator.asAnyCoordinator(),
services: ServiceContainer.withMocks(
authRepository: authRepository,
biometricsService: biometricsService,
biometricsRepository: biometricsRepository,
errorReporter: errorReporter,
settingsRepository: settingsRepository,
stateService: stateService,
@ -48,7 +48,7 @@ class AccountSecurityProcessorTests: BitwardenTestCase { // swiftlint:disable:th
appSettingsStore = nil
authRepository = nil
biometricsService = nil
biometricsRepository = nil
coordinator = nil
errorReporter = nil
settingsRepository = nil
@ -93,18 +93,10 @@ class AccountSecurityProcessorTests: BitwardenTestCase { // swiftlint:disable:th
let account: Account = .fixture()
stateService.activeAccount = account
await subject.perform(.lockVault(userInitiated: true))
await subject.perform(.lockVault)
XCTAssertEqual(authRepository.lockVaultUserId, account.profile.userId)
XCTAssertEqual(coordinator.routes.last, .lockVault(account: account, userInitiated: true))
}
/// `perform(_:)` with `.lockVault` fails, locks the vault and navigates to the landing screen.
func test_perform_lockVault_failure() async {
await subject.perform(.lockVault(userInitiated: true))
XCTAssertEqual(errorReporter.errors as? [StateServiceError], [StateServiceError.noActiveAccount])
XCTAssertEqual(coordinator.routes.last, .logout(userInitiated: true))
XCTAssertEqual(authRepository.lockVaultUserId, nil)
XCTAssertEqual(coordinator.events.last, .authAction(.lockVault(userId: nil)))
}
/// `perform(_:)` with `.accountFingerprintPhrasePressed` navigates to the web app
@ -184,29 +176,7 @@ class AccountSecurityProcessorTests: BitwardenTestCase { // swiftlint:disable:th
// Tapping yes logs the user out.
try await alert.tapAction(title: Localizations.yes)
XCTAssertEqual(coordinator.routes.last, .logout(userInitiated: true))
}
/// `receive(_:)` with `.logout` presents a logout confirmation alert.
func test_receive_logout_error() async throws {
authRepository.logoutResult = .failure(StateServiceError.noActiveAccount)
subject.receive(.logout)
let alert = try coordinator.unwrapLastRouteAsAlert()
XCTAssertEqual(alert.title, Localizations.logOut)
XCTAssertEqual(alert.message, Localizations.logoutConfirmation)
XCTAssertEqual(alert.preferredStyle, .alert)
XCTAssertEqual(alert.alertActions.count, 2)
XCTAssertEqual(alert.alertActions[0].title, Localizations.yes)
XCTAssertEqual(alert.alertActions[1].title, Localizations.cancel)
// Tapping yes relays any errors to the error reporter.
try await alert.tapAction(title: Localizations.yes)
XCTAssertEqual(
errorReporter.errors as? [StateServiceError],
[StateServiceError.noActiveAccount]
)
XCTAssertEqual(coordinator.events.last, .authAction(.logout(userId: nil, userInitiated: true)))
}
/// `.receive(_:)` with `.pendingLoginRequestsTapped` navigates to the pending requests view.
@ -389,7 +359,7 @@ class AccountSecurityProcessorTests: BitwardenTestCase { // swiftlint:disable:th
/// `perform(_:)` with `.loadData` updates the state.
func test_perform_loadData_biometricsValue() async {
let biometricUnlockStatus = BiometricsUnlockStatus.available(.faceID, enabled: true, hasValidIntegrity: true)
biometricsService.biometricUnlockStatus = .success(
biometricsRepository.biometricUnlockStatus = .success(
biometricUnlockStatus
)
subject.state.biometricUnlockStatus = .notAvailable
@ -401,7 +371,7 @@ class AccountSecurityProcessorTests: BitwardenTestCase { // swiftlint:disable:th
/// `perform(_:)` with `.loadData` updates the state.
func test_perform_loadData_biometricsValue_error() async {
struct TestError: Error {}
biometricsService.biometricUnlockStatus = .failure(TestError())
biometricsRepository.biometricUnlockStatus = .failure(TestError())
subject.state.biometricUnlockStatus = .notAvailable
await subject.perform(.loadData)
@ -412,7 +382,7 @@ class AccountSecurityProcessorTests: BitwardenTestCase { // swiftlint:disable:th
func test_perform_toggleUnlockWithBiometrics_authRepositoryFailure() async throws {
struct TestError: Error, Equatable {}
let biometricUnlockStatus = BiometricsUnlockStatus.available(.faceID, enabled: true, hasValidIntegrity: true)
biometricsService.biometricUnlockStatus = .success(
biometricsRepository.biometricUnlockStatus = .success(
.available(.touchID, enabled: false, hasValidIntegrity: false)
)
@ -426,10 +396,10 @@ class AccountSecurityProcessorTests: BitwardenTestCase { // swiftlint:disable:th
}
/// `perform(_:)` with `.toggleUnlockWithBiometrics` updates the state.
func test_perform_toggleUnlockWithBiometrics_biometricsServiceFailure() async throws {
func test_perform_toggleUnlockWithBiometrics_biometricsRepositoryFailure() async throws {
struct TestError: Error, Equatable {}
let biometricUnlockStatus = BiometricsUnlockStatus.available(.faceID, enabled: true, hasValidIntegrity: true)
biometricsService.biometricUnlockStatus = .failure(TestError())
biometricsRepository.biometricUnlockStatus = .failure(TestError())
authRepository.allowBiometricUnlockResult = .success(())
subject.state.biometricUnlockStatus = biometricUnlockStatus
@ -443,20 +413,20 @@ class AccountSecurityProcessorTests: BitwardenTestCase { // swiftlint:disable:th
/// `perform(_:)` with `.toggleUnlockWithBiometrics` configures biometric integrity state if needed.
func test_perform_toggleUnlockWithBiometrics_invalidBiometryState() async {
let biometricUnlockStatus = BiometricsUnlockStatus.available(.faceID, enabled: true, hasValidIntegrity: false)
biometricsService.biometricUnlockStatus = .success(
biometricsRepository.biometricUnlockStatus = .success(
biometricUnlockStatus
)
authRepository.allowBiometricUnlockResult = .success(())
subject.state.biometricUnlockStatus = .available(.faceID, enabled: false, hasValidIntegrity: false)
await subject.perform(.toggleUnlockWithBiometrics(false))
XCTAssertTrue(biometricsService.didConfigureBiometricIntegrity)
XCTAssertTrue(biometricsRepository.didConfigureBiometricIntegrity)
}
/// `perform(_:)` with `.toggleUnlockWithBiometrics` updates the state.
func test_perform_toggleUnlockWithBiometrics_success() async {
let biometricUnlockStatus = BiometricsUnlockStatus.available(.faceID, enabled: false, hasValidIntegrity: true)
biometricsService.biometricUnlockStatus = .success(
biometricsRepository.biometricUnlockStatus = .success(
biometricUnlockStatus
)
authRepository.allowBiometricUnlockResult = .success(())

View File

@ -105,7 +105,7 @@ struct AccountSecurityView: View {
accessibilityIdentifier: "LockNowLabel"
) {
Task {
await store.perform(.lockVault(userInitiated: true))
await store.perform(.lockVault)
}
}

Some files were not shown because too many files have changed in this diff Show More