From f50e970b5d51298d2ab6ac6570e2e4bf5e810c66 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 9 Jul 2026 15:29:33 -0400 Subject: [PATCH] Add more end-to-end regV5 tests. --- .../org/signal/core/ui/compose/Dialogs.kt | 10 +- .../screens/pincreation/PinCreationScreen.kt | 7 +- .../org/signal/registration/test/TestTags.kt | 2 + .../registration/RegistrationEndToEndTest.kt | 410 ++++++++++++++++-- .../fakes/FakeNetworkController.kt | 233 +++++++--- .../fakes/FakeStorageController.kt | 61 ++- 6 files changed, 614 insertions(+), 109 deletions(-) diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt b/core/ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt index b8fb15a667..c2f27c9439 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt @@ -76,6 +76,7 @@ object Dialogs { const val TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON = "dialog-confirm-button" const val TEST_TAG_ALERT_DIALOG_DISMISS_BUTTON = "dialog-dismiss-button" + const val TEST_TAG_MESSAGE_DIALOG_DISMISS_BUTTON = "dialog-message-dismiss-button" object Defaults { val shape: Shape @Composable get() = RoundedCornerShape(28.dp) @@ -141,9 +142,12 @@ object Dialogs { }, text = { Text(text = message) }, confirmButton = { - TextButton(onClick = { - onDismiss() - }) { + TextButton( + onClick = { + onDismiss() + }, + modifier = Modifier.testTag(TEST_TAG_MESSAGE_DIALOG_DISMISS_BUTTON) + ) { Text(text = dismiss, color = dismissColor) } }, diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreen.kt index 81d78cf899..add1a16262 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreen.kt @@ -522,7 +522,9 @@ private fun PinCreationTopBar( IconButton( onClick = { menuController.show() }, - modifier = Modifier.padding(horizontal = 8.dp) + modifier = Modifier + .padding(horizontal = 8.dp) + .testTag(TestTags.PIN_CREATION_MENU_BUTTON) ) { Icon( imageVector = ImageVector.vectorResource(CoreR.drawable.symbol_more_vertical_24), @@ -547,7 +549,8 @@ private fun PinCreationTopBar( onClick = { menuController.hide() showOptOutDialog = true - } + }, + modifier = Modifier.testTag(TestTags.PIN_CREATION_DISABLE_PIN_MENU_ITEM) ) } } diff --git a/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt b/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt index 5347affe52..82a586f611 100644 --- a/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt +++ b/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt @@ -143,6 +143,8 @@ object TestTags { const val PIN_CREATION_CONFIRM_INPUT = "pin_creation_confirm_input" const val PIN_CREATION_NEXT_BUTTON = "pin_creation_next_button" const val PIN_CREATION_TOGGLE_KEYBOARD_BUTTON = "pin_creation_toggle_keyboard_button" + const val PIN_CREATION_MENU_BUTTON = "pin_creation_menu_button" + const val PIN_CREATION_DISABLE_PIN_MENU_ITEM = "pin_creation_disable_pin_menu_item" // Pin Entry Screen const val PIN_ENTRY_SCREEN = "pin_entry_screen" diff --git a/feature/registration/src/test/java/org/signal/registration/RegistrationEndToEndTest.kt b/feature/registration/src/test/java/org/signal/registration/RegistrationEndToEndTest.kt index bb85be0f50..aef766de20 100644 --- a/feature/registration/src/test/java/org/signal/registration/RegistrationEndToEndTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/RegistrationEndToEndTest.kt @@ -6,15 +6,25 @@ package org.signal.registration import android.app.Application +import android.net.Uri import android.os.Looper +import androidx.activity.compose.LocalActivityResultRegistryOwner +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.ActivityResultRegistryOwner +import androidx.activity.result.contract.ActivityResultContract +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput +import androidx.core.app.ActivityOptionsCompat import androidx.lifecycle.SavedStateHandle import androidx.test.core.app.ApplicationProvider import com.google.accompanist.permissions.ExperimentalPermissionsApi +import kotlinx.coroutines.flow.flowOf import org.junit.Before import org.junit.Rule import org.junit.Test @@ -22,10 +32,19 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows import org.robolectric.annotation.Config +import org.signal.archive.LocalBackupRestoreProgress +import org.signal.core.models.AccountEntropyPool +import org.signal.core.models.MasterKey import org.signal.core.ui.CoreUiDependenciesRule import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.theme.SignalTheme import org.signal.core.util.logging.Log +import org.signal.libsignal.net.RequestResult +import org.signal.registration.NetworkController.MasterKeyResponse +import org.signal.registration.NetworkController.RegisterAccountError +import org.signal.registration.NetworkController.RegistrationLockResponse +import org.signal.registration.NetworkController.RestoreMasterKeyError +import org.signal.registration.NetworkController.SvrCredentials import org.signal.registration.fakes.FakeNetworkController import org.signal.registration.fakes.FakeStorageController import org.signal.registration.fakes.SystemOutLogger @@ -33,11 +52,15 @@ import org.signal.registration.screens.util.MockMultiplePermissionsState import org.signal.registration.screens.util.MockPermissionsState import org.signal.registration.test.TestTags import java.time.Duration +import kotlin.time.Duration.Companion.days /** * End-to-end tests for the registration flow: renders the full [RegistrationNavHost] with a real * [RegistrationRepository] backed by in-memory fake controllers, and drives it by interacting with * the UI the way a user would. + * + * The fakes default to a happy path. To exercise other navigation paths, override the relevant + * response handler on [networkController] or state on [storageController] before driving the UI. */ @OptIn(ExperimentalPermissionsApi::class) @RunWith(RobolectricTestRunner::class) @@ -46,7 +69,8 @@ class RegistrationEndToEndTest { companion object { private const val PHONE_NUMBER = "5550123456" - private const val VERIFICATION_CODE = "123456" + private const val E164 = "+1$PHONE_NUMBER" + private const val VERIFICATION_CODE = FakeNetworkController.DEFAULT_VERIFICATION_CODE private const val PIN = "9182" private const val WAIT_TIMEOUT_MS = 30_000L } @@ -62,6 +86,8 @@ class RegistrationEndToEndTest { private lateinit var repository: RegistrationRepository private lateinit var viewModel: RegistrationViewModel + private val backupFolderUri: Uri = Uri.parse("content://test/backups") + @Before fun setup() { Log.initialize(SystemOutLogger()) @@ -69,7 +95,7 @@ class RegistrationEndToEndTest { val context = ApplicationProvider.getApplicationContext() Shadows.shadowOf(context).grantPermissions(*RegistrationPermissions.getRequiredPermissions(context).toTypedArray()) - networkController = FakeNetworkController(correctVerificationCode = VERIFICATION_CODE) + networkController = FakeNetworkController() storageController = FakeStorageController() repository = RegistrationRepository(context, networkController, storageController, isLinkAndSyncAvailable = false) viewModel = RegistrationViewModel(repository, SavedStateHandle()) @@ -78,37 +104,344 @@ class RegistrationEndToEndTest { @Test fun `happy path - new registration by entering phone number, verification code, and creating a pin`() { var registrationComplete = false + launchRegistrationFlow(onRegistrationComplete = { registrationComplete = true }) - composeTestRule.setContent { - SignalTheme { - RegistrationNavHost( - registrationRepository = repository, - registrationViewModel = viewModel, - permissionsState = createMockPermissionsState(), - onRegistrationComplete = { registrationComplete = true } + submitPhoneNumber() + submitVerificationCode(VERIFICATION_CODE) + createPin(PIN) + + waitFor("registration to complete") { registrationComplete } + + val committed = storageController.committedData + assert(committed != null) { "Expected registration data to be committed" } + assert(committed!!.e164 == E164) { "Expected committed e164 $E164 but was ${committed.e164}" } + assert(committed.aci.isNotEmpty()) { "Expected committed ACI to be populated" } + assert(committed.pni.isNotEmpty()) { "Expected committed PNI to be populated" } + assert(committed.pin == PIN) { "Expected committed pin $PIN but was ${committed.pin}" } + assert(committed.accountEntropyPool.isNotEmpty()) { "Expected committed AEP to be populated" } + + assert(networkController.lastCreateSessionE164 == E164) { "Expected a session for $E164 but was ${networkController.lastCreateSessionE164}" } + assert(networkController.lastRegisterAccountRequest?.e164 == E164) { "Expected registration for $E164 but was ${networkController.lastRegisterAccountRequest}" } + assert(networkController.lastSetPinRequest?.pin == PIN) { "Expected pin $PIN on SVR but was ${networkController.lastSetPinRequest?.pin}" } + assert(networkController.accountAttributesSyncJobEnqueued) { "Expected the account attributes sync job to be enqueued" } + + assert(storageController.restoreDecision == RestoreDecision.NEW_ACCOUNT) { "Expected NEW_ACCOUNT restore decision but was ${storageController.restoreDecision}" } + } + + @Test + fun `a registration lock is unlocked by entering the existing pin and registration completes`() { + val masterKey = MasterKey(ByteArray(32) { it.toByte() }) + + networkController.onRegisterAccount = { request -> + if (request.registrationLock == null) { + RequestResult.NonSuccess( + RegisterAccountError.RegistrationLock( + RegistrationLockResponse( + timeRemaining = 14.days.inWholeMilliseconds, + svr2Credentials = SvrCredentials(username = "svr-user", password = "svr-pass") + ) + ) ) + } else { + RequestResult.Success(networkController.registerAccountResponse(request.e164)) } } - // Welcome -> PhoneNumberEntry (permissions are already granted, so the permissions screen is skipped) - waitForTag(TestTags.WELCOME_SCREEN) - composeTestRule.onNodeWithTag(TestTags.WELCOME_GET_STARTED_BUTTON).performClick() + networkController.onRestoreMasterKeyFromSvr = { request -> + if (request.pin == PIN) { + RequestResult.Success(MasterKeyResponse(masterKey)) + } else { + RequestResult.NonSuccess(RestoreMasterKeyError.WrongPin(triesRemaining = 3)) + } + } - // Enter the phone number and confirm it in the dialog - waitForTag(TestTags.PHONE_NUMBER_SCREEN) - composeTestRule.onNodeWithTag(TestTags.PHONE_NUMBER_PHONE_FIELD).performTextInput(PHONE_NUMBER) - waitForTag(TestTags.PHONE_NUMBER_NEXT_BUTTON) - composeTestRule.onNodeWithTag(TestTags.PHONE_NUMBER_NEXT_BUTTON).performClick() + var registrationComplete = false + launchRegistrationFlow(onRegistrationComplete = { registrationComplete = true }) + + submitPhoneNumber() + submitVerificationCode(VERIFICATION_CODE) + + // The account is reglocked, so the user must prove they know their existing PIN + waitForTag(TestTags.PIN_ENTRY_SCREEN) + composeTestRule.onNodeWithTag(TestTags.PIN_ENTRY_INPUT).performTextInput(PIN) + composeTestRule.onNodeWithTag(TestTags.PIN_ENTRY_CONTINUE_BUTTON).performClick() + + waitFor("registration to complete") { registrationComplete } + + assert(networkController.lastRestoreMasterKeyRequest?.pin == PIN) { "Expected master key restore with pin $PIN but was ${networkController.lastRestoreMasterKeyRequest}" } + assert(networkController.lastRegisterAccountRequest?.registrationLock == masterKey.deriveRegistrationLock()) { "Expected registration with the derived reglock token" } + + val committed = storageController.committedData + assert(committed != null) { "Expected registration data to be committed" } + assert(committed!!.e164 == E164) { "Expected committed e164 $E164 but was ${committed.e164}" } + assert(committed.aci.isNotEmpty()) { "Expected committed ACI to be populated" } + } + + @Test + fun `re-registering an existing account offers restore selection, which can be skipped to complete registration`() { + networkController.onRegisterAccount = { request -> + RequestResult.Success(networkController.registerAccountResponse(request.e164, reregistration = true)) + } + + var registrationComplete = false + launchRegistrationFlow(onRegistrationComplete = { registrationComplete = true }) + + submitPhoneNumber() + submitVerificationCode(VERIFICATION_CODE) + + // The user is re-registering, so they're offered a restore. Decline it, confirming the skip warning. + waitForTag(TestTags.ARCHIVE_RESTORE_SELECTION_SCREEN) + composeTestRule.onNodeWithTag(TestTags.ARCHIVE_RESTORE_SELECTION_NONE).performClick() waitForTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON) composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON).performClick() - // Session is created and a code is requested, landing us on the verification code screen - waitForTag(TestTags.VERIFICATION_CODE_DIGIT_0) - composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_0).performTextInput(VERIFICATION_CODE) + createPin(PIN) - // The code verifies and the account registers, landing us on PIN creation + waitFor("registration to complete") { registrationComplete } + + val committed = storageController.committedData + assert(committed != null) { "Expected registration data to be committed" } + assert(committed!!.e164 == E164) { "Expected committed e164 $E164 but was ${committed.e164}" } + assert(committed.pin == PIN) { "Expected committed pin $PIN but was ${committed.pin}" } + } + + @Test + fun `opting out of creating a pin still completes registration`() { + var registrationComplete = false + launchRegistrationFlow(onRegistrationComplete = { registrationComplete = true }) + + submitPhoneNumber() + submitVerificationCode(VERIFICATION_CODE) + + // Instead of creating a PIN, disable PINs via the overflow menu, confirming the warning waitForTag(TestTags.PIN_CREATION_SCREEN) - composeTestRule.onNodeWithTag(TestTags.PIN_CREATION_INPUT).performTextInput(PIN) + composeTestRule.onNodeWithTag(TestTags.PIN_CREATION_MENU_BUTTON).performClick() + waitForTag(TestTags.PIN_CREATION_DISABLE_PIN_MENU_ITEM) + composeTestRule.onNodeWithTag(TestTags.PIN_CREATION_DISABLE_PIN_MENU_ITEM).performClick() + waitForTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON) + composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON).performClick() + + waitFor("registration to complete") { registrationComplete } + + val committed = storageController.committedData + assert(committed != null) { "Expected registration data to be committed" } + assert(committed!!.pinOptedOut) { "Expected the committed data to record the PIN opt-out" } + assert(committed.pin.isEmpty()) { "Expected no committed pin but was ${committed.pin}" } + assert(networkController.lastSetPinRequest == null) { "Should not have backed up a pin to SVR" } + assert(storageController.restoreDecision == RestoreDecision.NEW_ACCOUNT) { "Expected NEW_ACCOUNT restore decision but was ${storageController.restoreDecision}" } + } + + @Test + fun `restoring a remote backup before registering completes registration`() { + val aep = AccountEntropyPool.generate() + + var registrationComplete = false + launchRegistrationFlow(onRegistrationComplete = { registrationComplete = true }) + + startManualRestore() + chooseRestoreOption(TestTags.ARCHIVE_RESTORE_SELECTION_FROM_SIGNAL_BACKUPS) + enterPhoneNumber() + enterAep(aep) + startRemoteRestore() + + waitFor("registration to complete") { registrationComplete } + + assert(networkController.lastRegisterAccountRequest?.recoveryPassword == aep.deriveMasterKey().deriveRegistrationRecoveryPassword()) { + "Expected registration via the recovery password derived from the entered AEP" + } + + val committed = storageController.committedData + assert(committed != null) { "Expected registration data to be committed" } + assert(committed!!.e164 == E164) { "Expected committed e164 $E164 but was ${committed.e164}" } + assert(committed.accountEntropyPool == aep.value) { "Expected the committed AEP to be the one the user entered" } + assert(storageController.restoreDecision == RestoreDecision.COMPLETED) { "Expected COMPLETED restore decision but was ${storageController.restoreDecision}" } + } + + @Test + fun `restoring a local backup before registering completes registration`() { + val aep = AccountEntropyPool.generate() + + var registrationComplete = false + launchRegistrationFlow(folderPickerResult = backupFolderUri, onRegistrationComplete = { registrationComplete = true }) + + startManualRestore() + chooseRestoreOption(TestTags.ARCHIVE_RESTORE_SELECTION_FROM_BACKUP_FOLDER) + enterPhoneNumber() + restoreLocalBackup(aep) + + // With the backup restored, registration happens via the recovery password and the user creates a PIN + createPin(PIN) + + waitFor("registration to complete") { registrationComplete } + + assert(networkController.lastRegisterAccountRequest?.recoveryPassword == aep.deriveMasterKey().deriveRegistrationRecoveryPassword()) { + "Expected registration via the recovery password derived from the restored AEP" + } + + val committed = storageController.committedData + assert(committed != null) { "Expected registration data to be committed" } + assert(committed!!.e164 == E164) { "Expected committed e164 $E164 but was ${committed.e164}" } + assert(committed.accountEntropyPool == aep.value) { "Expected the committed AEP to be the one from the restored backup" } + assert(committed.pin == PIN) { "Expected committed pin $PIN but was ${committed.pin}" } + } + + @Test + fun `restoring a remote backup after registering completes registration`() { + val aep = AccountEntropyPool.generate() + + networkController.onRegisterAccount = { request -> + RequestResult.Success(networkController.registerAccountResponse(request.e164, reregistration = true)) + } + + var registrationComplete = false + launchRegistrationFlow(onRegistrationComplete = { registrationComplete = true }) + + submitPhoneNumber() + submitVerificationCode(VERIFICATION_CODE) + + // The user is re-registering, so they're offered a restore + chooseRestoreOption(TestTags.ARCHIVE_RESTORE_SELECTION_FROM_SIGNAL_BACKUPS) + enterAep(aep) + startRemoteRestore() + + waitFor("registration to complete") { registrationComplete } + + val committed = storageController.committedData + assert(committed != null) { "Expected registration data to be committed" } + assert(committed!!.accountEntropyPool == aep.value) { "Expected the committed AEP to be the one the user entered" } + assert(storageController.restoreDecision == RestoreDecision.COMPLETED) { "Expected COMPLETED restore decision but was ${storageController.restoreDecision}" } + } + + @Test + fun `restoring a local backup after registering completes registration`() { + val aep = AccountEntropyPool.generate() + + networkController.onRegisterAccount = { request -> + RequestResult.Success(networkController.registerAccountResponse(request.e164, reregistration = true)) + } + + // The backup contains the user's PIN, so the flow finishes without any PIN screens + storageController.onRestoreLocalBackupV2 = { _, _ -> + flowOf(LocalBackupRestoreProgress.Complete(restoredSvrPin = PIN, restoredProfileKey = null)) + } + + var registrationComplete = false + launchRegistrationFlow(folderPickerResult = backupFolderUri, onRegistrationComplete = { registrationComplete = true }) + + submitPhoneNumber() + submitVerificationCode(VERIFICATION_CODE) + + // The user is re-registering, so they're offered a restore + chooseRestoreOption(TestTags.ARCHIVE_RESTORE_SELECTION_FROM_BACKUP_FOLDER) + restoreLocalBackup(aep) + + waitFor("registration to complete") { registrationComplete } + + val committed = storageController.committedData + assert(committed != null) { "Expected registration data to be committed" } + assert(committed!!.pin == PIN) { "Expected the pin from the restored backup but was ${committed.pin}" } + assert(storageController.restoreDecision == RestoreDecision.COMPLETED) { "Expected COMPLETED restore decision but was ${storageController.restoreDecision}" } + } + + // -- Flow helpers: each one drives the UI from the screen the flow is currently on. + + /** + * @param folderPickerResult When set, any system activity launched for a result (i.e. the backup folder picker) + * is immediately answered with this value. + */ + private fun launchRegistrationFlow( + folderPickerResult: Uri? = null, + onRegistrationComplete: () -> Unit = {} + ) { + composeTestRule.setContent { + SignalTheme { + ActivityResultInterceptor(folderPickerResult) { + RegistrationNavHost( + registrationRepository = repository, + registrationViewModel = viewModel, + permissionsState = createMockPermissionsState(), + onRegistrationComplete = onRegistrationComplete + ) + } + } + } + } + + @Composable + private fun ActivityResultInterceptor(result: Any?, content: @Composable () -> Unit) { + if (result == null) { + content() + } else { + val owner = remember { ImmediateResultRegistryOwner(result) } + CompositionLocalProvider(LocalActivityResultRegistryOwner provides owner) { + content() + } + } + } + + /** From the Welcome screen: continues to phone number entry (permissions are granted, so that screen is skipped), enters [PHONE_NUMBER], and confirms the dialog. */ + private fun submitPhoneNumber() { + waitForTag(TestTags.WELCOME_SCREEN) + composeTestRule.onNodeWithTag(TestTags.WELCOME_GET_STARTED_BUTTON).performClick() + enterPhoneNumber() + } + + /** From the phone number entry screen: enters [PHONE_NUMBER] and confirms the dialog. */ + private fun enterPhoneNumber() { + waitForTag(TestTags.PHONE_NUMBER_SCREEN) + composeTestRule.onNodeWithTag(TestTags.PHONE_NUMBER_PHONE_FIELD).performTextInput(PHONE_NUMBER) + composeTestRule.onNodeWithTag(TestTags.PHONE_NUMBER_NEXT_BUTTON).performClick() + + waitForTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON) + composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON).performClick() + } + + /** From the Welcome screen: navigates to manual restore selection via "restore or transfer" → "don't have my old phone". */ + private fun startManualRestore() { + waitForTag(TestTags.WELCOME_SCREEN) + composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_OR_TRANSFER_BUTTON).performClick() + waitForTag(TestTags.WELCOME_RESTORE_NO_OLD_PHONE_BUTTON) + composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_NO_OLD_PHONE_BUTTON).performClick() + } + + /** From the archive restore selection screen: picks the restore option with the given tag. */ + private fun chooseRestoreOption(optionTag: String) { + waitForTag(TestTags.ARCHIVE_RESTORE_SELECTION_SCREEN) + composeTestRule.onNodeWithTag(optionTag).performClick() + } + + /** From the AEP entry screen: types the backup key and submits it. */ + private fun enterAep(aep: AccountEntropyPool) { + waitForTag(TestTags.ENTER_AEP_SCREEN) + composeTestRule.onNodeWithTag(TestTags.ENTER_AEP_INPUT).performTextInput(aep.value) + composeTestRule.onNodeWithTag(TestTags.ENTER_AEP_NEXT_BUTTON).performClick() + } + + /** From the local backup restore screen: picks the backup folder (answered by the fake folder picker), restores the backup that is found, and decrypts it with [aep]. */ + private fun restoreLocalBackup(aep: AccountEntropyPool) { + waitForTag(TestTags.LOCAL_BACKUP_RESTORE_SELECT_FOLDER_BUTTON) + composeTestRule.onNodeWithTag(TestTags.LOCAL_BACKUP_RESTORE_SELECT_FOLDER_BUTTON).performClick() + waitForTag(TestTags.LOCAL_BACKUP_RESTORE_RESTORE_BUTTON) + composeTestRule.onNodeWithTag(TestTags.LOCAL_BACKUP_RESTORE_RESTORE_BUTTON).performClick() + enterAep(aep) + } + + /** From the remote restore screen: starts the restore once the backup info has loaded. */ + private fun startRemoteRestore() { + waitForTag(TestTags.REMOTE_BACKUP_RESTORE_RESTORE_BUTTON) + composeTestRule.onNodeWithTag(TestTags.REMOTE_BACKUP_RESTORE_RESTORE_BUTTON).performClick() + } + + /** From the verification code screen: enters all six digits of [code], which submits automatically. */ + private fun submitVerificationCode(code: String) { + waitForTag(TestTags.VERIFICATION_CODE_DIGIT_0) + composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_0).performTextInput(code) + } + + /** From the PIN creation screen: enters [pin], then re-enters it on the confirmation step. */ + private fun createPin(pin: String) { + waitForTag(TestTags.PIN_CREATION_SCREEN) + composeTestRule.onNodeWithTag(TestTags.PIN_CREATION_INPUT).performTextInput(pin) composeTestRule.onNodeWithTag(TestTags.PIN_CREATION_NEXT_BUTTON).performClick() // Wait for the confirm step's fresh input field to fully replace the create step's @@ -116,27 +449,8 @@ class RegistrationEndToEndTest { composeTestRule.onAllNodesWithTag(TestTags.PIN_CREATION_CONFIRM_INPUT).fetchSemanticsNodes().isNotEmpty() && composeTestRule.onAllNodesWithTag(TestTags.PIN_CREATION_INPUT).fetchSemanticsNodes().isEmpty() } - composeTestRule.onNodeWithTag(TestTags.PIN_CREATION_CONFIRM_INPUT).performTextInput(PIN) + composeTestRule.onNodeWithTag(TestTags.PIN_CREATION_CONFIRM_INPUT).performTextInput(pin) composeTestRule.onNodeWithTag(TestTags.PIN_CREATION_NEXT_BUTTON).performClick() - - // The PIN is backed up to SVR and registration completes - waitFor("registration to complete") { registrationComplete } - - val committed = storageController.committedData - assert(committed != null) { "Expected registration data to be committed" } - assert(committed!!.e164 == "+1$PHONE_NUMBER") { "Expected committed e164 +1$PHONE_NUMBER but was ${committed.e164}" } - assert(committed.aci.isNotEmpty()) { "Expected committed ACI to be populated" } - assert(committed.pni.isNotEmpty()) { "Expected committed PNI to be populated" } - assert(committed.pin == PIN) { "Expected committed pin $PIN but was ${committed.pin}" } - assert(committed.accountEntropyPool.isNotEmpty()) { "Expected committed AEP to be populated" } - - assert(networkController.sessionCreated) { "Expected a verification session to be created" } - assert(networkController.verificationCodeRequested) { "Expected a verification code to be requested" } - assert(networkController.registeredE164 == "+1$PHONE_NUMBER") { "Expected registration for +1$PHONE_NUMBER but was ${networkController.registeredE164}" } - assert(networkController.svrPin == PIN) { "Expected pin $PIN on SVR but was ${networkController.svrPin}" } - assert(networkController.accountAttributesSyncJobEnqueued) { "Expected the account attributes sync job to be enqueued" } - - assert(storageController.restoreDecision == RestoreDecision.NEW_ACCOUNT) { "Expected NEW_ACCOUNT restore decision but was ${storageController.restoreDecision}" } } /** @@ -172,3 +486,15 @@ class RegistrationEndToEndTest { ) } } + +/** + * An [ActivityResultRegistryOwner] that immediately answers any launched contract (e.g. the system folder picker) + * with [result], since no real activity can handle intents in a unit test. + */ +private class ImmediateResultRegistryOwner(private val result: Any?) : ActivityResultRegistryOwner { + override val activityResultRegistry = object : ActivityResultRegistry() { + override fun onLaunch(requestCode: Int, contract: ActivityResultContract, input: I, options: ActivityOptionsCompat?) { + dispatchResult(requestCode, result) + } + } +} diff --git a/feature/registration/src/test/java/org/signal/registration/fakes/FakeNetworkController.kt b/feature/registration/src/test/java/org/signal/registration/fakes/FakeNetworkController.kt index ff2500945a..584e8d9a6b 100644 --- a/feature/registration/src/test/java/org/signal/registration/fakes/FakeNetworkController.kt +++ b/feature/registration/src/test/java/org/signal/registration/fakes/FakeNetworkController.kt @@ -47,53 +47,180 @@ import java.util.UUID import kotlin.time.Duration /** - * An in-memory [NetworkController] that plays the part of a well-behaved server for a fresh registration. - * Methods that should never be hit in the flows under test fail loudly. + * An in-memory [NetworkController] whose responses can be customized per-test. + * + * Every method the registration flow exercises delegates to an overridable `on` handler. The defaults play + * the part of a well-behaved server for a fresh registration, so tests only override the responses they care about: + * + * ``` + * networkController.onRegisterAccount = { + * RequestResult.NonSuccess(RegisterAccountError.RateLimited(retryAfter = 30.seconds)) + * } + * ``` + * + * Requests are recorded (see `last*` properties) no matter which handler serves them, so tests can assert on what + * the flow actually sent. Methods that no flow under test should reach fail loudly. */ class FakeNetworkController( - private val correctVerificationCode: String = "123456" + private val correctVerificationCode: String = DEFAULT_VERIFICATION_CODE ) : NetworkController { - val sessionId = "fake-session-id" + companion object { + const val DEFAULT_VERIFICATION_CODE = "123456" + const val SESSION_ID = "fake-session-id" + } - var sessionCreated = false + data class UpdateSessionRequest(val sessionId: String?, val pushChallengeToken: String?, val captchaToken: String?) + data class RegisterAccountRequest(val e164: String, val sessionId: String?, val recoveryPassword: String?, val registrationLock: String?) + data class SetPinRequest(val pin: String, val masterKey: MasterKey) + data class RestoreMasterKeyRequest(val svrCredentials: SvrCredentials, val pin: String) + + // -- Recorded requests, populated regardless of which handler serves them. + + var lastCreateSessionE164: String? = null private set - var verificationCodeRequested = false + var lastUpdateSessionRequest: UpdateSessionRequest? = null private set - var registeredE164: String? = null + var lastRequestedCodeTransport: VerificationCodeTransport? = null private set - var svrPin: String? = null + var lastSubmittedVerificationCode: String? = null private set - var svrMasterKey: MasterKey? = null + var lastRegisterAccountRequest: RegisterAccountRequest? = null + private set + var lastSetPinRequest: SetPinRequest? = null + private set + var lastRestoreMasterKeyRequest: RestoreMasterKeyRequest? = null private set var accountAttributesSyncJobEnqueued = false private set - private var verified = false + /** + * Whether the fake session has been verified. Set by the default [onSubmitVerificationCode] handler when the + * correct code is submitted, and reflected in the sessions built by [session]. + */ + var sessionVerified = false - private fun currentSession(): SessionMetadata { + /** Returned by [getFcmToken]. Null means the device does not support FCM. */ + var fcmToken: String? = null + + /** Returned by [awaitPushChallengeToken]. Null means no push challenge ever arrives. */ + var pushChallengeToken: String? = null + + // -- Response handlers. Override these in tests to change how the fake server responds. + + var onCreateSession: suspend (e164: String) -> RequestResult = { + RequestResult.Success(session()) + } + + var onGetSession: suspend (sessionId: String) -> RequestResult = { + RequestResult.Success(session()) + } + + var onUpdateSession: suspend (UpdateSessionRequest) -> RequestResult = { + RequestResult.Success(session()) + } + + var onRequestVerificationCode: suspend (sessionId: String) -> RequestResult = { + RequestResult.Success(session()) + } + + var onSubmitVerificationCode: suspend (code: String) -> RequestResult = { code -> + if (code == correctVerificationCode) { + sessionVerified = true + } + RequestResult.Success(session()) + } + + var onRegisterAccount: suspend (RegisterAccountRequest) -> RequestResult = { request -> + check(sessionVerified || request.recoveryPassword != null) { "Attempted to register with a session before it was verified!" } + RequestResult.Success(registerAccountResponse(request.e164)) + } + + var onSetPinAndMasterKeyOnSvr: suspend (SetPinRequest) -> RequestResult = { + RequestResult.Success(null) + } + + var onRestoreMasterKeyFromSvr: suspend (RestoreMasterKeyRequest) -> RequestResult = { + notExpected() + } + + var onGetSvrCredentials: suspend () -> RequestResult = { + notExpected() + } + + var onCheckSvrCredentials: suspend (e164: String, credentials: List) -> RequestResult = { _, _ -> + notExpected() + } + + var onRestoreAccountRecord: suspend () -> RequestResult = { + RequestResult.Success(Unit) + } + + var onGetRemoteBackupInfo: suspend (AccountEntropyPool) -> RequestResult = { + RequestResult.Success(GetBackupInfoResponse(cdn = 3, backupDir = "backup-dir", mediaDir = "media-dir", backupName = "backup", usedSpace = 1_000_000)) + } + + var onGetBackupFileLastModified: suspend (AccountEntropyPool) -> RequestResult = { + RequestResult.Success(1_700_000_000_000) + } + + var onVerifyBackupKey: suspend (AccountEntropyPool) -> RequestResult = { + RequestResult.Success(Unit) + } + + // -- Response factories with happy-path defaults, for handlers that only want to tweak a field or two. + + fun session( + verified: Boolean = sessionVerified, + allowedToRequestCode: Boolean = true, + requestedInformation: List = emptyList(), + nextSms: Long? = null, + nextCall: Long? = null, + nextVerificationAttempt: Long? = null + ): SessionMetadata { return SessionMetadata( - id = sessionId, - nextSms = null, - nextCall = null, - nextVerificationAttempt = null, - allowedToRequestCode = true, - requestedInformation = emptyList(), + id = SESSION_ID, + nextSms = nextSms, + nextCall = nextCall, + nextVerificationAttempt = nextVerificationAttempt, + allowedToRequestCode = allowedToRequestCode, + requestedInformation = requestedInformation, verified = verified ) } + fun registerAccountResponse( + e164: String, + storageCapable: Boolean = false, + reregistration: Boolean = false + ): RegisterAccountResponse { + return RegisterAccountResponse( + aci = UUID.randomUUID().toString(), + pni = UUID.randomUUID().toString(), + e164 = e164, + usernameHash = null, + usernameLinkHandle = null, + storageCapable = storageCapable, + entitlements = null, + reregistration = reregistration + ) + } + + // -- NetworkController implementation: record the request, then delegate to the handler. + override suspend fun createSession(e164: String, fcmToken: String?, mcc: String?, mnc: String?): RequestResult { - sessionCreated = true - return RequestResult.Success(currentSession()) + lastCreateSessionE164 = e164 + return onCreateSession(e164) } override suspend fun getSession(sessionId: String): RequestResult { - return RequestResult.Success(currentSession()) + return onGetSession(sessionId) } override suspend fun updateSession(sessionId: String?, pushChallengeToken: String?, captchaToken: String?): RequestResult { - return RequestResult.Success(currentSession()) + val request = UpdateSessionRequest(sessionId, pushChallengeToken, captchaToken) + lastUpdateSessionRequest = request + return onUpdateSession(request) } override suspend fun requestVerificationCode( @@ -102,15 +229,13 @@ class FakeNetworkController( androidSmsRetrieverSupported: Boolean, transport: VerificationCodeTransport ): RequestResult { - verificationCodeRequested = true - return RequestResult.Success(currentSession()) + lastRequestedCodeTransport = transport + return onRequestVerificationCode(sessionId) } override suspend fun submitVerificationCode(sessionId: String, verificationCode: String): RequestResult { - if (verificationCode == correctVerificationCode) { - verified = true - } - return RequestResult.Success(currentSession()) + lastSubmittedVerificationCode = verificationCode + return onSubmitVerificationCode(verificationCode) } override suspend fun registerAccount( @@ -124,37 +249,27 @@ class FakeNetworkController( fcmToken: String?, skipDeviceTransfer: Boolean ): RequestResult { - check(verified) { "Attempted to register before the session was verified!" } - registeredE164 = e164 - - return RequestResult.Success( - RegisterAccountResponse( - aci = UUID.randomUUID().toString(), - pni = UUID.randomUUID().toString(), - e164 = e164, - usernameHash = null, - usernameLinkHandle = null, - storageCapable = false, - entitlements = null, - reregistration = false - ) - ) + val request = RegisterAccountRequest(e164, sessionId, recoveryPassword, attributes.registrationLock) + lastRegisterAccountRequest = request + return onRegisterAccount(request) } - override suspend fun getFcmToken(): String? = null + override suspend fun getFcmToken(): String? = fcmToken - override suspend fun awaitPushChallengeToken(): String? = null + override suspend fun awaitPushChallengeToken(): String? = pushChallengeToken override fun getCaptchaUrl(): String = "https://example.com/captcha" override suspend fun restoreMasterKeyFromSvr(svrCredentials: SvrCredentials, pin: String): RequestResult { - notExpected() + val request = RestoreMasterKeyRequest(svrCredentials, pin) + lastRestoreMasterKeyRequest = request + return onRestoreMasterKeyFromSvr(request) } override suspend fun setPinAndMasterKeyOnSvr(pin: String, masterKey: MasterKey): RequestResult { - svrPin = pin - svrMasterKey = masterKey - return RequestResult.Success(null) + val request = SetPinRequest(pin, masterKey) + lastSetPinRequest = request + return onSetPinAndMasterKeyOnSvr(request) } override suspend fun enqueueSvrGuessResetJobIfPossible(): Boolean = true @@ -163,9 +278,13 @@ class FakeNetworkController( override suspend fun disableRegistrationLock(): RequestResult = notExpected() - override suspend fun getSvrCredentials(): RequestResult = notExpected() + override suspend fun getSvrCredentials(): RequestResult { + return onGetSvrCredentials() + } - override suspend fun checkSvrCredentials(e164: String, credentials: List): RequestResult = notExpected() + override suspend fun checkSvrCredentials(e164: String, credentials: List): RequestResult { + return onCheckSvrCredentials(e164, credentials) + } override suspend fun setAccountAttributes(attributes: AccountAttributes): RequestResult = notExpected() @@ -173,11 +292,17 @@ class FakeNetworkController( accountAttributesSyncJobEnqueued = true } - override suspend fun getRemoteBackupInfo(aep: AccountEntropyPool): RequestResult = notExpected() + override suspend fun getRemoteBackupInfo(aep: AccountEntropyPool): RequestResult { + return onGetRemoteBackupInfo(aep) + } - override suspend fun getBackupFileLastModified(aep: AccountEntropyPool, backupInfo: GetBackupInfoResponse): RequestResult = notExpected() + override suspend fun getBackupFileLastModified(aep: AccountEntropyPool, backupInfo: GetBackupInfoResponse): RequestResult { + return onGetBackupFileLastModified(aep) + } - override suspend fun verifyBackupKeyAssociatedWithAccount(aep: AccountEntropyPool): RequestResult = notExpected() + override suspend fun verifyBackupKeyAssociatedWithAccount(aep: AccountEntropyPool): RequestResult { + return onVerifyBackupKey(aep) + } override fun startProvisioning(): Flow = notExpected() @@ -204,7 +329,7 @@ class FakeNetworkController( override suspend fun setRestoreMethod(token: String, method: RestoreMethod): RequestResult = notExpected() override suspend fun restoreAccountRecord(timeout: Duration): RequestResult { - return RequestResult.Success(Unit) + return onRestoreAccountRecord() } override suspend fun setProfile(givenName: String, familyName: String, avatar: ByteArray?, discoverableByPhoneNumber: Boolean): RequestResult { diff --git a/feature/registration/src/test/java/org/signal/registration/fakes/FakeStorageController.kt b/feature/registration/src/test/java/org/signal/registration/fakes/FakeStorageController.kt index d2bcb1c69f..d8e17493fe 100644 --- a/feature/registration/src/test/java/org/signal/registration/fakes/FakeStorageController.kt +++ b/feature/registration/src/test/java/org/signal/registration/fakes/FakeStorageController.kt @@ -7,6 +7,7 @@ package org.signal.registration.fakes import android.net.Uri import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf import org.signal.archive.LocalBackupRestoreProgress import org.signal.core.models.AccountEntropyPool import org.signal.registration.PreExistingRegistrationData @@ -17,10 +18,14 @@ import org.signal.registration.proto.RegistrationData import org.signal.registration.screens.localbackuprestore.LocalBackupInfo import org.signal.registration.screens.messagesync.LinkAndSyncProgress import org.signal.registration.screens.remotebackuprestore.RemoteBackupRestoreProgress +import java.time.LocalDateTime /** - * An in-memory [StorageController] representing a fresh, never-registered install. - * Methods that should never be hit in the flows under test fail loudly. + * An in-memory [StorageController] representing a fresh, never-registered install by default. + * Set [preExistingRegistrationData] or [storedProfileData] to simulate a device with prior state. + * + * Backup scanning and restore operations delegate to overridable `on` handlers whose defaults find a single + * V2 backup and restore it successfully. Methods that no flow under test should reach fail loudly. */ class FakeStorageController : StorageController { @@ -31,7 +36,39 @@ class FakeStorageController : StorageController { var restoreDecision: RestoreDecision? = null private set - override suspend fun getPreExistingRegistrationData(): PreExistingRegistrationData? = null + /** Simulates a previously-registered device, which the flow will try to re-register via recovery password. */ + var preExistingRegistrationData: PreExistingRegistrationData? = null + + /** Profile data already on disk, used to pre-seed or skip the create-profile screen. */ + var storedProfileData: StoredProfileData = StoredProfileData() + + // -- Response handlers. Override these in tests to change what is found on disk and how restores play out. + + var onScanLocalBackupFolder: suspend (folderUri: Uri) -> List = { folderUri -> + listOf( + LocalBackupInfo( + type = LocalBackupInfo.BackupType.V2, + date = LocalDateTime.of(2026, 1, 1, 12, 0), + name = "signal-backup-2026-01-01-12-00-00", + uri = Uri.withAppendedPath(folderUri, "signal-backup-2026-01-01-12-00-00"), + sizeBytes = 1024 + ) + ) + } + + var onRestoreLocalBackupV1: (uri: Uri, passphrase: String) -> Flow = { _, _ -> + flowOf(LocalBackupRestoreProgress.Complete(restoredSvrPin = null, restoredProfileKey = null)) + } + + var onRestoreLocalBackupV2: (backupUri: Uri, aep: AccountEntropyPool) -> Flow = { _, _ -> + flowOf(LocalBackupRestoreProgress.Complete(restoredSvrPin = null, restoredProfileKey = null)) + } + + var onRestoreRemoteBackup: (aep: AccountEntropyPool) -> Flow = { + flowOf(RemoteBackupRestoreProgress.Complete(restoredSvrPin = null, restoredProfileKey = null)) + } + + override suspend fun getPreExistingRegistrationData(): PreExistingRegistrationData? = preExistingRegistrationData override suspend fun clearAllData() { inProgressData = RegistrationData() @@ -53,17 +90,25 @@ class FakeStorageController : StorageController { restoreDecision = decision } - override fun restoreLocalBackupV1(uri: Uri, passphrase: String): Flow = notExpected() + override fun restoreLocalBackupV1(rootUri: Uri, backupUri: Uri, passphrase: String): Flow { + return onRestoreLocalBackupV1(backupUri, passphrase) + } - override fun restoreLocalBackupV2(rootUri: Uri, backupUri: Uri, aep: AccountEntropyPool): Flow = notExpected() + override fun restoreLocalBackupV2(rootUri: Uri, backupUri: Uri, aep: AccountEntropyPool): Flow { + return onRestoreLocalBackupV2(backupUri, aep) + } - override fun restoreRemoteBackup(aep: AccountEntropyPool): Flow = notExpected() + override fun restoreRemoteBackup(aep: AccountEntropyPool): Flow { + return onRestoreRemoteBackup(aep) + } override fun restoreLinkAndSyncBackup(cdn: Int, key: String): Flow = notExpected() - override suspend fun scanLocalBackupFolder(folderUri: Uri): List = notExpected() + override suspend fun scanLocalBackupFolder(folderUri: Uri): List { + return onScanLocalBackupFolder(folderUri) + } - override suspend fun getStoredProfileData(): StoredProfileData = StoredProfileData() + override suspend fun getStoredProfileData(): StoredProfileData = storedProfileData private fun notExpected(): Nothing { throw NotImplementedError("This method is not expected to be called in the flow under test.")