From ddeece0a5caaeb68441a2f1f5be62d2046c2dc81 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Thu, 26 Sep 2024 10:22:10 -0400 Subject: [PATCH] [BWA-79] Sanitize manual entry code before validation (#200) --- .../ManualCodeEntryViewModel.kt | 47 ++- .../ManualCodeEntryViewModelTest.kt | 329 ++++++++++++++++++ 2 files changed, 351 insertions(+), 25 deletions(-) create mode 100644 app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModelTest.kt diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModel.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModel.kt index 48d220e949..764dfcb9ab 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModel.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import com.bitwarden.authenticator.R import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType +import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManager import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository import com.bitwarden.authenticator.ui.platform.base.BaseViewModel import com.bitwarden.authenticator.ui.platform.base.util.Text @@ -67,36 +68,22 @@ class ManualCodeEntryViewModel @Inject constructor( } private fun handleCodeSubmit() { - if (state.code.isBlank()) { - mutableStateFlow.update { - it.copy( - dialog = ManualCodeEntryState.DialogState.Error( - message = R.string.key_is_required.asText(), - ), - ) - } + val isSteamCode = state.code.startsWith(TotpCodeManager.STEAM_CODE_PREFIX) + val sanitizedCode = state.code + .replace(" ", "") + .replace(TotpCodeManager.STEAM_CODE_PREFIX, "") + if (sanitizedCode.isBlank()) { + showErrorDialog(R.string.key_is_required.asText()) return } - if (!state.code.isBase32()) { - mutableStateFlow.update { - it.copy( - dialog = ManualCodeEntryState.DialogState.Error( - message = R.string.key_is_invalid.asText(), - ), - ) - } + if (!sanitizedCode.isBase32()) { + showErrorDialog(R.string.key_is_invalid.asText()) return } if (state.issuer.isBlank()) { - mutableStateFlow.update { - it.copy( - dialog = ManualCodeEntryState.DialogState.Error( - message = R.string.name_is_required.asText(), - ), - ) - } + showErrorDialog(R.string.name_is_required.asText()) return } @@ -104,11 +91,11 @@ class ManualCodeEntryViewModel @Inject constructor( authenticatorRepository.createItem( AuthenticatorItemEntity( id = UUID.randomUUID().toString(), - key = state.code, + key = sanitizedCode, issuer = state.issuer, accountName = "", userId = null, - type = if (state.code.startsWith("steam://")) { + type = if (isSteamCode) { AuthenticatorItemType.STEAM } else { AuthenticatorItemType.TOTP @@ -134,6 +121,16 @@ class ManualCodeEntryViewModel @Inject constructor( private fun handleSettingsClick() { sendEvent(ManualCodeEntryEvent.NavigateToAppSettings) } + + private fun showErrorDialog(message: Text) { + mutableStateFlow.update { + it.copy( + dialog = ManualCodeEntryState.DialogState.Error( + message = message, + ), + ) + } + } } /** diff --git a/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModelTest.kt b/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModelTest.kt new file mode 100644 index 0000000000..416f5e86c3 --- /dev/null +++ b/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModelTest.kt @@ -0,0 +1,329 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.manualcodeentry + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType +import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository +import com.bitwarden.authenticator.data.authenticator.repository.model.CreateItemResult +import com.bitwarden.authenticator.ui.platform.base.BaseViewModelTest +import com.bitwarden.authenticator.ui.platform.base.util.asText +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +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 +import java.util.UUID + +class ManualCodeEntryViewModelTest : BaseViewModelTest() { + + private val mockAuthenticatorRepository = mockk() + + @BeforeEach + fun setUp() { + mockkStatic(UUID::class) + every { UUID.randomUUID().toString() } returns "mockUUID" + } + + @AfterEach + fun tearDown() { + unmockkStatic(UUID::class) + } + + @Test + fun `initial state should be correct when saved state is not null`() { + val initialState = ManualCodeEntryState( + code = "ABCD", + issuer = "mockIssuer", + dialog = null, + ) + val viewModel = createViewModel(initialState = initialState) + assertEquals(initialState, viewModel.stateFlow.value) + } + + @Test + fun `initial state should be correct when saved state is null`() { + val viewModel = createViewModel(initialState = null) + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + } + + @Test + fun `CloseClick should navigate back`() = runTest { + val viewModel = createViewModel() + viewModel.trySendAction(ManualCodeEntryAction.CloseClick) + viewModel.eventFlow.test { + assertEquals( + ManualCodeEntryEvent.NavigateBack, + awaitItem(), + ) + } + } + + @Test + fun `CodeTextChange should update state`() { + val viewModel = createViewModel() + viewModel.trySendAction(ManualCodeEntryAction.CodeTextChange("newCode")) + assertEquals( + DEFAULT_STATE.copy(code = "newCode"), + viewModel.stateFlow.value, + ) + } + + @Test + fun `IssuerTextChange should update state`() { + val viewModel = createViewModel() + viewModel.trySendAction(ManualCodeEntryAction.IssuerTextChange("newIssuer")) + assertEquals( + DEFAULT_STATE.copy(issuer = "newIssuer"), + viewModel.stateFlow.value, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `CodeSubmit should createItem, show toast, and navigate back on success when code is valid`() = + runTest { + coEvery { + mockAuthenticatorRepository.createItem( + item = AuthenticatorItemEntity( + id = "mockUUID", + key = "ABCD", + issuer = "mockIssuer", + accountName = "", + userId = null, + favorite = false, + type = AuthenticatorItemType.TOTP, + ), + ) + } returns CreateItemResult.Success + + val viewModel = createViewModel( + initialState = DEFAULT_STATE + .copy(code = "ABCD", issuer = "mockIssuer"), + ) + + viewModel.trySendAction(ManualCodeEntryAction.CodeSubmit) + + coVerify { + mockAuthenticatorRepository.createItem( + item = AuthenticatorItemEntity( + id = "mockUUID", + key = "ABCD", + issuer = "mockIssuer", + accountName = "", + userId = null, + favorite = false, + type = AuthenticatorItemType.TOTP, + ), + ) + } + viewModel.eventFlow.test { + assertEquals( + ManualCodeEntryEvent.ShowToast(R.string.verification_code_added.asText()), + awaitItem(), + ) + assertEquals( + ManualCodeEntryEvent.NavigateBack, + awaitItem(), + ) + } + } + + @Test + fun `CodeSubmit should replace whitespace from code`() = runTest { + coEvery { + mockAuthenticatorRepository.createItem( + item = AuthenticatorItemEntity( + id = "mockUUID", + key = "ABCD", + issuer = "mockIssuer", + accountName = "", + userId = null, + favorite = false, + type = AuthenticatorItemType.TOTP, + ), + ) + } returns CreateItemResult.Success + + val viewModel = createViewModel( + initialState = DEFAULT_STATE.copy( + code = "A B C D", + issuer = "mockIssuer", + ), + ) + + viewModel.trySendAction(ManualCodeEntryAction.CodeSubmit) + + coVerify { + mockAuthenticatorRepository.createItem( + item = AuthenticatorItemEntity( + id = "mockUUID", + key = "ABCD", + issuer = "mockIssuer", + accountName = "", + userId = null, + favorite = false, + type = AuthenticatorItemType.TOTP, + ), + ) + } + } + + @Test + fun `CodeSubmit should show error dialog when code is empty`() = runTest { + val viewModel = createViewModel( + initialState = DEFAULT_STATE.copy( + code = " ", + ), + ) + + viewModel.trySendAction(ManualCodeEntryAction.CodeSubmit) + + assertEquals( + ManualCodeEntryState.DialogState.Error( + message = R.string.key_is_required.asText(), + ), + viewModel.stateFlow.value.dialog, + ) + } + + @Test + fun `CodeSubmit should show error dialog when code is not base32`() { + val viewModel = createViewModel( + initialState = DEFAULT_STATE.copy( + code = "ABCD12345", + ), + ) + + viewModel.trySendAction(ManualCodeEntryAction.CodeSubmit) + + assertEquals( + ManualCodeEntryState.DialogState.Error( + message = R.string.key_is_invalid.asText(), + ), + viewModel.stateFlow.value.dialog, + ) + } + + @Test + fun `CodeSubmit should show error dialog when issuer is empty`() { + val viewModel = createViewModel( + initialState = DEFAULT_STATE.copy( + code = "ABCD", + issuer = "", + ), + ) + + viewModel.trySendAction(ManualCodeEntryAction.CodeSubmit) + + assertEquals( + ManualCodeEntryState.DialogState.Error( + message = R.string.name_is_required.asText(), + ), + viewModel.stateFlow.value.dialog, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `CodeSubmit should set AuthenticatorItemType to STEAM when code starts with steam protocol`() { + coEvery { + mockAuthenticatorRepository.createItem( + item = AuthenticatorItemEntity( + id = "mockUUID", + key = "ABCD", + issuer = "mockIssuer", + accountName = "", + userId = null, + favorite = false, + type = AuthenticatorItemType.STEAM, + ), + ) + } returns CreateItemResult.Success + + val viewModel = createViewModel( + initialState = DEFAULT_STATE.copy( + code = "steam://ABCD", + issuer = "mockIssuer", + ), + ) + + viewModel.trySendAction(ManualCodeEntryAction.CodeSubmit) + + coVerify { + mockAuthenticatorRepository.createItem( + item = AuthenticatorItemEntity( + id = "mockUUID", + key = "ABCD", + issuer = "mockIssuer", + accountName = "", + userId = null, + favorite = false, + type = AuthenticatorItemType.STEAM, + ), + ) + } + } + + @Test + fun `ScanQrCodeTextClick should navigate to QR code screen`() = runTest { + val viewModel = createViewModel() + viewModel.trySendAction(ManualCodeEntryAction.ScanQrCodeTextClick) + viewModel.eventFlow.test { + assertEquals( + ManualCodeEntryEvent.NavigateToQrCodeScreen, + awaitItem(), + ) + } + } + + @Test + fun `SettingsClick should navigate to app settings`() = runTest { + val viewModel = createViewModel() + viewModel.trySendAction(ManualCodeEntryAction.SettingsClick) + viewModel.eventFlow.test { + assertEquals( + ManualCodeEntryEvent.NavigateToAppSettings, + awaitItem(), + ) + } + } + + @Test + fun `DismissDialog should clear dialog state`() { + val viewModel = createViewModel( + initialState = DEFAULT_STATE.copy( + dialog = ManualCodeEntryState.DialogState.Error( + message = R.string.key_is_required.asText(), + ), + ), + ) + viewModel.trySendAction(ManualCodeEntryAction.DismissDialog) + assertEquals( + DEFAULT_STATE, + viewModel.stateFlow.value, + ) + } + + private fun createViewModel( + initialState: ManualCodeEntryState? = DEFAULT_STATE, + ): ManualCodeEntryViewModel = + ManualCodeEntryViewModel( + savedStateHandle = SavedStateHandle().apply { set("state", initialState) }, + authenticatorRepository = mockAuthenticatorRepository, + ) +} + +private val DEFAULT_STATE: ManualCodeEntryState = + ManualCodeEntryState( + code = "", + issuer = "", + dialog = null, + )