mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 00:35:47 +01:00
Add end to end tests for numberless registration flows.
This commit is contained in:
committed by
Cody Henthorne
parent
84c3fd6fd6
commit
2f98a16094
+616
-1
@@ -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()
|
||||
|
||||
+3
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user