Refactor logic for auth requests & decrypt all fingerprints (#800)

This commit is contained in:
Caleb Derosier 2024-01-26 14:17:21 -07:00 committed by Álison Fernandes
parent a4e99745bc
commit a7e393e325
16 changed files with 367 additions and 194 deletions

View file

@ -16,7 +16,6 @@ import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
@ -184,11 +183,6 @@ interface AuthRepository : AuthenticatorProvider {
*/
suspend fun getAuthRequests(): AuthRequestsResult
/**
* Gets a unique fingerprint phrase for this user.
*/
suspend fun getFingerprintPhrase(email: String): UserFingerprintResult
/**
* Get a [Boolean] indicating whether this is a known device.
*/

View file

@ -582,17 +582,15 @@ class AuthRepositoryImpl(
authSdkSource
.getNewAuthRequest(email)
.flatMap { authRequest ->
newAuthRequestService.createAuthRequest(
newAuthRequestService
.createAuthRequest(
email = email,
publicKey = authRequest.publicKey,
deviceId = authDiskSource.uniqueAppId,
accessCode = authRequest.accessCode,
fingerprint = authRequest.fingerprint,
)
}
.fold(
onFailure = { AuthRequestResult.Error },
onSuccess = { request ->
.map { request ->
AuthRequestResult.Success(
authRequest = AuthRequest(
id = request.id,
@ -605,19 +603,27 @@ class AuthRepositoryImpl(
responseDate = request.responseDate,
requestApproved = request.requestApproved ?: false,
originUrl = request.originUrl,
fingerprint = authRequest.fingerprint,
),
)
},
}
}
.fold(
onFailure = { AuthRequestResult.Error },
onSuccess = { it },
)
override suspend fun getAuthRequests(): AuthRequestsResult =
authRequestsService.getAuthRequests()
authRequestsService
.getAuthRequests()
.fold(
onFailure = { AuthRequestsResult.Error },
onSuccess = { response ->
AuthRequestsResult.Success(
authRequests = response.authRequests.map { request ->
AuthRequest(
authRequests = response.authRequests.mapNotNull { request ->
when (val result = getFingerprintPhrase(request.publicKey)) {
is UserFingerprintResult.Error -> null
is UserFingerprintResult.Success -> AuthRequest(
id = request.id,
publicKey = request.publicKey,
platform = request.platform,
@ -628,27 +634,12 @@ class AuthRepositoryImpl(
responseDate = request.responseDate,
requestApproved = request.requestApproved ?: false,
originUrl = request.originUrl,
)
},
)
},
)
override suspend fun getFingerprintPhrase(
email: String,
): UserFingerprintResult =
authSdkSource
.getNewAuthRequest(email)
.flatMap { requestResponse ->
authSdkSource
.getUserFingerprint(
email = email,
publicKey = requestResponse.publicKey,
fingerprint = result.fingerprint,
)
}
.fold(
onFailure = { UserFingerprintResult.Error },
onSuccess = { UserFingerprintResult.Success(it) },
},
)
},
)
override suspend fun getIsKnownDevice(emailAddress: String): KnownDeviceResult =
@ -690,6 +681,23 @@ class AuthRepositoryImpl(
},
)
private suspend fun getFingerprintPhrase(
publicKey: String,
): UserFingerprintResult {
val profile = authDiskSource.userState?.activeAccount?.profile
?: return UserFingerprintResult.Error
return authSdkSource
.getUserFingerprint(
email = profile.email,
publicKey = publicKey,
)
.fold(
onFailure = { UserFingerprintResult.Error },
onSuccess = { UserFingerprintResult.Success(it) },
)
}
/**
* Get the remembered two-factor token associated with the user's email, if applicable.
*/

View file

@ -15,6 +15,7 @@ import java.time.ZonedDateTime
* @param responseDate The date & time on which this request was responded to.
* @param requestApproved Whether this request was approved.
* @param originUrl The origin URL of this auth request.
* @param fingerprint The fingerprint of this auth request.
*/
data class AuthRequest(
val id: String,
@ -27,4 +28,5 @@ data class AuthRequest(
val responseDate: ZonedDateTime?,
val requestApproved: Boolean,
val originUrl: String,
val fingerprint: String,
)

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.platform.repository
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
@ -113,6 +114,11 @@ interface SettingsRepository {
*/
fun disableAutofill()
/**
* Gets the unique fingerprint phrase for the current user.
*/
suspend fun getUserFingerprint(): UserFingerprintResult
/**
* Sets default values for various settings for the given [userId] if necessary. This is
* typically used when logging into a new account.

View file

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.platform.repository
import android.view.autofill.AutofillManager
import com.x8bit.bitwarden.BuildConfig
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
@ -252,6 +253,19 @@ class SettingsRepositoryImpl(
mutableIsAutofillEnabledStateFlow.value = false
}
@Suppress("ReturnCount")
override suspend fun getUserFingerprint(): UserFingerprintResult {
val userId = activeUserId
?: return UserFingerprintResult.Error
return vaultSdkSource
.getUserFingerprint(userId)
.fold(
onFailure = { UserFingerprintResult.Error },
onSuccess = { UserFingerprintResult.Success(it) },
)
}
override fun setDefaultsIfNecessary(userId: String) {
// Set Vault Settings defaults
if (!isVaultTimeoutActionSet(userId = userId)) {

View file

@ -68,6 +68,11 @@ interface VaultSdkSource {
*/
suspend fun getUserEncryptionKey(userId: String): Result<String>
/**
* Gets the user's fingerprint.
*/
suspend fun getUserFingerprint(userId: String): Result<String>
/**
* Attempts to initialize cryptography functionality for an individual user with the given
* [userId] for the Bitwarden SDK with a given [InitUserCryptoRequest].

View file

@ -65,6 +65,15 @@ class VaultSdkSourceImpl(
.getUserEncryptionKey()
}
override suspend fun getUserFingerprint(
userId: String,
): Result<String> =
runCatching {
getClient(userId = userId)
.platform()
.userFingerprint(userId)
}
override suspend fun initializeCrypto(
userId: String,
request: InitUserCryptoRequest,

View file

@ -6,7 +6,6 @@ import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
@ -34,14 +33,6 @@ class LoginWithDeviceViewModel @Inject constructor(
) {
init {
sendNewAuthRequest()
viewModelScope.launch {
trySendAction(
LoginWithDeviceAction.Internal.FingerprintPhraseReceived(
result = authRepository.getFingerprintPhrase(state.emailAddress),
),
)
}
}
override fun handleAction(action: LoginWithDeviceAction) {
@ -53,10 +44,6 @@ class LoginWithDeviceViewModel @Inject constructor(
is LoginWithDeviceAction.Internal.NewAuthRequestResultReceive -> {
handleNewAuthRequestResultReceived(action)
}
is LoginWithDeviceAction.Internal.FingerprintPhraseReceived -> {
handleFingerprintPhraseReceived(action)
}
}
}
@ -75,27 +62,20 @@ class LoginWithDeviceViewModel @Inject constructor(
private fun handleNewAuthRequestResultReceived(
action: LoginWithDeviceAction.Internal.NewAuthRequestResultReceive,
) {
if (action.result is AuthRequestResult.Error) {
// TODO BIT-1563 handle error
}
}
private fun handleFingerprintPhraseReceived(
action: LoginWithDeviceAction.Internal.FingerprintPhraseReceived,
) {
when (action.result) {
is UserFingerprintResult.Success -> {
is AuthRequestResult.Success -> {
mutableStateFlow.update {
it.copy(
viewState = LoginWithDeviceState.ViewState.Content(
fingerprintPhrase = action.result.fingerprint,
fingerprintPhrase = action.result.authRequest.fingerprint,
),
)
}
}
is UserFingerprintResult.Error -> {
is AuthRequestResult.Error -> {
// TODO BIT-1563 display error dialog
mutableStateFlow.update {
it.copy(
viewState = LoginWithDeviceState.ViewState.Error(
@ -209,12 +189,5 @@ sealed class LoginWithDeviceAction {
data class NewAuthRequestResultReceive(
val result: AuthRequestResult,
) : Internal()
/**
* A fingerprint phrase for this user has been received.
*/
data class FingerprintPhraseReceived(
val result: UserFingerprintResult,
) : Internal()
}
}

View file

@ -5,6 +5,7 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout
@ -18,6 +19,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@ -38,7 +40,7 @@ class AccountSecurityViewModel @Inject constructor(
initialState = savedStateHandle[KEY_STATE]
?: AccountSecurityState(
dialog = null,
fingerprintPhrase = "fingerprint-placeholder".asText(),
fingerprintPhrase = "".asText(), // This will be filled in dynamically
isApproveLoginRequestsEnabled = settingsRepository.isApprovePasswordlessLoginsEnabled,
isUnlockWithBiometricsEnabled = false,
isUnlockWithPinEnabled = settingsRepository.isUnlockWithPinEnabled,
@ -59,6 +61,14 @@ class AccountSecurityViewModel @Inject constructor(
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
viewModelScope.launch {
trySendAction(
AccountSecurityAction.Internal.FingerprintResultReceive(
fingerprintResult = settingsRepository.getUserFingerprint(),
),
)
}
}
override fun handleAction(action: AccountSecurityAction): Unit = when (action) {
@ -92,6 +102,10 @@ class AccountSecurityViewModel @Inject constructor(
is AccountSecurityAction.PushNotificationConfirm -> {
handlePushNotificationConfirm()
}
is AccountSecurityAction.Internal.FingerprintResultReceive -> {
handleFingerprintResultReceived(action)
}
}
private fun handleAccountFingerprintPhraseClick() {
@ -239,6 +253,20 @@ class AccountSecurityViewModel @Inject constructor(
}
}
}
private fun handleFingerprintResultReceived(
action: AccountSecurityAction.Internal.FingerprintResultReceive,
) {
mutableStateFlow.update {
it.copy(
fingerprintPhrase = when (val result = action.fingerprintResult) {
is UserFingerprintResult.Success -> result.fingerprint.asText()
// This should never fail for an unlocked account.
is UserFingerprintResult.Error -> "".asText()
},
)
}
}
}
/**
@ -474,4 +502,16 @@ sealed class AccountSecurityAction {
override val isUnlockWithPinEnabled: Boolean get() = true
}
}
/**
* Models actions that can be sent by the view model itself.
*/
sealed class Internal : AccountSecurityAction() {
/**
* A fingerprint has been received.
*/
data class FingerprintResultReceive(
val fingerprintResult: UserFingerprintResult,
) : Internal()
}
}

View file

@ -77,7 +77,7 @@ class PendingRequestsViewModel @Inject constructor(
PendingRequestsState.ViewState.Content(
requests = result.authRequests.map { authRequest ->
PendingRequestsState.ViewState.Content.PendingLoginRequest(
fingerprintPhrase = authRequest.publicKey,
fingerprintPhrase = authRequest.fingerprint,
platform = authRequest.platform,
timestamp = dateTimeFormatter.format(
authRequest.creationDate,

View file

@ -50,7 +50,6 @@ import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
@ -2135,6 +2134,7 @@ class AuthRepositoryTest {
responseDate = null,
requestApproved = true,
originUrl = "www.bitwarden.com",
fingerprint = fingerprint,
),
)
coEvery {
@ -2179,11 +2179,12 @@ class AuthRepositoryTest {
@Test
fun `getAuthRequests should return success when service returns success`() = runTest {
val fingerprint = "fingerprint"
val responseJson = AuthRequestsResponseJson(
authRequests = listOf(
AuthRequestsResponseJson.AuthRequest(
id = "1",
publicKey = "2",
publicKey = PUBLIC_KEY,
platform = "Android",
ipAddress = "192.168.0.1",
key = "public",
@ -2199,7 +2200,46 @@ class AuthRepositoryTest {
authRequests = listOf(
AuthRequest(
id = "1",
publicKey = "2",
publicKey = PUBLIC_KEY,
platform = "Android",
ipAddress = "192.168.0.1",
key = "public",
masterPasswordHash = "verySecureHash",
creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"),
responseDate = null,
requestApproved = true,
originUrl = "www.bitwarden.com",
fingerprint = fingerprint,
),
),
)
coEvery {
authSdkSource.getUserFingerprint(
email = EMAIL,
publicKey = PUBLIC_KEY,
)
} returns Result.success(fingerprint)
coEvery {
authRequestsService.getAuthRequests()
} returns responseJson.asSuccess()
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
val result = repository.getAuthRequests()
coVerify(exactly = 1) {
authRequestsService.getAuthRequests()
authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY)
}
assertEquals(expected, result)
}
@Test
fun `getAuthRequests should return empty list when user profile is null`() = runTest {
val responseJson = AuthRequestsResponseJson(
authRequests = listOf(
AuthRequestsResponseJson.AuthRequest(
id = "1",
publicKey = PUBLIC_KEY,
platform = "Android",
ipAddress = "192.168.0.1",
key = "public",
@ -2211,6 +2251,7 @@ class AuthRepositoryTest {
),
),
)
val expected = AuthRequestsResult.Success(emptyList())
coEvery {
authRequestsService.getAuthRequests()
} returns responseJson.asSuccess()
@ -2223,68 +2264,6 @@ class AuthRepositoryTest {
assertEquals(expected, result)
}
@Test
fun `getUserFingerprint should return failure when source returns failure`() = runTest {
coEvery {
authSdkSource.getNewAuthRequest(EMAIL)
} returns Result.success(
mockk<AuthRequestResponse> {
every { publicKey } returns PUBLIC_KEY
},
)
coEvery {
authSdkSource.getUserFingerprint(
email = EMAIL,
publicKey = PUBLIC_KEY,
)
} returns Result.failure(Throwable())
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
val result = repository.getFingerprintPhrase(EMAIL)
coVerify(exactly = 1) {
authSdkSource.getNewAuthRequest(EMAIL)
authSdkSource.getUserFingerprint(
email = EMAIL,
publicKey = PUBLIC_KEY,
)
}
assertEquals(UserFingerprintResult.Error, result)
}
@Test
fun `getUserFingerprint should return success when source returns success`() = runTest {
val fingerprint = "fingerprint"
coEvery {
authSdkSource.getNewAuthRequest(EMAIL)
} returns Result.success(
AuthRequestResponse(
fingerprint = fingerprint,
publicKey = PUBLIC_KEY,
privateKey = "key",
accessCode = "accessCode",
),
)
coEvery {
authSdkSource.getUserFingerprint(
email = EMAIL,
publicKey = PUBLIC_KEY,
)
} returns Result.success(fingerprint)
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
val result = repository.getFingerprintPhrase(EMAIL)
coVerify(exactly = 1) {
authSdkSource.getNewAuthRequest(EMAIL)
authSdkSource.getUserFingerprint(
email = EMAIL,
publicKey = PUBLIC_KEY,
)
}
assertEquals(UserFingerprintResult.Success(fingerprint), result)
}
@Test
fun `getIsKnownDevice should return failure when service returns failure`() = runTest {
coEvery {
@ -2462,7 +2441,7 @@ class AuthRepositoryTest {
private val ACCOUNT_1 = AccountJson(
profile = AccountJson.Profile(
userId = USER_ID_1,
email = "test@bitwarden.com",
email = EMAIL,
isEmailVerified = true,
name = "Bitwarden Tester",
hasPremium = false,

View file

@ -5,6 +5,7 @@ import app.cash.turbine.test
import com.bitwarden.core.DerivePinKeyResponse
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
import com.x8bit.bitwarden.data.platform.datasource.disk.util.FakeSettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager
@ -555,6 +556,56 @@ class SettingsRepositoryTest {
verify { autofillManager.disableAutofillServices() }
}
@Test
fun `getUserFingerprint should return failure with no active user`() = runTest {
fakeAuthDiskSource.userState = null
val result = settingsRepository.getUserFingerprint()
assertEquals(UserFingerprintResult.Error, result)
}
@Test
fun `getUserFingerprint should return failure with active user when source returns failure`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery {
vaultSdkSource.getUserFingerprint(
userId = MOCK_USER_STATE.activeUserId,
)
} returns Result.failure(Throwable())
val result = settingsRepository.getUserFingerprint()
coVerify(exactly = 1) {
vaultSdkSource.getUserFingerprint(
userId = MOCK_USER_STATE.activeUserId,
)
}
assertEquals(UserFingerprintResult.Error, result)
}
@Test
fun `getUserFingerprint should return success with active user when source returns success`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val fingerprint = "fingerprint"
coEvery {
vaultSdkSource.getUserFingerprint(
userId = MOCK_USER_STATE.activeUserId,
)
} returns Result.success(fingerprint)
val result = settingsRepository.getUserFingerprint()
coVerify(exactly = 1) {
vaultSdkSource.getUserFingerprint(
userId = MOCK_USER_STATE.activeUserId,
)
}
assertEquals(UserFingerprintResult.Success(fingerprint), result)
}
@Test
fun `getPullToRefreshEnabledFlow should react to changes in SettingsDiskSource`() = runTest {
val userId = "userId"
@ -728,7 +779,8 @@ class SettingsRepositoryTest {
@Suppress("MaxLineLength")
@Test
fun `isScreenCaptureAllowed property should update SettingsDiskSource and emit changes`() = runTest {
fun `isScreenCaptureAllowed property should update SettingsDiskSource and emit changes`() =
runTest {
val userId = "userId"
fakeAuthDiskSource.userState = MOCK_USER_STATE

View file

@ -22,6 +22,7 @@ import com.bitwarden.sdk.BitwardenException
import com.bitwarden.sdk.Client
import com.bitwarden.sdk.ClientCrypto
import com.bitwarden.sdk.ClientPasswordHistory
import com.bitwarden.sdk.ClientPlatform
import com.bitwarden.sdk.ClientVault
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
import com.x8bit.bitwarden.data.platform.util.asFailure
@ -42,12 +43,14 @@ import org.junit.jupiter.api.Test
@Suppress("LargeClass")
class VaultSdkSourceTest {
private val clientCrypto = mockk<ClientCrypto>()
private val clientPlatform = mockk<ClientPlatform>()
private val clientPasswordHistory = mockk<ClientPasswordHistory>()
private val clientVault = mockk<ClientVault>() {
every { passwordHistory() } returns clientPasswordHistory
}
private val client = mockk<Client>() {
every { vault() } returns clientVault
every { platform() } returns clientPlatform
every { crypto() } returns clientCrypto
}
private val sdkClientManager = mockk<SdkClientManager> {
@ -132,6 +135,28 @@ class VaultSdkSourceTest {
verify { sdkClientManager.getOrCreateClient(userId = userId) }
}
@Test
fun `getUserFingerprint should call SDK and return a Result with correct data`() = runBlocking {
val userId = "userId"
val expectedResult = "fingerprint"
coEvery {
clientPlatform.userFingerprint(
fingerprintMaterial = userId,
)
} returns expectedResult
val result = vaultSdkSource.getUserFingerprint(userId)
assertEquals(
expectedResult.asSuccess(),
result,
)
coVerify {
clientPlatform.userFingerprint(
fingerprintMaterial = userId,
)
}
}
@Test
fun `initializeUserCrypto with sdk success should return InitializeCryptoResult Success`() =
runBlocking {

View file

@ -4,26 +4,25 @@ import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequest
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.time.ZonedDateTime
class LoginWithDeviceViewModelTest : BaseViewModelTest() {
private val authRepository = mockk<AuthRepository> {
coEvery {
getFingerprintPhrase(EMAIL)
} returns UserFingerprintResult.Success("initialFingerprint")
coEvery {
createAuthRequest(EMAIL)
} returns mockk<AuthRequestResult.Success>()
} returns AuthRequestResult.Success(AUTH_REQUEST)
}
@Test
@ -33,7 +32,6 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() {
assertEquals(DEFAULT_STATE, awaitItem())
}
coVerify { authRepository.createAuthRequest(EMAIL) }
coVerify { authRepository.getFingerprintPhrase(EMAIL) }
}
@Test
@ -42,14 +40,11 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() {
coEvery {
authRepository.createAuthRequest(newEmail)
} returns mockk<AuthRequestResult.Success>()
coEvery {
authRepository.getFingerprintPhrase(newEmail)
} returns UserFingerprintResult.Success("initialFingerprint")
} returns AuthRequestResult.Success(AUTH_REQUEST)
val state = LoginWithDeviceState(
emailAddress = newEmail,
viewState = LoginWithDeviceState.ViewState.Content(
fingerprintPhrase = "initialFingerprint",
fingerprintPhrase = FINGERPRINT,
),
)
val viewModel = createViewModel(state)
@ -58,7 +53,6 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() {
}
coVerify {
authRepository.createAuthRequest(newEmail)
authRepository.getFingerprintPhrase(newEmail)
}
}
@ -101,13 +95,17 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() {
}
@Test
fun `on fingerprint result success received should show content`() = runTest {
fun `on auth request result success received should show content`() = runTest {
val newFingerprint = "newFingerprint"
val viewModel = createViewModel()
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
viewModel.actionChannel.trySend(
LoginWithDeviceAction.Internal.FingerprintPhraseReceived(
result = UserFingerprintResult.Success(newFingerprint),
LoginWithDeviceAction.Internal.NewAuthRequestResultReceive(
result = AuthRequestResult.Success(
authRequest = mockk<AuthRequest> {
every { fingerprint } returns newFingerprint
},
),
),
)
assertEquals(
@ -125,8 +123,8 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel()
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
viewModel.actionChannel.trySend(
LoginWithDeviceAction.Internal.FingerprintPhraseReceived(
result = UserFingerprintResult.Error,
LoginWithDeviceAction.Internal.NewAuthRequestResultReceive(
result = AuthRequestResult.Error,
),
)
assertEquals(
@ -149,11 +147,25 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() {
companion object {
private const val EMAIL = "test@gmail.com"
private const val FINGERPRINT = "fingerprint"
private val DEFAULT_STATE = LoginWithDeviceState(
emailAddress = EMAIL,
viewState = LoginWithDeviceState.ViewState.Content(
fingerprintPhrase = "initialFingerprint",
fingerprintPhrase = FINGERPRINT,
),
)
private val AUTH_REQUEST = AuthRequest(
id = "1",
publicKey = "2",
platform = "Android",
ipAddress = "192.168.0.1",
key = "public",
masterPasswordHash = "verySecureHash",
creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"),
responseDate = null,
requestApproved = true,
originUrl = "www.bitwarden.com",
fingerprint = FINGERPRINT,
)
}
}

View file

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.Environment
@ -12,6 +13,8 @@ import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentReposito
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
@ -29,8 +32,13 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
@Test
fun `initial state should be correct when saved state is set`() {
val viewModel = createViewModel(initialState = DEFAULT_STATE)
val settingsRepository = getMockSettingsRepository()
val viewModel = createViewModel(
initialState = DEFAULT_STATE,
settingsRepository = settingsRepository,
)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
coVerify { settingsRepository.getUserFingerprint() }
}
@Test
@ -40,6 +48,7 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
every { vaultTimeout } returns VaultTimeout.ThirtyMinutes
every { vaultTimeoutAction } returns VaultTimeoutAction.LOCK
every { isApprovePasswordlessLoginsEnabled } returns false
coEvery { getUserFingerprint() } returns UserFingerprintResult.Success(FINGERPRINT)
}
val viewModel = createViewModel(
initialState = null,
@ -49,6 +58,33 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
DEFAULT_STATE.copy(isUnlockWithPinEnabled = true),
viewModel.stateFlow.value,
)
coVerify { settingsRepository.getUserFingerprint() }
}
@Test
fun `on FingerprintResultReceive should update the fingerprint phrase`() = runTest {
val fingerprint = "fingerprint"
val viewModel = createViewModel()
// Set fingerprint phrase to value received
viewModel.trySendAction(
AccountSecurityAction.Internal.FingerprintResultReceive(
UserFingerprintResult.Success(fingerprint),
),
)
assertEquals(
DEFAULT_STATE.copy(fingerprintPhrase = fingerprint.asText()),
viewModel.stateFlow.value,
)
// Clear fingerprint phrase
viewModel.trySendAction(
AccountSecurityAction.Internal.FingerprintResultReceive(
UserFingerprintResult.Error,
),
)
assertEquals(
DEFAULT_STATE.copy(fingerprintPhrase = "".asText()),
viewModel.stateFlow.value,
)
}
@Test
@ -151,6 +187,7 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
fun `on VaultTimeoutTypeSelect should update the selection()`() = runTest {
val settingsRepository = mockk<SettingsRepository>() {
every { vaultTimeout = any() } just runs
coEvery { getUserFingerprint() } returns UserFingerprintResult.Success(FINGERPRINT)
}
val viewModel = createViewModel(settingsRepository = settingsRepository)
viewModel.trySendAction(
@ -169,6 +206,7 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
fun `on CustomVaultTimeoutSelect should update the selection()`() = runTest {
val settingsRepository = mockk<SettingsRepository>() {
every { vaultTimeout = any() } just runs
coEvery { getUserFingerprint() } returns UserFingerprintResult.Success(FINGERPRINT)
}
val viewModel = createViewModel(settingsRepository = settingsRepository)
viewModel.trySendAction(
@ -191,6 +229,7 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
fun `on VaultTimeoutActionSelect should update vault timeout action`() = runTest {
val settingsRepository = mockk<SettingsRepository>() {
every { vaultTimeoutAction = any() } just runs
coEvery { getUserFingerprint() } returns UserFingerprintResult.Success(FINGERPRINT)
}
val viewModel = createViewModel(settingsRepository = settingsRepository)
viewModel.trySendAction(
@ -257,6 +296,7 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
)
val settingsRepository: SettingsRepository = mockk() {
every { clearUnlockPin() } just runs
coEvery { getUserFingerprint() } returns UserFingerprintResult.Success(FINGERPRINT)
}
val viewModel = createViewModel(
initialState = initialState,
@ -296,6 +336,7 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
)
val settingsRepository: SettingsRepository = mockk() {
every { storeUnlockPin(any(), any()) } just runs
coEvery { getUserFingerprint() } returns UserFingerprintResult.Success(FINGERPRINT)
}
val viewModel = createViewModel(
initialState = initialState,
@ -353,6 +394,7 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
runTest {
val settingsRepository = mockk<SettingsRepository> {
every { isApprovePasswordlessLoginsEnabled = true } just runs
coEvery { getUserFingerprint() } returns UserFingerprintResult.Success(FINGERPRINT)
}
val viewModel = createViewModel(
settingsRepository = settingsRepository,
@ -387,6 +429,7 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
runTest {
val settingsRepository = mockk<SettingsRepository> {
every { isApprovePasswordlessLoginsEnabled = false } just runs
coEvery { getUserFingerprint() } returns UserFingerprintResult.Success(FINGERPRINT)
}
val viewModel = createViewModel(
settingsRepository = settingsRepository,
@ -416,13 +459,21 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
}
}
/**
* Returns a [mockk] of the [SettingsRepository] with the call made on init already mocked.
*/
private fun getMockSettingsRepository(): SettingsRepository =
mockk<SettingsRepository> {
coEvery { getUserFingerprint() } returns UserFingerprintResult.Success(FINGERPRINT)
}
@Suppress("LongParameterList")
private fun createViewModel(
initialState: AccountSecurityState? = DEFAULT_STATE,
authRepository: AuthRepository = mockk(relaxed = true),
vaultRepository: VaultRepository = mockk(relaxed = true),
settingsRepository: SettingsRepository = mockk(relaxed = true),
environmentRepository: EnvironmentRepository = fakeEnvironmentRepository,
settingsRepository: SettingsRepository = getMockSettingsRepository(),
savedStateHandle: SavedStateHandle = SavedStateHandle().apply {
set("state", initialState)
},
@ -435,9 +486,10 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
)
companion object {
private const val FINGERPRINT = "fingerprint"
private val DEFAULT_STATE = AccountSecurityState(
dialog = null,
fingerprintPhrase = "fingerprint-placeholder".asText(),
fingerprintPhrase = FINGERPRINT.asText(),
isApproveLoginRequestsEnabled = false,
isUnlockWithBiometricsEnabled = false,
isUnlockWithPinEnabled = false,

View file

@ -56,7 +56,7 @@ class PendingRequestsViewModelTest : BaseViewModelTest() {
authRequests = listOf(
AuthRequest(
id = "1",
publicKey = "pantry-overdue-survive-sleep-jab",
publicKey = "publicKey-1",
platform = "Android",
ipAddress = "192.168.0.1",
key = "publicKey",
@ -65,10 +65,11 @@ class PendingRequestsViewModelTest : BaseViewModelTest() {
responseDate = null,
requestApproved = true,
originUrl = "www.bitwarden.com",
fingerprint = "pantry-overdue-survive-sleep-jab",
),
AuthRequest(
id = "2",
publicKey = "erupt-anew-matchbook-disk-student",
publicKey = "publicKey-2",
platform = "iOS",
ipAddress = "192.168.0.2",
key = "publicKey",
@ -77,6 +78,7 @@ class PendingRequestsViewModelTest : BaseViewModelTest() {
responseDate = null,
requestApproved = false,
originUrl = "www.bitwarden.com",
fingerprint = "erupt-anew-matchbook-disk-student",
),
),
)