From 2f98a160942a48c40f0dddbd677c8d88d0301659 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Tue, 8 Sep 2026 21:31:24 -0400 Subject: [PATCH] Add end to end tests for numberless registration flows. --- .../registration/RegistrationEndToEndTest.kt | 617 +++++++++++++++++- .../fakes/FakeNetworkController.kt | 5 +- 2 files changed, 619 insertions(+), 3 deletions(-) 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 1d493f5b24..d30d009128 100644 --- a/feature/registration/src/test/java/org/signal/registration/RegistrationEndToEndTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/RegistrationEndToEndTest.kt @@ -6,6 +6,7 @@ package org.signal.registration import android.app.Application +import android.content.pm.ApplicationInfo import android.net.Uri import android.os.Looper import android.view.View @@ -28,15 +29,22 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextClearance 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 io.mockk.coEvery +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkAll import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking +import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test @@ -52,20 +60,26 @@ import org.signal.core.models.ServiceId.PNI 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.Base64 import org.signal.core.util.billing.OneTimePurchaseApi import org.signal.core.util.logging.Log import org.signal.libsignal.net.RequestResult import org.signal.libsignal.protocol.IdentityKeyPair import org.signal.network.api.RegistrationApiV2.CheckSvrCredentialsResponse +import org.signal.network.api.RegistrationApiV2.CreateLoginReceiptCredentialResult import org.signal.network.api.RegistrationApiV2.RegisterAccountError import org.signal.network.api.RegistrationApiV2.RegistrationLockResponse import org.signal.network.api.RegistrationApiV2.RestoreMethod import org.signal.network.api.RegistrationApiV2.SvrCredentials import org.signal.network.api.RegistrationApiV2.UpdateSessionError +import org.signal.passwordmanager.CredentialManagerResult +import org.signal.passwordmanager.SignalCredentialManager +import org.signal.passwordmanager.UsernamePasswordCredential import org.signal.registration.NetworkController.MasterKeyResponse import org.signal.registration.NetworkController.ProvisioningEvent import org.signal.registration.NetworkController.RestoreMasterKeyError import org.signal.registration.fakes.FakeNetworkController +import org.signal.registration.fakes.FakeOneTimePurchaseApi import org.signal.registration.fakes.FakeStorageController import org.signal.registration.fakes.SystemOutLogger import org.signal.registration.proto.SvrCredential @@ -95,6 +109,7 @@ class RegistrationEndToEndTest { private const val E164 = "+1$PHONE_NUMBER" private const val VERIFICATION_CODE = FakeNetworkController.DEFAULT_VERIFICATION_CODE private const val PIN = "9182" + private const val USERNAME = "signaluser" private const val WAIT_TIMEOUT_MS = 30_000L } @@ -106,6 +121,7 @@ class RegistrationEndToEndTest { private lateinit var networkController: FakeNetworkController private lateinit var storageController: FakeStorageController + private lateinit var purchaseApi: FakeOneTimePurchaseApi private lateinit var repository: RegistrationRepository private lateinit var viewModel: RegistrationViewModel private var backDispatcher: OnBackPressedDispatcher? = null @@ -121,9 +137,15 @@ class RegistrationEndToEndTest { networkController = FakeNetworkController() storageController = FakeStorageController() + purchaseApi = FakeOneTimePurchaseApi() repository = RegistrationRepository(context, networkController, storageController, isLinkAndSyncAvailable = false, signalLoginPurchaseApi = OneTimePurchaseApi.Empty) } + @After + fun tearDown() { + unmockkAll() + } + @Test fun `happy path - new registration by entering phone number, verification code, and creating a pin`() { var registrationComplete = false @@ -1515,6 +1537,526 @@ class RegistrationEndToEndTest { assert(storageController.restoreDecision == RestoreDecision.SKIPPED) { "Expected SKIPPED restore decision but was ${storageController.restoreDecision}" } } + // -- Phone-numberless registration (Signal Login) + + @Test + fun `happy path - registering without a phone number by buying a signal login, recording it, and typing it back`() { + enableSignalLoginRegistration() + + var registrationComplete = false + launchRegistrationFlow(onRegistrationComplete = { registrationComplete = true }) + + startSignalLoginRegistration() + buySignalLogin() + + // Redeeming the purchase registers the account outright, so the next thing the user sees is the login they now own + waitForTag(TestTags.SIGNAL_LOGIN_INFO_SCREEN) + val login = registeredSignalLogin() + + recordSignalLoginManually() + enterSignalLogin(login) + skipUsername() + + waitFor("registration to complete") { registrationComplete } + + val committed = storageController.committedData + assert(committed != null) { "Expected registration data to be committed" } + assert(committed!!.accountData?.e164 == null) { "Expected no committed e164 but was ${committed.accountData?.e164}" } + assert(committed.accountData?.pni == null) { "Expected no committed PNI but was ${committed.accountData?.pni}" } + assert(committed.accountData?.aci == login.aci.toString()) { "Expected committed ACI ${login.aci} but was ${committed.accountData?.aci}" } + assert(committed.accountEntropyPool == login.aep.value) { "Expected the committed AEP to be the one the user was shown" } + assert(committed.pin.isEmpty()) { "An account with no phone number has no PIN, but was ${committed.pin}" } + assert(storageController.registrationFlowFinishedCount == 1) { "Expected the flow-finished hook to fire exactly once but fired ${storageController.registrationFlowFinishedCount} times" } + assert(storageController.restoreDecision == RestoreDecision.NEW_ACCOUNT) { "Expected NEW_ACCOUNT restore decision but was ${storageController.restoreDecision}" } + + val request = networkController.lastRegisterAccountRequest + assert(request != null) { "Expected a registration attempt" } + assert(request!!.receiptCredentialPresentation != null) { "Expected the purchase to be redeemed as proof of payment but was $request" } + assert(request.e164 == null && request.sessionId == null && request.recoveryPassword == null) { "Expected a registration carrying nothing but a receipt credential but was $request" } + assert(request.pniPreKeys == null) { "An account with no phone number has no PNI, so no PNI key material should be sent" } + assert(networkController.lastSetPinRequest == null) { "Should not have backed up a PIN for an account with no phone number" } + assert(purchaseApi.consumedTokens == listOf(FakeOneTimePurchaseApi.PURCHASE_TOKEN)) { "Expected the purchase to be consumed once but was ${purchaseApi.consumedTokens}" } + } + + @Test + fun `a purchased signal login that the password manager takes and hands back moves the user on to the username step`() { + enableSignalLoginRegistration() + val savedCredentials = stubPasswordManager() + + var registrationComplete = false + launchRegistrationFlow(onRegistrationComplete = { registrationComplete = true }) + + startSignalLoginRegistration() + buySignalLogin() + + waitForTag(TestTags.SIGNAL_LOGIN_INFO_SCREEN) + val login = registeredSignalLogin() + + composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_INFO_SAVE_TO_PASSWORD_MANAGER_BUTTON).performClick() + + // The password manager took the login, so the user is asked to confirm it really landed there + waitForTag(TestTags.CONFIRM_LOGIN_SAVED_TO_PASSWORD_MANAGER_CONFIRM_BUTTON) + composeTestRule.onNodeWithTag(TestTags.CONFIRM_LOGIN_SAVED_TO_PASSWORD_MANAGER_CONFIRM_BUTTON).performClick() + + // What comes back is the login the user was given, so there is nothing left to do but pick a username + skipUsername() + + waitFor("registration to complete") { registrationComplete } + + val expected = UsernamePasswordCredential(username = login.aci.toString().uppercase(), password = login.aep.displayValue) + assert(savedCredentials == listOf(expected)) { "Expected the login the user was shown to be handed to the password manager once but was $savedCredentials" } + + val committed = storageController.committedData + assert(committed != null) { "Expected registration data to be committed" } + assert(committed!!.accountData?.e164 == null) { "Expected no committed e164 but was ${committed.accountData?.e164}" } + assert(committed.accountEntropyPool == login.aep.value) { "Expected the committed AEP to be the one saved to the password manager" } + } + + @Test + fun `a login the password manager cannot hand back is not treated as saved, and can be recorded by hand instead`() { + enableSignalLoginRegistration() + stubPasswordManager() + coEvery { SignalCredentialManager.getCredential(any(), any()) } returns null + + launchRegistrationFlow() + + startSignalLoginRegistration() + buySignalLogin() + + waitForTag(TestTags.SIGNAL_LOGIN_INFO_SCREEN) + composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_INFO_SAVE_TO_PASSWORD_MANAGER_BUTTON).performClick() + waitForTag(TestTags.CONFIRM_LOGIN_SAVED_TO_PASSWORD_MANAGER_CONFIRM_BUTTON) + composeTestRule.onNodeWithTag(TestTags.CONFIRM_LOGIN_SAVED_TO_PASSWORD_MANAGER_CONFIRM_BUTTON).performClick() + + // Nothing came back, so the user is warned rather than sent on believing their login is safe somewhere + val context = ApplicationProvider.getApplicationContext() + waitForText(context.getString(R.string.SignalLoginInfoScreen__your_signal_login_could_not_be_confirmed)) + assert(composeTestRule.onAllNodesWithTag(TestTags.ADD_USERNAME_SCREEN).fetchSemanticsNodes().isEmpty()) { + "Expected to stay on the login info screen rather than moving on with an unconfirmed login" + } + + // Recording it by hand is still open to them + composeTestRule.onNodeWithText(context.getString(android.R.string.cancel)).performClick() + waitForTag(TestTags.SIGNAL_LOGIN_INFO_SAVE_MANUALLY_BUTTON) + composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_INFO_SAVE_MANUALLY_BUTTON).performClick() + + waitForTag(TestTags.SIGNAL_LOGIN_MANUAL_SAVE_SCREEN) + } + + @Test + fun `typing back a login that is not the one the user was shown is rejected until the real one is entered`() { + enableSignalLoginRegistration() + + var registrationComplete = false + launchRegistrationFlow(onRegistrationComplete = { registrationComplete = true }) + + startSignalLoginRegistration() + buySignalLogin() + + waitForTag(TestTags.SIGNAL_LOGIN_INFO_SCREEN) + val login = registeredSignalLogin() + + recordSignalLoginManually() + + // Some other perfectly well-formed login is still not the one they were just handed + enterSignalLogin(SignalLogin(ACI.from(UUID.randomUUID()), AccountEntropyPool.generate())) + + waitForText(ApplicationProvider.getApplicationContext().getString(R.string.SignalLoginCredentialEntryScreen__that_doesnt_match_the_signal_login_you_were_shown)) + assert(composeTestRule.onAllNodesWithTag(TestTags.ADD_USERNAME_SCREEN).fetchSemanticsNodes().isEmpty()) { + "Expected to stay on the confirmation step rather than accepting a login the user was never shown" + } + + // Correcting it is accepted, and nothing was ever sent to the service to check it + clearSignalLoginFields() + enterSignalLogin(login) + skipUsername() + + waitFor("registration to complete") { registrationComplete } + + assert(networkController.lastRegisterAccountRequest?.receiptCredentialPresentation != null) { + "Confirming a recorded login is a local check, so the only registration should still be the purchase redemption" + } + } + + @Test + fun `asking to see the login info again from the confirmation sheet returns to the keys`() { + enableSignalLoginRegistration() + launchRegistrationFlow() + + startSignalLoginRegistration() + buySignalLogin() + + waitForTag(TestTags.SIGNAL_LOGIN_INFO_SAVE_MANUALLY_BUTTON) + composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_INFO_SAVE_MANUALLY_BUTTON).performClick() + waitForTag(TestTags.SIGNAL_LOGIN_MANUAL_SAVE_CONTINUE_BUTTON) + composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_MANUAL_SAVE_CONTINUE_BUTTON).performClick() + + waitForTag(TestTags.CONFIRM_LOGIN_SAVED_SHOW_LOGIN_INFO_AGAIN_BUTTON) + composeTestRule.onNodeWithTag(TestTags.CONFIRM_LOGIN_SAVED_SHOW_LOGIN_INFO_AGAIN_BUTTON).performClick() + + waitFor("the confirmation sheet to close") { + composeTestRule.onAllNodesWithTag(TestTags.CONFIRM_LOGIN_SAVED_SHEET).fetchSemanticsNodes().isEmpty() + } + assert(composeTestRule.onAllNodesWithTag(TestTags.SIGNAL_LOGIN_MANUAL_SAVE_SCREEN).fetchSemanticsNodes().isNotEmpty()) { + "Expected the keys to still be on screen after backing out of the confirmation sheet" + } + } + + @Test + fun `choosing a username after buying a signal login claims it and completes registration`() { + enableSignalLoginRegistration() + + var registrationComplete = false + launchRegistrationFlow(onRegistrationComplete = { registrationComplete = true }) + + startSignalLoginRegistration() + buySignalLogin() + + waitForTag(TestTags.SIGNAL_LOGIN_INFO_SCREEN) + val login = registeredSignalLogin() + + recordSignalLoginManually() + enterSignalLogin(login) + + waitForTag(TestTags.ADD_USERNAME_FIELD) + composeTestRule.onNodeWithTag(TestTags.ADD_USERNAME_FIELD).performTextInput(USERNAME) + waitForEnabledTag(TestTags.ADD_USERNAME_NEXT_BUTTON) + composeTestRule.onNodeWithTag(TestTags.ADD_USERNAME_NEXT_BUTTON).performClick() + + waitFor("registration to complete") { registrationComplete } + + assert(networkController.lastReservedNickname == USERNAME) { "Expected a reservation for $USERNAME but was ${networkController.lastReservedNickname}" } + assert(networkController.lastReservedDiscriminator == null) { "Expected the service to pick the discriminator but was ${networkController.lastReservedDiscriminator}" } + assert(storageController.savedUsername == "$USERNAME.42") { "Expected the confirmed username to be saved but was ${storageController.savedUsername}" } + } + + @Test + fun `a receipt credential pasted into a debug build registers an account with no phone number`() { + makeBuildDebuggable() + enableSignalLoginRegistration(isGooglePlayBillingAvailable = false) + + var registrationComplete = false + launchRegistrationFlow(onRegistrationComplete = { registrationComplete = true }) + + startSignalLoginRegistration() + + waitForTag(TestTags.SIGNAL_LOGIN_PAYMENT_RECEIPT_CREDENTIAL_FIELD) + composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_PAYMENT_RECEIPT_CREDENTIAL_FIELD).performTextInput(issuedReceiptCredential()) + waitForEnabledTag(TestTags.SIGNAL_LOGIN_PAYMENT_CONTINUE_BUTTON) + composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_PAYMENT_CONTINUE_BUTTON).performClick() + + waitForTag(TestTags.SIGNAL_LOGIN_INFO_SCREEN) + val login = registeredSignalLogin() + + recordSignalLoginManually() + enterSignalLogin(login) + skipUsername() + + waitFor("registration to complete") { registrationComplete } + + assert(networkController.lastRegisterAccountRequest?.receiptCredentialPresentation != null) { + "Expected the pasted credential to be presented as proof of payment but was ${networkController.lastRegisterAccountRequest}" + } + assert(purchaseApi.launchCount == 0) { "Pasting a credential should bypass Google Play entirely" } + assert(storageController.committedData?.accountData?.e164 == null) { "Expected an account with no phone number" } + } + + @Test + fun `logging in with an existing signal login reclaims the account and offers a restore, which can be skipped`() { + enableSignalLoginRegistration() + val login = signalLoginFor(reregistration = true) + + var registrationComplete = false + launchRegistrationFlow(onRegistrationComplete = { registrationComplete = true }) + + startSignalLoginRegistration() + useExistingSignalLogin() + enterSignalLogin(login) + + // The service knows the account, so the user is asked how they want to bring their data back + chooseRestoreOption(TestTags.ARCHIVE_RESTORE_SELECTION_NONE) + waitForTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON) + composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON).performClick() + + waitFor("registration to complete") { registrationComplete } + + val request = networkController.lastRegisterAccountRequest + assert(request != null) { "Expected a registration attempt" } + assert(request!!.aci == login.aci) { "Expected the login to be reclaimed by ACI ${login.aci} but was $request" } + assert(request.recoveryPassword == login.aep.deriveMasterKey().deriveRegistrationRecoveryPassword()) { "Expected the RRP derived from the entered recovery key but was $request" } + assert(request.e164 == null && request.sessionId == null) { "Reclaiming a login needs neither a number nor a session but was $request" } + + val committed = storageController.committedData + assert(committed != null) { "Expected registration data to be committed" } + assert(committed!!.accountData?.e164 == null) { "Expected no committed e164 but was ${committed.accountData?.e164}" } + assert(committed.accountData?.aci == login.aci.toString()) { "Expected committed ACI ${login.aci} but was ${committed.accountData?.aci}" } + assert(committed.accountEntropyPool == login.aep.value) { "Expected the entered recovery key to become the account's AEP" } + assert(committed.pin.isEmpty()) { "An account with no phone number has no PIN, but was ${committed.pin}" } + assert(storageController.restoreDecision == RestoreDecision.SKIPPED) { "Expected SKIPPED restore decision but was ${storageController.restoreDecision}" } + } + + @Test + fun `typing an account id into the phone number field goes straight to signal login entry with it filled in`() { + enableSignalLoginRegistration() + val login = signalLoginFor(reregistration = false) + + var registrationComplete = false + launchRegistrationFlow(onRegistrationComplete = { registrationComplete = true }) + + goToPhoneNumberEntry() + composeTestRule.onNodeWithTag(TestTags.PHONE_NUMBER_PHONE_FIELD).performTextInput(login.aci.toString()) + waitForEnabledTag(TestTags.PHONE_NUMBER_NEXT_BUTTON) + composeTestRule.onNodeWithTag(TestTags.PHONE_NUMBER_NEXT_BUTTON).performClick() + + // The account ID came along with the user, so only the recovery key is left to enter + waitForTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_ENTRY_SCREEN) + composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_RECOVERY_KEY_FIELD).performTextInput(login.aep.value) + submitSignalLogin() + + waitFor("registration to complete") { registrationComplete } + + assert(networkController.lastCreateSessionE164 == null) { "An account ID is not a phone number, so no verification session should be created" } + assert(networkController.lastRegisterAccountRequest?.aci == login.aci) { "Expected the typed account ID to be reclaimed but was ${networkController.lastRegisterAccountRequest}" } + assert(storageController.restoreDecision == RestoreDecision.NEW_ACCOUNT) { "Expected NEW_ACCOUNT restore decision but was ${storageController.restoreDecision}" } + } + + @Test + fun `a signal login the service rejects is flagged on both halves and can be corrected`() { + enableSignalLoginRegistration() + val login = signalLoginFor(reregistration = false) + + var registrationComplete = false + launchRegistrationFlow(onRegistrationComplete = { registrationComplete = true }) + + startSignalLoginRegistration() + useExistingSignalLogin() + + // Either half could be the one that is wrong, so the pair is rejected as a whole + enterSignalLogin(SignalLogin(login.aci, AccountEntropyPool.generate())) + + waitForText(ApplicationProvider.getApplicationContext().getString(R.string.SignalLoginCredentialEntryScreen__incorrect_account_id_or_recovery_key)) + assert(!registrationComplete) { "Registration should not complete with a login the service rejected" } + + clearSignalLoginFields() + enterSignalLogin(login) + + waitFor("registration to complete") { registrationComplete } + + assert(composeTestRule.onAllNodesWithTag(TestTags.ARCHIVE_RESTORE_SELECTION_SCREEN).fetchSemanticsNodes().isEmpty()) { + "The service reported a brand new account, so there should have been nothing to offer to restore" + } + assert(storageController.restoreDecision == RestoreDecision.NEW_ACCOUNT) { "Expected NEW_ACCOUNT restore decision but was ${storageController.restoreDecision}" } + } + + @Test + fun `a signal login that needs two-factor authentication is completed with a code from an authenticator app`() { + enableSignalLoginRegistration() + val totp = "654321" + val login = signalLoginFor(reregistration = false, requiredTotp = totp.toInt()) + + var registrationComplete = false + launchRegistrationFlow(onRegistrationComplete = { registrationComplete = true }) + + startSignalLoginRegistration() + useExistingSignalLogin() + enterSignalLogin(login) + + // The service wants a second factor, so the user picks one and enters a code from it + waitForTag(TestTags.TWO_FACTOR_SELECTION_AUTHENTICATOR_APP_OPTION) + composeTestRule.onNodeWithTag(TestTags.TWO_FACTOR_SELECTION_AUTHENTICATOR_APP_OPTION).performClick() + waitForTag(TestTags.TOTP_ENTRY_DIGIT_0) + composeTestRule.onNodeWithTag(TestTags.TOTP_ENTRY_DIGIT_0).performTextInput(totp) + + waitFor("registration to complete") { registrationComplete } + + assert(networkController.lastRegisterAccountRequest?.totp == totp.toInt()) { "Expected the entered code to be sent with the login but was ${networkController.lastRegisterAccountRequest}" } + assert(storageController.committedData?.accountData?.e164 == null) { "Expected an account with no phone number" } + } + + @Test + fun `a reglocked signal login account is unlocked with the reglock derived from the entered recovery key`() { + enableSignalLoginRegistration() + val login = signalLoginFor(reregistration = false, registrationLocked = true) + + var registrationComplete = false + launchRegistrationFlow(onRegistrationComplete = { registrationComplete = true }) + + startSignalLoginRegistration() + useExistingSignalLogin() + enterSignalLogin(login) + + waitFor("registration to complete") { registrationComplete } + + assert(networkController.lastRegisterAccountRequest?.registrationLock == login.aep.deriveMasterKey().deriveRegistrationLock()) { + "Expected the reglock derived from the recovery key to be retried automatically but was ${networkController.lastRegisterAccountRequest}" + } + assert(composeTestRule.onAllNodesWithTag(TestTags.PIN_ENTRY_SCREEN).fetchSemanticsNodes().isEmpty()) { + "The recovery key already proves ownership, so the user should never have been asked for a PIN" + } + } + + @Test + fun `a reglock the entered recovery key cannot derive falls back to asking for the pin`() { + enableSignalLoginRegistration() + val login = SignalLogin(ACI.from(UUID.randomUUID()), AccountEntropyPool.generate()) + + networkController.onRegisterAccount = { + RequestResult.NonSuccess( + RegisterAccountError.RegistrationLock( + RegistrationLockResponse( + timeRemaining = 14.days.inWholeMilliseconds, + svr2Credentials = SvrCredentials(username = "svr-user", password = "svr-pass") + ) + ) + ) + } + + launchRegistrationFlow() + + startSignalLoginRegistration() + useExistingSignalLogin() + enterSignalLogin(login) + + // The account's reglock isn't the one the recovery key derives, so the PIN behind it is the only way in + waitForTag(TestTags.PIN_ENTRY_SCREEN) + assert(networkController.lastRegisterAccountRequest?.registrationLock == login.aep.deriveMasterKey().deriveRegistrationLock()) { + "Expected the derived reglock to have been tried before falling back but was ${networkController.lastRegisterAccountRequest}" + } + assert(storageController.committedData == null) { "Expected no registration data to be committed while still locked out" } + } + + @Test + fun `restoring a remote backup after logging in with a signal login completes registration without a pin`() { + enableSignalLoginRegistration() + val login = signalLoginFor(reregistration = true) + + var registrationComplete = false + launchRegistrationFlow(onRegistrationComplete = { registrationComplete = true }) + + startSignalLoginRegistration() + useExistingSignalLogin() + enterSignalLogin(login) + + chooseRestoreOption(TestTags.ARCHIVE_RESTORE_SELECTION_FROM_SIGNAL_BACKUPS) + startRemoteRestore() + + waitFor("registration to complete") { registrationComplete } + + assert(composeTestRule.onAllNodesWithTag(TestTags.PIN_CREATION_SCREEN).fetchSemanticsNodes().isEmpty()) { + "An account with no phone number has no PIN, so PIN creation should never have been shown" + } + val committed = storageController.committedData + assert(committed != null) { "Expected registration data to be committed" } + assert(committed!!.accountData?.e164 == null) { "Expected no committed e164 but was ${committed.accountData?.e164}" } + assert(committed.pin.isEmpty()) { "Expected no committed pin but was ${committed.pin}" } + assert(storageController.restoreDecision == RestoreDecision.COMPLETED) { "Expected COMPLETED restore decision but was ${storageController.restoreDecision}" } + } + + // -- Fixture helpers: configure the app and the fake service before the flow is launched. + + /** + * Rebuilds the repository with phone-numberless registration turned on, which is what puts the Signal Login screens + * in front of the user at all. + */ + private fun enableSignalLoginRegistration(isGooglePlayBillingAvailable: Boolean = true) { + repository = RegistrationRepository( + context = ApplicationProvider.getApplicationContext(), + networkController = networkController, + storageController = storageController, + isLinkAndSyncAvailable = false, + isPhoneNumberlessRegistrationAvailable = true, + isGooglePlayBillingAvailable = isGooglePlayBillingAvailable, + signalLoginPurchaseApi = purchaseApi + ) + } + + /** Marks the app debuggable, which is what gates the debug-only affordances in the flow. */ + private fun makeBuildDebuggable() { + val applicationInfo = ApplicationProvider.getApplicationContext().applicationInfo + applicationInfo.flags = applicationInfo.flags or ApplicationInfo.FLAG_DEBUGGABLE + } + + /** + * Stands the system password manager up in memory: it takes whatever it is handed and gives back the last thing it + * took. The returned list holds the credentials it has been asked to save, in order. + */ + private fun stubPasswordManager(): List { + val saved = mutableListOf() + + mockkObject(SignalCredentialManager) + every { SignalCredentialManager.isSupported(any()) } returns true + coEvery { SignalCredentialManager.saveCredential(any(), any(), any()) } answers { + saved += UsernamePasswordCredential(username = secondArg(), password = thirdArg()) + CredentialManagerResult.Success + } + coEvery { SignalCredentialManager.getCredential(any(), any()) } answers { saved.lastOrNull() } + + return saved + } + + /** + * A Signal Login the fake service will honor, along with the [FakeNetworkController.onRegisterAccount] handler that + * answers for the account behind it. Anything but the matching account ID and recovery key is rejected. + * + * @param reregistration Whether the service reports the login as reclaiming an account that already existed. + * @param requiredTotp When set, the login is only accepted alongside this two-factor code. + * @param registrationLocked Whether the account is registration locked until the reglock derived from the recovery key is provided. + */ + private fun signalLoginFor( + reregistration: Boolean, + requiredTotp: Int? = null, + registrationLocked: Boolean = false + ): SignalLogin { + val login = SignalLogin(ACI.from(UUID.randomUUID()), AccountEntropyPool.generate()) + val masterKey = login.aep.deriveMasterKey() + + networkController.onRegisterAccount = { request -> + when { + request.aci != login.aci || request.recoveryPassword != masterKey.deriveRegistrationRecoveryPassword() -> { + RequestResult.NonSuccess(RegisterAccountError.RegistrationRecoveryPasswordIncorrect("no such login")) + } + requiredTotp != null && request.totp != requiredTotp -> { + RequestResult.NonSuccess(RegisterAccountError.TotpMissingOrIncorrect) + } + registrationLocked && request.registrationLock != masterKey.deriveRegistrationLock() -> { + RequestResult.NonSuccess( + RegisterAccountError.RegistrationLock( + RegistrationLockResponse( + timeRemaining = 14.days.inWholeMilliseconds, + svr2Credentials = SvrCredentials(username = "svr-user", password = "svr-pass") + ) + ) + ) + } + else -> RequestResult.Success(networkController.registerAccountResponse(e164 = null, reregistration = reregistration, aci = login.aci.toString())) + } + } + + return login + } + + /** A base64 receipt credential issued by the fake service, in the form a debug build lets the user paste one in. */ + private fun issuedReceiptCredential(): String { + val requestContext = networkController.createReceiptCredentialRequestContext() + val issued = networkController.issueLoginReceiptCredential(requestContext.request) as CreateLoginReceiptCredentialResult.Issued + val received = networkController.receiveReceiptCredential(requestContext, issued.receiptCredentialResponse) as ReceiptCredentialResult.Success + + return Base64.encodeWithPadding(received.value.serialize()) + } + + /** The Signal Login the flow has just registered, read out of the shared state so a test can act on it as the user would. */ + private fun registeredSignalLogin(): SignalLogin { + val state = viewModel.state.value + val aci = state.aci + val aep = state.accountEntropyPool + assert(aci != null && aep != null) { "Expected the flow to be holding a registered Signal Login but was $state" } + + return SignalLogin(aci!!, aep!!) + } + + /** Both halves of a Signal Login: the account ID the user types, and the recovery key that pairs with it. */ + private data class SignalLogin(val aci: ACI, val aep: AccountEntropyPool) + // -- Flow helpers: each one drives the UI from the screen the flow is currently on. /** @@ -1557,9 +2099,15 @@ class RegistrationEndToEndTest { /** 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() { + goToPhoneNumberEntry() + enterPhoneNumber() + } + + /** From the Welcome screen: continues to phone number entry, where either a number or an account ID can be entered. */ + private fun goToPhoneNumberEntry() { waitForTag(TestTags.WELCOME_SCREEN) composeTestRule.onNodeWithTag(TestTags.WELCOME_GET_STARTED_BUTTON).performClick() - enterPhoneNumber() + waitForTag(TestTags.PHONE_NUMBER_SCREEN) } /** From the phone number entry screen: enters [PHONE_NUMBER] and confirms the dialog. */ @@ -1684,6 +2232,65 @@ class RegistrationEndToEndTest { composeTestRule.onNodeWithTag(TestTags.PIN_CREATION_NEXT_BUTTON).performClick() } + /** From the Welcome screen: continues to phone number entry, then opts to register without a phone number. */ + private fun startSignalLoginRegistration() { + goToPhoneNumberEntry() + composeTestRule.onNodeWithTag(TestTags.PHONE_NUMBER_REGISTER_WITHOUT_NUMBER_BUTTON).performClick() + } + + /** From the Signal Login payment screen: buys a login, which registers an account with no phone number. */ + private fun buySignalLogin() { + waitForTag(TestTags.SIGNAL_LOGIN_PAYMENT_SCREEN) + waitForEnabledTag(TestTags.SIGNAL_LOGIN_PAYMENT_CONTINUE_BUTTON) + composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_PAYMENT_CONTINUE_BUTTON).performClick() + } + + /** From the Signal Login payment screen: says the user already owns a login, which leads to credential entry. */ + private fun useExistingSignalLogin() { + waitForTag(TestTags.SIGNAL_LOGIN_PAYMENT_SCREEN) + composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_PAYMENT_EXISTING_LOGIN_OPTION).performScrollTo().performClick() + waitForEnabledTag(TestTags.SIGNAL_LOGIN_PAYMENT_CONTINUE_BUTTON) + composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_PAYMENT_CONTINUE_BUTTON).performClick() + } + + /** From the Signal Login info screen: records the login by hand, then says on the sheet that it really is recorded. */ + private fun recordSignalLoginManually() { + waitForTag(TestTags.SIGNAL_LOGIN_INFO_SAVE_MANUALLY_BUTTON) + composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_INFO_SAVE_MANUALLY_BUTTON).performClick() + waitForTag(TestTags.SIGNAL_LOGIN_MANUAL_SAVE_CONTINUE_BUTTON) + composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_MANUAL_SAVE_CONTINUE_BUTTON).performClick() + waitForTag(TestTags.CONFIRM_LOGIN_SAVED_CONTINUE_BUTTON) + composeTestRule.onNodeWithTag(TestTags.CONFIRM_LOGIN_SAVED_CONTINUE_BUTTON).performClick() + } + + /** On the Signal Login credential entry screen: fills both halves of [login] in and submits them. */ + private fun enterSignalLogin(login: SignalLogin) { + waitForTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_ENTRY_SCREEN) + composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_ACCOUNT_ID_FIELD).performTextInput(login.aci.toString()) + composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_RECOVERY_KEY_FIELD).performTextInput(login.aep.value) + submitSignalLogin() + } + + /** On the Signal Login credential entry screen with both halves already filled in: submits them. */ + private fun submitSignalLogin() { + waitForEnabledTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_NEXT_BUTTON) + composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_NEXT_BUTTON).performClick() + } + + /** On the Signal Login credential entry screen: empties both fields so a rejected login can be typed again. */ + private fun clearSignalLoginFields() { + composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_ACCOUNT_ID_FIELD).performTextClearance() + composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_CREDENTIAL_RECOVERY_KEY_FIELD).performTextClearance() + } + + /** From the add-username screen: declines to pick a username, confirming the warning that comes with it. */ + private fun skipUsername() { + waitForTag(TestTags.ADD_USERNAME_SKIP_BUTTON) + composeTestRule.onNodeWithTag(TestTags.ADD_USERNAME_SKIP_BUTTON).performClick() + waitForTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON) + composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON).performClick() + } + /** * Waits for [condition] to become true, pumping the main looper so that work scheduled by coroutines resuming from * background dispatchers (the real repository hops through Dispatchers.IO) gets executed. Advancing the looper clock @@ -1710,6 +2317,14 @@ class RegistrationEndToEndTest { } } + /** Waits for the node with [tag] to be present and enabled, since a screen often renders its action button before the data that makes it usable has loaded. */ + private fun waitForEnabledTag(tag: String) { + waitFor("node with tag $tag to be enabled") { + val node = composeTestRule.onAllNodesWithTag(tag).fetchSemanticsNodes().firstOrNull() + node != null && node.config.getOrNull(SemanticsProperties.Disabled) == null + } + } + private fun waitForText(text: String) { waitFor("node with text $text") { composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() 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 4f4fa659af..a8eb77f017 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 @@ -309,10 +309,11 @@ class FakeNetworkController( fun registerAccountResponse( e164: String?, storageCapable: Boolean = false, - reregistration: Boolean = false + reregistration: Boolean = false, + aci: String = UUID.randomUUID().toString() ): RegisterAccountResponse { return RegisterAccountResponse( - aci = UUID.randomUUID().toString(), + aci = aci, // An account with no phone number has no PNI, exactly as the service reports it. pni = if (e164 != null) UUID.randomUUID().toString() else null, e164 = e164,