Add end to end tests for numberless registration flows.

This commit is contained in:
Greyson Parrelli
2026-09-09 16:44:25 -04:00
committed by Cody Henthorne
parent 84c3fd6fd6
commit 2f98a16094
2 changed files with 619 additions and 3 deletions
@@ -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<Application>()
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<Application>().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<Application>().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<Application>(),
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<Application>().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<UsernamePasswordCredential> {
val saved = mutableListOf<UsernamePasswordCredential>()
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()
@@ -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,