mirror of
https://github.com/bitwarden/ios.git
synced 2025-12-11 04:34:55 -06:00
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:
parent
a3febaeeb4
commit
41d8e61a3b
@ -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
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 "com.8bit.bitwarden.extension-setup"
|
||||
).@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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
198
BitwardenShared/Core/Auth/Services/KeychainRepository.swift
Normal file
198
BitwardenShared/Core/Auth/Services/KeychainRepository.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
279
BitwardenShared/Core/Auth/Services/KeychainRepositoryTests.swift
Normal file
279
BitwardenShared/Core/Auth/Services/KeychainRepositoryTests.swift
Normal 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, [])
|
||||
}
|
||||
}
|
||||
112
BitwardenShared/Core/Auth/Services/KeychainService.swift
Normal file
112
BitwardenShared/Core/Auth/Services/KeychainService.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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!
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
27
BitwardenShared/UI/Auth/AuthAction.swift
Normal file
27
BitwardenShared/UI/Auth/AuthAction.swift
Normal 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)
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
58
BitwardenShared/UI/Auth/AuthEvent.swift
Normal file
58
BitwardenShared/UI/Auth/AuthEvent.swift
Normal 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)
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
106
BitwardenShared/UI/Auth/AuthRouter.swift
Normal file
106
BitwardenShared/UI/Auth/AuthRouter.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
852
BitwardenShared/UI/Auth/AuthRouterTests.swift
Normal file
852
BitwardenShared/UI/Auth/AuthRouterTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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: {}
|
||||
)
|
||||
|
||||
345
BitwardenShared/UI/Auth/Extensions/AuthRouter+Redirects.swift
Normal file
345
BitwardenShared/UI/Auth/Extensions/AuthRouter+Redirects.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
),
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
@ -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
|
||||
) {
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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
|
||||
) {
|
||||
|
||||
@ -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!
|
||||
|
||||
|
||||
@ -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
|
||||
) {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
) {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
|
||||
@ -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,
|
||||
]
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
@ -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"
|
||||
),
|
||||
],
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
),
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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])
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
) {
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(())
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user