diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt index e3072015c..6586f62bf 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt @@ -13,6 +13,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult import com.x8bit.bitwarden.data.vault.repository.model.VaultData import com.x8bit.bitwarden.data.vault.repository.model.VaultState +import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -68,7 +69,7 @@ interface VaultRepository { /** * Flow that represents the totp code. */ - val totpCodeFlow: Flow + val totpCodeFlow: Flow /** * Clear any previously unlocked, in-memory data (vault, send, etc). @@ -108,9 +109,9 @@ interface VaultRepository { fun lockVaultIfNecessary(userId: String) /** - * Emits the totp code flow to listeners. + * Emits the totp code result flow to listeners. */ - fun emitTotpCode(totpCode: String) + fun emitTotpCodeResult(totpCodeResult: TotpCodeResult) /** * Attempt to unlock the vault and sync the vault data for the currently active user. diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt index 824e484c0..43931370d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt @@ -37,6 +37,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult import com.x8bit.bitwarden.data.vault.repository.model.VaultData import com.x8bit.bitwarden.data.vault.repository.model.VaultState +import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipher import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkSend @@ -93,7 +94,7 @@ class VaultRepositoryImpl( private val activeUserId: String? get() = authDiskSource.userState?.activeUserId - private val mutableTotpCodeFlow = bufferedMutableSharedFlow() + private val mutableTotpCodeResultFlow = bufferedMutableSharedFlow() private val mutableVaultStateStateFlow = MutableStateFlow( @@ -138,8 +139,8 @@ class VaultRepositoryImpl( initialValue = DataState.Loading, ) - override val totpCodeFlow: Flow - get() = mutableTotpCodeFlow.asSharedFlow() + override val totpCodeFlow: Flow + get() = mutableTotpCodeResultFlow.asSharedFlow() override val ciphersStateFlow: StateFlow>> get() = mutableCiphersStateFlow.asStateFlow() @@ -285,8 +286,8 @@ class VaultRepositoryImpl( setVaultToLocked(userId = userId) } - override fun emitTotpCode(totpCode: String) { - mutableTotpCodeFlow.tryEmit(totpCode) + override fun emitTotpCodeResult(totpCodeResult: TotpCodeResult) { + mutableTotpCodeResultFlow.tryEmit(totpCodeResult) } @Suppress("ReturnCount") diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/TotpCodeResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/TotpCodeResult.kt new file mode 100644 index 000000000..d6b4cd785 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/TotpCodeResult.kt @@ -0,0 +1,17 @@ +package com.x8bit.bitwarden.data.vault.repository.model + +/** + * Models result of the user adding a totp code. + */ +sealed class TotpCodeResult { + + /** + * Code has been successfully added. + */ + data class Success(val code: String) : TotpCodeResult() + + /** + * There was an error scanning the code. + */ + data object CodeScanningError : TotpCodeResult() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt index 36630b278..f2c3e8d90 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt @@ -10,6 +10,7 @@ import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.platform.repository.util.takeUntilLoaded import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult +import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.util.Text @@ -89,7 +90,7 @@ class VaultAddEditViewModel @Inject constructor( vaultRepository .totpCodeFlow - .map { VaultAddEditAction.Internal.TotpCodeReceive(totpCode = it) } + .map { VaultAddEditAction.Internal.TotpCodeReceive(totpResult = it) } .onEach(::sendAction) .launchIn(viewModelScope) } @@ -843,15 +844,29 @@ class VaultAddEditViewModel @Inject constructor( } private fun handleVaultTotpCodeReceive(action: VaultAddEditAction.Internal.TotpCodeReceive) { - updateLoginContent { loginType -> - loginType.copy(totp = action.totpCode) - } + when (action.totpResult) { + is TotpCodeResult.Success -> { + sendEvent( + event = VaultAddEditEvent.ShowToast( + message = R.string.authenticator_key_added.asText(), + ), + ) - sendEvent( - event = VaultAddEditEvent.ShowToast( - message = R.string.authenticator_key_added.asText(), - ), - ) + updateLoginContent { loginType -> + loginType.copy(totp = action.totpResult.code) + } + } + + TotpCodeResult.CodeScanningError -> { + mutableStateFlow.update { + it.copy( + dialog = VaultAddEditState.DialogState.Error( + R.string.authenticator_key_read_error.asText(), + ), + ) + } + } + } } //endregion Internal Type Handlers @@ -1612,9 +1627,9 @@ sealed class VaultAddEditAction { sealed class Internal : VaultAddEditAction() { /** - * Indicates that the vault totp code has been received. + * Indicates that the vault totp code result has been received. */ - data class TotpCodeReceive(val totpCode: String) : Internal() + data class TotpCodeReceive(val totpResult: TotpCodeResult) : Internal() /** * Indicates that the vault item data has been received. diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/manualcodeentry/ManualCodeEntryViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/manualcodeentry/ManualCodeEntryViewModel.kt index 02f28261b..172afcc9a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/manualcodeentry/ManualCodeEntryViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/manualcodeentry/ManualCodeEntryViewModel.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.vault.feature.manualcodeentry import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import com.x8bit.bitwarden.data.vault.repository.VaultRepository +import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.util.Text import dagger.hilt.android.lifecycle.HiltViewModel @@ -45,7 +46,7 @@ class ManualCodeEntryViewModel @Inject constructor( } private fun handleCodeSubmit() { - vaultRepository.emitTotpCode(state.code) + vaultRepository.emitTotpCodeResult(TotpCodeResult.Success(state.code)) sendEvent(ManualCodeEntryEvent.NavigateBack) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanViewModel.kt index b7c5bc156..af2d93eb2 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanViewModel.kt @@ -1,11 +1,19 @@ package com.x8bit.bitwarden.ui.vault.feature.qrcodescan +import android.net.Uri import com.x8bit.bitwarden.data.vault.repository.VaultRepository +import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.util.Text import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +private const val ALGORITHM = "algorithm" +private const val DIGITS = "digits" +private const val PERIOD = "period" +private const val SECRET = "secret" +private const val TOTP_CODE_PREFIX = "otpauth://totp" + /** * Handles [QrCodeScanAction], * and launches [QrCodeScanEvent] for the [QrCodeScanScreen]. @@ -37,8 +45,31 @@ class QrCodeScanViewModel @Inject constructor( ) } + // For more information: https://bitwarden.com/help/authenticator-keys/#support-for-more-parameters private fun handleQrCodeScanReceive(action: QrCodeScanAction.QrCodeScanReceive) { - vaultRepository.emitTotpCode(action.qrCode) + var result: TotpCodeResult = TotpCodeResult.Success(action.qrCode) + val scannedCode = action.qrCode + + if (scannedCode.isBlank() || !scannedCode.startsWith(TOTP_CODE_PREFIX)) { + vaultRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError) + sendEvent(QrCodeScanEvent.NavigateBack) + return + } + + val scannedCodeUri = Uri.parse(scannedCode) + val secretValue = scannedCodeUri.getQueryParameter(SECRET) + if (secretValue == null || !secretValue.isBase32()) { + vaultRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError) + sendEvent(QrCodeScanEvent.NavigateBack) + return + } + + val values = scannedCodeUri.queryParameterNames + if (!areParametersValid(scannedCode, values)) { + result = TotpCodeResult.CodeScanningError + } + + vaultRepository.emitTotpCodeResult(result) sendEvent(QrCodeScanEvent.NavigateBack) } @@ -47,6 +78,40 @@ class QrCodeScanViewModel @Inject constructor( QrCodeScanEvent.NavigateToManualCodeEntry, ) } + + @Suppress("NestedBlockDepth", "ReturnCount", "MagicNumber") + private fun areParametersValid(scannedCode: String, parameters: Set): Boolean { + parameters.forEach { parameter -> + Uri.parse(scannedCode).getQueryParameter(parameter)?.let { value -> + when (parameter) { + DIGITS -> { + val digit = value.toInt() + if (digit > 10 || digit < 1) { + return false + } + } + + PERIOD -> { + val period = value.toInt() + if (period < 1) { + return false + } + } + + ALGORITHM -> { + val lowercaseAlgo = value.lowercase() + if (lowercaseAlgo != "sha1" && + lowercaseAlgo != "sha256" && + lowercaseAlgo != "sha512" + ) { + return false + } + } + } + } + } + return true + } } /** @@ -95,3 +160,11 @@ sealed class QrCodeScanAction { */ data object CameraSetupErrorReceive : QrCodeScanAction() } + +/** + * Checks if a string is using base32 digits. + */ +private fun String.isBase32(): Boolean { + val regex = ("^[A-Z2-7]+=*$").toRegex() + return regex.matches(this) +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt index 7e76a4634..a29d80650 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt @@ -9,6 +9,7 @@ import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult +import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.Text @@ -50,7 +51,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { vaultAddEditType = VaultAddEditType.AddItem, ) - private val totpTestCodeFlow: MutableSharedFlow = bufferedMutableSharedFlow() + private val totpTestCodeFlow: MutableSharedFlow = bufferedMutableSharedFlow() private val mutableVaultItemFlow = MutableStateFlow>(DataState.Loading) @@ -593,13 +594,13 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { @Test fun `TotpCodeReceive should update totp code in state`() = runTest { val viewModel = createAddVaultItemViewModel() - val testKey = "TestKey" + val result = TotpCodeResult.Success("TestKey") val expectedState = loginInitialState.copy( viewState = VaultAddEditState.ViewState.Content( common = createCommonContentViewState(), type = createLoginTypeContentViewState( - totpCode = testKey, + totpCode = "TestKey", ), ), ) @@ -607,7 +608,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { viewModel.eventFlow.test { viewModel.actionChannel.trySend( VaultAddEditAction.Internal.TotpCodeReceive( - testKey, + result, ), ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/manualcodeentry/ManualCodeEntryViewModelTests.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/manualcodeentry/ManualCodeEntryViewModelTests.kt index 9a44e4c20..f738abbe1 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/manualcodeentry/ManualCodeEntryViewModelTests.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/manualcodeentry/ManualCodeEntryViewModelTests.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.vault.repository.VaultRepository +import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import io.mockk.every import io.mockk.just @@ -17,10 +18,10 @@ import org.junit.jupiter.api.Test class ManualCodeEntryViewModelTests : BaseViewModelTest() { - private val totpTestCodeFlow: Flow = bufferedMutableSharedFlow() + private val totpTestCodeFlow: Flow = bufferedMutableSharedFlow() private val vaultRepository: VaultRepository = mockk { every { totpCodeFlow } returns totpTestCodeFlow - every { emitTotpCode(any()) } just runs + every { emitTotpCodeResult(any()) } just runs } @Test @@ -41,7 +42,9 @@ class ManualCodeEntryViewModelTests : BaseViewModelTest() { viewModel.eventFlow.test { viewModel.actionChannel.trySend(ManualCodeEntryAction.CodeSubmit) - verify(exactly = 1) { vaultRepository.emitTotpCode("TestCode") } + verify(exactly = 1) { + vaultRepository.emitTotpCodeResult(TotpCodeResult.Success("TestCode")) + } assertEquals(ManualCodeEntryEvent.NavigateBack, awaitItem()) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanViewModelTest.kt index c6566b7c8..6bb8a1425 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanViewModelTest.kt @@ -1,25 +1,42 @@ package com.x8bit.bitwarden.ui.vault.feature.qrcodescan +import android.net.Uri import app.cash.turbine.test import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.vault.repository.VaultRepository +import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import io.mockk.every import io.mockk.just import io.mockk.mockk +import io.mockk.mockkStatic import io.mockk.runs +import io.mockk.unmockkStatic import io.mockk.verify import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test class QrCodeScanViewModelTest : BaseViewModelTest() { - private val totpTestCodeFlow: Flow = bufferedMutableSharedFlow() + private val totpTestCodeFlow: Flow = bufferedMutableSharedFlow() private val vaultRepository: VaultRepository = mockk { every { totpCodeFlow } returns totpTestCodeFlow - every { emitTotpCode(any()) } just runs + every { emitTotpCodeResult(any()) } just runs + } + private val uriMock = mockk() + + @BeforeEach + fun setup() { + mockkStatic(Uri::class) + } + + @AfterEach + fun tearDown() { + unmockkStatic(Uri::class) } @Test @@ -59,20 +76,199 @@ class QrCodeScanViewModelTest : BaseViewModelTest() { } @Test - fun `QrCodeScan should emit new code and NavigateBack`() = runTest { + fun `QrCodeScan should emit new code and NavigateBack with a valid code with all values`() = + runTest { + setupMockUri() + + val validCode = + "otpauth://totp/Test:me?secret=JBSWY3DPEHPK3PXP&algorithm=sha256&digits=8&period=60" + val viewModel = createViewModel() + val result = TotpCodeResult.Success(validCode) + + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(QrCodeScanAction.QrCodeScanReceive(validCode)) + + verify(exactly = 1) { vaultRepository.emitTotpCodeResult(result) } + assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) + } + } + + @Test + fun `QrCodeScan should emit new code and NavigateBack without optional values`() = runTest { + setupMockUri( + queryParameterNames = setOf(SECRET), + ) + + val validCode = + "otpauth://totp/Test:me?secret=JBSWY3DPEHPK3PXP" val viewModel = createViewModel() - val code = "NewCode" + val result = TotpCodeResult.Success(validCode) viewModel.eventFlow.test { - viewModel.actionChannel.trySend(QrCodeScanAction.QrCodeScanReceive(code)) + viewModel.actionChannel.trySend(QrCodeScanAction.QrCodeScanReceive(validCode)) - verify(exactly = 1) { vaultRepository.emitTotpCode(code) } + verify(exactly = 1) { vaultRepository.emitTotpCodeResult(result) } assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) } } - fun createViewModel(): QrCodeScanViewModel = + @Test + fun `QrCodeScan should emit failure result and NavigateBack with invalid algorithm`() = + runTest { + setupMockUri(algorithm = "SHA-224") + + val viewModel = createViewModel() + val result = TotpCodeResult.CodeScanningError + val invalidCode = + "otpauth://totp/Test:me?secret=JBSWY3DPEHPK3PXP&algorithm=sha224" + + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(QrCodeScanAction.QrCodeScanReceive(invalidCode)) + + verify(exactly = 1) { vaultRepository.emitTotpCodeResult(result) } + assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) + } + } + + @Test + fun `QrCodeScan should emit failure result and NavigateBack with invalid digits`() = runTest { + setupMockUri(digits = "11") + + val viewModel = createViewModel() + val result = TotpCodeResult.CodeScanningError + val invalidCode = + "otpauth://totp/Test:me?secret=JBSWY3DPEHPK3PXP&digits=11" + + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(QrCodeScanAction.QrCodeScanReceive(invalidCode)) + + verify(exactly = 1) { vaultRepository.emitTotpCodeResult(result) } + assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) + } + } + + @Test + fun `QrCodeScan should emit failure result and NavigateBack with invalid period`() = runTest { + setupMockUri(period = "0") + + val viewModel = createViewModel() + val result = TotpCodeResult.CodeScanningError + val invalidCode = + "otpauth://totp/Test:me?secret=JBSWY3DPEHPK3PXP&period=0" + + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(QrCodeScanAction.QrCodeScanReceive(invalidCode)) + + verify(exactly = 1) { vaultRepository.emitTotpCodeResult(result) } + assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) + } + } + + @Test + fun `QrCodeScan should emit failure result without correct prefix`() = runTest { + + val viewModel = createViewModel() + val result = TotpCodeResult.CodeScanningError + val invalidCode = + "nototpauth://totp/Test:me?secret=JBSWY3DPEHPK3PXP" + + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(QrCodeScanAction.QrCodeScanReceive(invalidCode)) + + verify(exactly = 1) { vaultRepository.emitTotpCodeResult(result) } + assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) + } + } + + @Test + fun `QrCodeScan should emit failure result with non base32 secret`() = runTest { + setupMockUri(secret = "JBSWY3DPEHPK3PXP1") + + val viewModel = createViewModel() + val result = TotpCodeResult.CodeScanningError + val invalidCode = + "otpauth://totp/Test:me?secret=JBSWY3DPEHPK3PXP1" + + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(QrCodeScanAction.QrCodeScanReceive(invalidCode)) + + verify(exactly = 1) { vaultRepository.emitTotpCodeResult(result) } + assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) + } + } + + @Test + fun `QrCodeScan should emit failure result and NavigateBack without Secret`() = runTest { + setupMockUri(secret = null) + + val viewModel = createViewModel() + val result = TotpCodeResult.CodeScanningError + val invalidCode = "otpauth://totp/Test:me" + + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(QrCodeScanAction.QrCodeScanReceive(invalidCode)) + + verify(exactly = 1) { vaultRepository.emitTotpCodeResult(result) } + assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) + } + } + + @Test + fun `QrCodeScan should emit failure result and NavigateBack if secret is empty`() = runTest { + setupMockUri(secret = "") + + val viewModel = createViewModel() + val result = TotpCodeResult.CodeScanningError + val invalidCode = "otpauth://totp/Test:me?secret= " + + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(QrCodeScanAction.QrCodeScanReceive(invalidCode)) + + verify(exactly = 1) { vaultRepository.emitTotpCodeResult(result) } + assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) + } + } + + @Test + fun `QrCodeScan should emit failure result and NavigateBack if code is empty`() = runTest { + val viewModel = createViewModel() + val result = TotpCodeResult.CodeScanningError + val invalidCode = "" + + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(QrCodeScanAction.QrCodeScanReceive(invalidCode)) + + verify(exactly = 1) { vaultRepository.emitTotpCodeResult(result) } + assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) + } + } + + private fun setupMockUri( + secret: String? = "JBSWY3DPEHPK3PXP", + algorithm: String = "SHA256", + digits: String = "8", + period: String = "60", + queryParameterNames: Set = setOf( + ALGORITHM, PERIOD, DIGITS, SECRET, + ), + ) { + every { Uri.parse(any()) } returns uriMock + every { uriMock.getQueryParameter(SECRET) } returns secret + every { uriMock.getQueryParameter(ALGORITHM) } returns algorithm + every { uriMock.getQueryParameter(DIGITS) } returns digits + every { uriMock.getQueryParameter(PERIOD) } returns period + every { uriMock.queryParameterNames } returns queryParameterNames + } + + private fun createViewModel(): QrCodeScanViewModel = QrCodeScanViewModel( vaultRepository = vaultRepository, ) + + companion object { + private const val ALGORITHM = "algorithm" + private const val DIGITS = "digits" + private const val PERIOD = "period" + private const val SECRET = "secret" + } }