From ca7762c3d051319e9c9f5acb77ac111df5420855 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Wed, 8 Jul 2026 12:55:10 -0400 Subject: [PATCH] Fix some more remote restore bugs in regV5. --- .../v2/AppRegistrationNetworkController.kt | 18 ++++ .../sample/debug/DebugNetworkController.kt | 8 ++ .../dependencies/DemoNetworkController.kt | 20 ++++ .../signal/registration/NetworkController.kt | 20 ++++ .../registration/RegistrationNavigation.kt | 1 + .../registration/RegistrationRepository.kt | 4 + ...orRemoteBackupPostRegistrationViewModel.kt | 75 ++++++++++--- .../screens/aepentry/EnterAepScreen.kt | 51 ++++++++- ...moteBackupPostRegistrationViewModelTest.kt | 102 +++++++++++++++--- 9 files changed, 270 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt index e816c4d053..ad28193f72 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt @@ -59,8 +59,11 @@ import org.signal.registration.NetworkController.SvrCredentials import org.signal.registration.NetworkController.ThirdPartyServiceErrorResponse import org.signal.registration.NetworkController.UpdateSessionError import org.signal.registration.NetworkController.VerificationCodeTransport +import org.signal.registration.NetworkController.VerifyBackupKeyError import org.signal.registration.proto.RegistrationProvisionMessage import org.thoughtcrime.securesms.BuildConfig +import org.thoughtcrime.securesms.backup.v2.BackupRepository +import org.thoughtcrime.securesms.backup.v2.RestoreTimestampResult import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.gcm.FcmUtil @@ -745,6 +748,21 @@ class AppRegistrationNetworkController( } } + override suspend fun verifyBackupKeyAssociatedWithAccount(aep: AccountEntropyPool): RequestResult = withContext(Dispatchers.IO) { + val aci = SignalStore.account.aci ?: return@withContext RequestResult.ApplicationError(IllegalStateException("ACI not available")) + + when (val result = BackupRepository.verifyBackupKeyAssociatedWithAccount(aci, aep)) { + is RestoreTimestampResult.Success -> RequestResult.Success(Unit) + RestoreTimestampResult.NotFound, + RestoreTimestampResult.BackupsNotEnabled -> RequestResult.NonSuccess(VerifyBackupKeyError.NoBackup) + RestoreTimestampResult.VerificationFailure -> RequestResult.NonSuccess(VerifyBackupKeyError.IncorrectKey) + is RestoreTimestampResult.RateLimited -> RequestResult.NonSuccess(VerifyBackupKeyError.RateLimited(result.retryAfter)) + // Failure is the catch-all for "couldn't check the backup"; the specific outcomes are already broken out above, so + // this is overwhelmingly a connectivity/transport issue (e.g. no network). + RestoreTimestampResult.Failure -> RequestResult.RetryableNetworkError(IOException("Failed to verify backup key associated with account")) + } + } + override fun startNewDeviceTransferServer(context: Context, aep: AccountEntropyPool) { val pendingIntent = android.app.PendingIntent.getActivity( context, diff --git a/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt b/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt index 63912b5a1e..3a528a3931 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt @@ -312,4 +312,12 @@ class DebugNetworkController( } return delegate.getBackupFileLastModified(aep, backupInfo) } + + override suspend fun verifyBackupKeyAssociatedWithAccount(aep: AccountEntropyPool): RequestResult { + NetworkDebugState.getOverride>("verifyBackupKeyAssociatedWithAccount")?.let { + Log.d(TAG, "[verifyBackupKeyAssociatedWithAccount] Returning debug override") + return it + } + return delegate.verifyBackupKeyAssociatedWithAccount(aep) + } } diff --git a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt index f91c670140..079514c474 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt @@ -37,6 +37,7 @@ import org.signal.libsignal.protocol.IdentityKey import org.signal.libsignal.protocol.IdentityKeyPair import org.signal.libsignal.protocol.ecc.ECPrivateKey import org.signal.libsignal.zkgroup.GenericServerPublicParams +import org.signal.libsignal.zkgroup.VerificationFailedException import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequestContext import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialResponse import org.signal.network.NetworkResult @@ -1426,6 +1427,25 @@ class DemoNetworkController( ) } + override suspend fun verifyBackupKeyAssociatedWithAccount(aep: AccountEntropyPool): RequestResult = withContext(Dispatchers.IO) { + when (val result = getRemoteBackupInfo(aep)) { + is RequestResult.Success -> RequestResult.Success(Unit) + is RequestResult.NonSuccess -> when (val error = result.error) { + is NetworkController.GetBackupInfoError.NoBackup -> RequestResult.NonSuccess(NetworkController.VerifyBackupKeyError.NoBackup) + is NetworkController.GetBackupInfoError.RateLimited -> RequestResult.NonSuccess(NetworkController.VerifyBackupKeyError.RateLimited(error.retryAfter)) + else -> RequestResult.NonSuccess(NetworkController.VerifyBackupKeyError.IncorrectKey) + } + is RequestResult.RetryableNetworkError -> RequestResult.RetryableNetworkError(result.networkError) + is RequestResult.ApplicationError -> { + if (result.cause is VerificationFailedException) { + RequestResult.NonSuccess(NetworkController.VerifyBackupKeyError.IncorrectKey) + } else { + RequestResult.ApplicationError(result.cause) + } + } + } + } + override suspend fun getBackupFileLastModified( aep: AccountEntropyPool, backupInfo: NetworkController.GetBackupInfoResponse diff --git a/feature/registration/src/main/java/org/signal/registration/NetworkController.kt b/feature/registration/src/main/java/org/signal/registration/NetworkController.kt index 3731195230..e5aac119d1 100644 --- a/feature/registration/src/main/java/org/signal/registration/NetworkController.kt +++ b/feature/registration/src/main/java/org/signal/registration/NetworkController.kt @@ -217,6 +217,16 @@ interface NetworkController { */ suspend fun getBackupFileLastModified(aep: AccountEntropyPool, backupInfo: GetBackupInfoResponse): RequestResult + /** + * Verifies that [aep] is the correct backup key for the current account by checking it against the remote backup. + * Used to detect an incorrect backup passphrase before attempting a full restore, so the user can be given the + * chance to re-enter it. + * + * A [VerifyBackupKeyError.IncorrectKey] result means the key failed zk verification (i.e. it does not match the + * account's backup). + */ + suspend fun verifyBackupKeyAssociatedWithAccount(aep: AccountEntropyPool): RequestResult + /** * Starts a provisioning session for QR-based quick restore. * @@ -447,6 +457,16 @@ interface NetworkController { data class RateLimited(val retryAfter: Duration) : GetBackupInfoError() } + sealed class VerifyBackupKeyError : BadRequestError { + /** The entered key failed zk verification -- it is not the correct backup key for this account. */ + data object IncorrectKey : VerifyBackupKeyError() + + /** The key verified, but no backup exists for this account. */ + data object NoBackup : VerifyBackupKeyError() + + data class RateLimited(val retryAfter: Duration?) : VerifyBackupKeyError() + } + data class MasterKeyResponse( val masterKey: MasterKey ) diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt index 520df3c2a0..06e4c7095c 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -818,6 +818,7 @@ private fun EntryProviderScope.navigationEntries( val context = LocalContext.current val viewModel: EnterAepForRemoteBackupPostRegistrationViewModel = viewModel( factory = EnterAepForRemoteBackupPostRegistrationViewModel.Factory( + repository = registrationRepository, parentEventEmitter = registrationViewModel::onEvent, isPasswordManagerAvailable = RegistrationCredentialManager.isSupported(context) ) diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt index 6073b75998..c80581f845 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt @@ -850,6 +850,10 @@ class RegistrationRepository(val context: Context, val networkController: Networ networkController.getBackupFileLastModified(aep, backupInfo) } + suspend fun verifyBackupKeyAssociatedWithAccount(aep: AccountEntropyPool): RequestResult = withContext(Dispatchers.IO) { + networkController.verifyBackupKeyAssociatedWithAccount(aep) + } + fun restoreRemoteBackup(aep: AccountEntropyPool): Flow { return storageController.restoreRemoteBackup(aep) } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPostRegistrationViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPostRegistrationViewModel.kt index 13606d71c3..7f0ef6fbd4 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPostRegistrationViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPostRegistrationViewModel.kt @@ -5,22 +5,27 @@ package org.signal.registration.screens.aepentry +import androidx.annotation.VisibleForTesting import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import org.signal.core.models.AccountEntropyPool import org.signal.core.util.logging.Log +import org.signal.libsignal.net.RequestResult +import org.signal.registration.NetworkController import org.signal.registration.RegistrationFlowEvent +import org.signal.registration.RegistrationRepository import org.signal.registration.RegistrationRoute +import org.signal.registration.screens.EventDrivenViewModel import org.signal.registration.screens.util.navigateTo class EnterAepForRemoteBackupPostRegistrationViewModel( + private val repository: RegistrationRepository, private val parentEventEmitter: (RegistrationFlowEvent) -> Unit, isPasswordManagerAvailable: Boolean = false -) : ViewModel() { +) : EventDrivenViewModel(TAG) { companion object { private val TAG = Log.tag(EnterAepForRemoteBackupPostRegistrationViewModel::class) @@ -29,34 +34,80 @@ class EnterAepForRemoteBackupPostRegistrationViewModel( private val _state = MutableStateFlow(EnterAepState(isPasswordManagerAvailable = isPasswordManagerAvailable)) val state: StateFlow = _state.asStateFlow() - fun onEvent(event: EnterAepEvents) { - Log.d(TAG, "[Event] $event") + override suspend fun processEvent(event: EnterAepEvents) { + applyEvent(_state.value, event) { _state.value = it } + } + + @VisibleForTesting + suspend fun applyEvent(inputState: EnterAepState, event: EnterAepEvents, stateEmitter: (EnterAepState) -> Unit) { when (event) { is EnterAepEvents.BackupKeyChanged -> { - _state.update { EnterAepScreenEventHandler.applyEvent(it, event) } + stateEmitter(EnterAepScreenEventHandler.applyEvent(inputState, event)) } is EnterAepEvents.Submit -> { - if (_state.value.isBackupKeyValid) { - val aep = AccountEntropyPool(_state.value.backupKey) - parentEventEmitter(RegistrationFlowEvent.UserSuppliedAepSubmitted(aep)) - parentEventEmitter.navigateTo(RegistrationRoute.RemoteRestore(aep)) - } + applySubmit(inputState, stateEmitter) } is EnterAepEvents.Cancel -> { parentEventEmitter(RegistrationFlowEvent.NavigateBack) } is EnterAepEvents.DismissError -> { - _state.update { EnterAepScreenEventHandler.applyEvent(it, event) } + stateEmitter(EnterAepScreenEventHandler.applyEvent(inputState, event)) + } + } + } + + /** + * The account is already registered, so we verify the entered key by checking it against the remote backup. This lets + * us surface an [AepValidationError.Incorrect] error inline before navigating to the restore screen, rather than + * failing partway through a restore with no recourse. + */ + private suspend fun applySubmit(inputState: EnterAepState, stateEmitter: (EnterAepState) -> Unit) { + check(inputState.isBackupKeyValid) { "AEP is not valid, should not have gotten here." } + + val aep = AccountEntropyPool(inputState.backupKey) + + stateEmitter(inputState.copy(isRegistering = true)) + + Log.i(TAG, "[Submit] Verifying user-supplied AEP against remote backup.") + + when (val result = repository.verifyBackupKeyAssociatedWithAccount(aep)) { + is RequestResult.Success -> { + Log.i(TAG, "[Submit] Backup key verified.") + stateEmitter(inputState.copy(isRegistering = false)) + parentEventEmitter(RegistrationFlowEvent.UserSuppliedAepSubmitted(aep)) + parentEventEmitter.navigateTo(RegistrationRoute.RemoteRestore(aep)) + } + is RequestResult.NonSuccess -> { + when (val error = result.error) { + is NetworkController.VerifyBackupKeyError.IncorrectKey, + is NetworkController.VerifyBackupKeyError.NoBackup -> { + Log.w(TAG, "[Submit] Entered backup key is incorrect (error: $error).") + stateEmitter(inputState.copy(isRegistering = false, aepValidationError = AepValidationError.Incorrect)) + } + is NetworkController.VerifyBackupKeyError.RateLimited -> { + Log.w(TAG, "[Submit] Rate limited (retryAfter: ${error.retryAfter}).") + stateEmitter(inputState.copy(isRegistering = false, registrationError = RegistrationError.RateLimited)) + } + } + } + is RequestResult.RetryableNetworkError -> { + Log.w(TAG, "[Submit] Network error.", result.networkError) + stateEmitter(inputState.copy(isRegistering = false, registrationError = RegistrationError.NetworkError)) + } + is RequestResult.ApplicationError -> { + Log.w(TAG, "[Submit] Application error.", result.cause) + stateEmitter(inputState.copy(isRegistering = false, registrationError = RegistrationError.UnknownError)) } } } class Factory( + private val repository: RegistrationRepository, private val parentEventEmitter: (RegistrationFlowEvent) -> Unit, private val isPasswordManagerAvailable: Boolean = false ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return EnterAepForRemoteBackupPostRegistrationViewModel(parentEventEmitter, isPasswordManagerAvailable) as T + return EnterAepForRemoteBackupPostRegistrationViewModel(repository, parentEventEmitter, isPasswordManagerAvailable) as T } } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreen.kt index c39c025bb4..e0480da802 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -52,6 +53,7 @@ import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch import org.signal.core.ui.compose.AllDevicePreviews import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.Previews import org.signal.registration.R import org.signal.registration.fonts.MonoTypeface @@ -70,12 +72,34 @@ fun EnterAepScreen( onEvent: (EnterAepEvents) -> Unit, modifier: Modifier = Modifier ) { + RegistrationErrorDialog(state.registrationError, onEvent) + when (val layoutParams = RegistrationScaffold.rememberLayoutParams()) { is RegistrationScaffold.Params.OnePane -> OnePaneLayout(layoutParams, state, onEvent, modifier) is RegistrationScaffold.Params.TwoPane -> TwoPaneLayout(layoutParams, state, onEvent, modifier) } } +/** + * Shows a dismissable dialog for generic registration errors (network/rate-limit/unknown). Incorrect-key errors are + * surfaced inline on the text field instead, so they are intentionally not shown here. + */ +@Composable +private fun RegistrationErrorDialog(error: RegistrationError?, onEvent: (EnterAepEvents) -> Unit) { + val message = when (error) { + RegistrationError.NetworkError -> stringResource(R.string.VerificationCodeScreen__network_error) + RegistrationError.RateLimited -> stringResource(R.string.VerificationCodeScreen__too_many_attempts) + RegistrationError.UnknownError -> stringResource(R.string.VerificationCodeScreen__an_unexpected_error_occurred) + RegistrationError.IncorrectRecoveryPassword, null -> null + } ?: return + + Dialogs.SimpleMessageDialog( + message = message, + dismiss = stringResource(android.R.string.ok), + onDismiss = { onEvent(EnterAepEvents.DismissError) } + ) +} + @Composable private fun OnePaneLayout( params: RegistrationScaffold.Params.OnePane, @@ -314,7 +338,15 @@ private fun NextButton(state: EnterAepState, onEvent: (EnterAepEvents) -> Unit, enabled = state.isBackupKeyValid && state.aepValidationError == null && !state.isRegistering, onClick = { onEvent(EnterAepEvents.Submit) } ) { - Text(text = stringResource(R.string.LocalBackupRestoreScreen__next)) + if (state.isRegistering) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 3.dp, + color = MaterialTheme.colorScheme.primary + ) + } else { + Text(text = stringResource(R.string.LocalBackupRestoreScreen__next)) + } } } @@ -383,6 +415,23 @@ private fun EnterAepScreenFilledPreview() { } } +@AllDevicePreviews +@Composable +private fun EnterAepScreenLoadingPreview() { + Previews.Preview { + EnterAepScreen( + state = EnterAepState( + enteredText = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t", + backupKey = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t", + isBackupKeyValid = true, + isRegistering = true, + isPasswordManagerAvailable = true + ), + onEvent = {} + ) + } +} + @AllDevicePreviews @Composable private fun EnterAepScreenErrorPreview() { diff --git a/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPostRegistrationViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPostRegistrationViewModelTest.kt index 403a3fecb4..6224b4fa98 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPostRegistrationViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPostRegistrationViewModelTest.kt @@ -12,23 +12,38 @@ import assertk.assertions.isEqualTo import assertk.assertions.isInstanceOf import assertk.assertions.isNull import assertk.assertions.prop +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test import org.signal.core.models.AccountEntropyPool +import org.signal.libsignal.net.RequestResult +import org.signal.registration.NetworkController import org.signal.registration.RegistrationFlowEvent +import org.signal.registration.RegistrationRepository import org.signal.registration.RegistrationRoute +import java.io.IOException +import kotlin.time.Duration.Companion.seconds class EnterAepForRemoteBackupPostRegistrationViewModelTest { private lateinit var viewModel: EnterAepForRemoteBackupPostRegistrationViewModel + private lateinit var mockRepository: RegistrationRepository private lateinit var emittedParentEvents: MutableList private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit + private lateinit var emittedStates: MutableList + private lateinit var stateEmitter: (EnterAepState) -> Unit @Before fun setup() { + mockRepository = mockk(relaxed = true) emittedParentEvents = mutableListOf() parentEventEmitter = { event -> emittedParentEvents.add(event) } + emittedStates = mutableListOf() + stateEmitter = { state -> emittedStates.add(state) } viewModel = EnterAepForRemoteBackupPostRegistrationViewModel( + repository = mockRepository, parentEventEmitter = parentEventEmitter ) } @@ -36,20 +51,21 @@ class EnterAepForRemoteBackupPostRegistrationViewModelTest { // ==================== BackupKeyChanged Tests ==================== @Test - fun `BackupKeyChanged updates backup key in state`() { - val testKey = VALID_AEP + fun `BackupKeyChanged updates state with new key`() = runTest { + viewModel.applyEvent(EnterAepState(), EnterAepEvents.BackupKeyChanged(VALID_AEP), stateEmitter) - viewModel.onEvent(EnterAepEvents.BackupKeyChanged(testKey)) - - assertThat(viewModel.state.value.backupKey).isEqualTo(testKey) + assertThat(emittedStates).hasSize(1) + assertThat(emittedStates.last().backupKey).isEqualTo(VALID_AEP) } // ==================== Submit Tests ==================== @Test - fun `Submit with valid key emits UserSuppliedAepSubmitted then NavigateToScreen with RemoteRestore`() { - viewModel.onEvent(EnterAepEvents.BackupKeyChanged(VALID_AEP)) - viewModel.onEvent(EnterAepEvents.Submit) + fun `Submit with verified key emits UserSuppliedAepSubmitted then NavigateToScreen with RemoteRestore`() = runTest { + val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true) + coEvery { mockRepository.verifyBackupKeyAssociatedWithAccount(any()) } returns RequestResult.Success(Unit) + + viewModel.applyEvent(initialState, EnterAepEvents.Submit, stateEmitter) assertThat(emittedParentEvents).hasSize(2) assertThat(emittedParentEvents[0]) @@ -64,18 +80,70 @@ class EnterAepForRemoteBackupPostRegistrationViewModelTest { } @Test - fun `Submit with invalid key emits nothing`() { - viewModel.onEvent(EnterAepEvents.BackupKeyChanged("too-short")) - viewModel.onEvent(EnterAepEvents.Submit) + fun `Submit sets isRegistering true before verification then false`() = runTest { + val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true) + coEvery { mockRepository.verifyBackupKeyAssociatedWithAccount(any()) } returns RequestResult.Success(Unit) + + viewModel.applyEvent(initialState, EnterAepEvents.Submit, stateEmitter) + + assertThat(emittedStates).hasSize(2) + assertThat(emittedStates[0].isRegistering).isEqualTo(true) + assertThat(emittedStates[1].isRegistering).isEqualTo(false) + } + + @Test + fun `Submit with incorrect key sets aepValidationError and does not navigate`() = runTest { + val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true) + coEvery { mockRepository.verifyBackupKeyAssociatedWithAccount(any()) } returns + RequestResult.NonSuccess(NetworkController.VerifyBackupKeyError.IncorrectKey) + + viewModel.applyEvent(initialState, EnterAepEvents.Submit, stateEmitter) assertThat(emittedParentEvents).isEmpty() + assertThat(emittedStates.last().aepValidationError).isEqualTo(AepValidationError.Incorrect) + } + + @Test + fun `Submit with no backup is treated as an incorrect key and does not navigate`() = runTest { + val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true) + coEvery { mockRepository.verifyBackupKeyAssociatedWithAccount(any()) } returns + RequestResult.NonSuccess(NetworkController.VerifyBackupKeyError.NoBackup) + + viewModel.applyEvent(initialState, EnterAepEvents.Submit, stateEmitter) + + assertThat(emittedParentEvents).isEmpty() + assertThat(emittedStates.last().aepValidationError).isEqualTo(AepValidationError.Incorrect) + } + + @Test + fun `Submit with rate limited sets registrationError and does not navigate`() = runTest { + val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true) + coEvery { mockRepository.verifyBackupKeyAssociatedWithAccount(any()) } returns + RequestResult.NonSuccess(NetworkController.VerifyBackupKeyError.RateLimited(30.seconds)) + + viewModel.applyEvent(initialState, EnterAepEvents.Submit, stateEmitter) + + assertThat(emittedParentEvents).isEmpty() + assertThat(emittedStates.last().registrationError).isEqualTo(RegistrationError.RateLimited) + } + + @Test + fun `Submit with network error sets registrationError and does not navigate`() = runTest { + val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true) + coEvery { mockRepository.verifyBackupKeyAssociatedWithAccount(any()) } returns + RequestResult.RetryableNetworkError(IOException("network")) + + viewModel.applyEvent(initialState, EnterAepEvents.Submit, stateEmitter) + + assertThat(emittedParentEvents).isEmpty() + assertThat(emittedStates.last().registrationError).isEqualTo(RegistrationError.NetworkError) } // ==================== Cancel Tests ==================== @Test - fun `Cancel emits NavigateBack`() { - viewModel.onEvent(EnterAepEvents.Cancel) + fun `Cancel emits NavigateBack`() = runTest { + viewModel.applyEvent(EnterAepState(), EnterAepEvents.Cancel, stateEmitter) assertThat(emittedParentEvents).hasSize(1) assertThat(emittedParentEvents.first()).isEqualTo(RegistrationFlowEvent.NavigateBack) @@ -84,10 +152,12 @@ class EnterAepForRemoteBackupPostRegistrationViewModelTest { // ==================== DismissError Tests ==================== @Test - fun `DismissError clears registrationError from state`() { - viewModel.onEvent(EnterAepEvents.DismissError) + fun `DismissError clears registrationError from state`() = runTest { + val initialState = EnterAepState(registrationError = RegistrationError.UnknownError) - assertThat(viewModel.state.value.registrationError).isNull() + viewModel.applyEvent(initialState, EnterAepEvents.DismissError, stateEmitter) + + assertThat(emittedStates.last().registrationError).isNull() } // ==================== Constants ====================