mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 16:54:28 +01:00
Add basic TOTP support with mocked creation.
This commit is contained in:
committed by
Alex Hart
parent
6a032e4f27
commit
62897a309c
+17
-6
@@ -63,7 +63,7 @@ class AccountSettingsViewModelTest {
|
||||
every { repository.isClientDeprecated() } returns false
|
||||
every { repository.getPinKeyboardType() } returns PinKeyboardType.NUMERIC
|
||||
every { repository.isPhoneNumberless() } returns false
|
||||
every { repository.getAuthenticatorAppCount() } returns 0
|
||||
coEvery { repository.getTotpAppCount() } returns 0
|
||||
every { repository.getPasskeyCount() } returns 0
|
||||
every { repository.verifyLocalPin(any()) } answers { firstArg<String>() == CORRECT_PIN }
|
||||
coEvery { repository.setRegistrationLockEnabled(any()) } returns true
|
||||
@@ -303,16 +303,27 @@ class AccountSettingsViewModelTest {
|
||||
@Test
|
||||
fun `the Signal Login section is filled in when the account is phone-numberless`() = runTest(testDispatcher) {
|
||||
every { repository.isPhoneNumberless() } returns true
|
||||
every { repository.getAuthenticatorAppCount() } returns 2
|
||||
coEvery { repository.getTotpAppCount() } returns 2
|
||||
every { repository.getPasskeyCount() } returns 8
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
assertThat(viewModel.state.value.isPhoneNumberless).isTrue()
|
||||
assertThat(viewModel.state.value.signalLogin?.authenticatorAppCount).isEqualTo(2)
|
||||
assertThat(viewModel.state.value.signalLogin?.totpAppCount).isEqualTo(2)
|
||||
assertThat(viewModel.state.value.signalLogin?.passkeyCount).isEqualTo(8)
|
||||
}
|
||||
|
||||
/** Zero would render as "no authenticator apps", which is a claim we can't make when we couldn't reach the service. */
|
||||
@Test
|
||||
fun `a count we couldn't fetch is null rather than zero`() = runTest(testDispatcher) {
|
||||
every { repository.isPhoneNumberless() } returns true
|
||||
coEvery { repository.getTotpAppCount() } returns null
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
assertThat(viewModel.state.value.signalLogin?.totpAppCount).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AccountAndRecoveryClicked opens the Signal Login details screen`() = runTest(testDispatcher) {
|
||||
every { repository.isPhoneNumberless() } returns true
|
||||
@@ -326,15 +337,15 @@ class AccountSettingsViewModelTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AuthenticatorAppClicked opens the authenticator apps screen`() = runTest(testDispatcher) {
|
||||
fun `TotpAppClicked opens the authenticator apps screen`() = runTest(testDispatcher) {
|
||||
every { repository.isPhoneNumberless() } returns true
|
||||
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(AccountSettingsEvent.AuthenticatorAppClicked)
|
||||
viewModel.onEvent(AccountSettingsEvent.TotpAppClicked)
|
||||
|
||||
assertThat(actions.last()).isEqualTo(AccountSettingsAction.NavigateToAuthenticatorApps)
|
||||
assertThat(actions.last()).isEqualTo(AccountSettingsAction.NavigateToTotpAppList)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
-195
@@ -1,195 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.account.authenticator
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.containsExactly
|
||||
import assertk.assertions.isEmpty
|
||||
import assertk.assertions.isEqualTo
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.signal.appsettings.authenticatorapps.AuthenticatorApp
|
||||
import org.signal.appsettings.authenticatorapps.AuthenticatorAppsAction
|
||||
import org.signal.appsettings.authenticatorapps.AuthenticatorAppsEvent
|
||||
import org.signal.appsettings.authenticatorapps.AuthenticatorAppsState.Dialog
|
||||
import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AuthenticatorAppsViewModelTest {
|
||||
|
||||
companion object {
|
||||
private val APP_ONE = AuthenticatorApp(id = 1, name = "Bitwarden Authenticator", createdAt = 0)
|
||||
private val APP_TWO = AuthenticatorApp(id = 2, name = "Twilio Authy", createdAt = 0)
|
||||
}
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
private val repository: AuthenticatorRepository = mockk(relaxed = true)
|
||||
|
||||
@get:Rule
|
||||
val dispatcherRule = CoroutineDispatcherRule(testDispatcher)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
|
||||
every { repository.getMaxApps() } returns 2
|
||||
every { repository.getAuthenticatorApps() } returns emptyList()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the configured apps are read on creation`() = runTest(testDispatcher) {
|
||||
every { repository.getAuthenticatorApps() } returns listOf(APP_ONE)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
assertThat(viewModel.state.value.apps).containsExactly(APP_ONE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ScreenResumed picks up apps added elsewhere`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
assertThat(viewModel.state.value.apps).isEmpty()
|
||||
|
||||
every { repository.getAuthenticatorApps() } returns listOf(APP_ONE)
|
||||
viewModel.onEvent(AuthenticatorAppsEvent.ScreenResumed)
|
||||
|
||||
assertThat(viewModel.state.value.apps).containsExactly(APP_ONE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AddAuthenticatorAppClicked opens setup when there's room for another app`() = runTest(testDispatcher) {
|
||||
every { repository.getAuthenticatorApps() } returns listOf(APP_ONE)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(AuthenticatorAppsEvent.AddAuthenticatorAppClicked)
|
||||
|
||||
assertThat(actions.last()).isEqualTo(AuthenticatorAppsAction.NavigateToSetup)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AddAuthenticatorAppClicked explains the limit when there's no room for another app`() = runTest(testDispatcher) {
|
||||
every { repository.getAuthenticatorApps() } returns listOf(APP_ONE, APP_TWO)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(AuthenticatorAppsEvent.AddAuthenticatorAppClicked)
|
||||
|
||||
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.MaxAppsReached)
|
||||
assertThat(actions).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RenameAppClicked opens the naming screen for that app`() = runTest(testDispatcher) {
|
||||
every { repository.getAuthenticatorApps() } returns listOf(APP_ONE)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(AuthenticatorAppsEvent.RenameAppClicked(APP_ONE.id))
|
||||
|
||||
assertThat(actions.last()).isEqualTo(AuthenticatorAppsAction.NavigateToRename(APP_ONE.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RemoveAppClicked asks the user to confirm first`() = runTest(testDispatcher) {
|
||||
every { repository.getAuthenticatorApps() } returns listOf(APP_ONE)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(AuthenticatorAppsEvent.RemoveAppClicked(APP_ONE.id))
|
||||
|
||||
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.ConfirmRemove(APP_ONE.id))
|
||||
assertThat(actions).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RemoveAppConfirmed collects a code before removing the app`() = runTest(testDispatcher) {
|
||||
every { repository.getAuthenticatorApps() } returns listOf(APP_ONE)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(AuthenticatorAppsEvent.RemoveAppClicked(APP_ONE.id))
|
||||
viewModel.onEvent(AuthenticatorAppsEvent.RemoveAppConfirmed)
|
||||
|
||||
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None)
|
||||
assertThat(actions.last()).isEqualTo(AuthenticatorAppsAction.NavigateToRemovalCodeEntry(APP_ONE.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RemoveAppConfirmed does nothing when no removal is pending`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(AuthenticatorAppsEvent.RemoveAppConfirmed)
|
||||
|
||||
assertThat(actions).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DialogDismissed clears the dialog`() = runTest(testDispatcher) {
|
||||
every { repository.getAuthenticatorApps() } returns listOf(APP_ONE)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.onEvent(AuthenticatorAppsEvent.RemoveAppClicked(APP_ONE.id))
|
||||
viewModel.onEvent(AuthenticatorAppsEvent.DialogDismissed)
|
||||
|
||||
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateBackClicked leaves the screen`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(AuthenticatorAppsEvent.NavigateBackClicked)
|
||||
|
||||
assertThat(actions.last()).isEqualTo(AuthenticatorAppsAction.NavigateBack)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LearnMoreClicked opens the support article`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(AuthenticatorAppsEvent.LearnMoreClicked)
|
||||
|
||||
assertThat(actions.last()).isEqualTo(AuthenticatorAppsAction.OpenLearnMore)
|
||||
}
|
||||
|
||||
private fun createViewModel() = AuthenticatorAppsViewModel(repository)
|
||||
|
||||
private fun TestScope.collectActions(actions: Flow<AuthenticatorAppsAction>): List<AuthenticatorAppsAction> {
|
||||
val collected = mutableListOf<AuthenticatorAppsAction>()
|
||||
backgroundScope.launch { actions.toList(collected) }
|
||||
return collected
|
||||
}
|
||||
}
|
||||
-125
@@ -1,125 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.account.authenticator
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.contains
|
||||
import assertk.assertions.isEmpty
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.signal.appsettings.authenticatorcodeentry.AuthenticatorCodeEntryAction
|
||||
import org.signal.appsettings.authenticatorcodeentry.AuthenticatorCodeEntryEvent
|
||||
import org.signal.appsettings.authenticatorcodeentry.AuthenticatorCodeEntryState.Purpose
|
||||
import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AuthenticatorCodeEntryViewModelTest {
|
||||
|
||||
companion object {
|
||||
private const val FULL_CODE = "123456"
|
||||
}
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
@get:Rule
|
||||
val dispatcherRule = CoroutineDispatcherRule(testDispatcher)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
clearApps()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
clearApps()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-digits are dropped and the code is capped at six digits`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.onEvent(AuthenticatorCodeEntryEvent.CodeChanged("12a34 5678"))
|
||||
|
||||
assertThat(viewModel.state.value.code).isEqualTo(FULL_CODE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a partial code can't be submitted`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(AuthenticatorCodeEntryEvent.CodeChanged("123"))
|
||||
|
||||
assertThat(viewModel.state.value.canSubmit).isFalse()
|
||||
|
||||
viewModel.onEvent(AuthenticatorCodeEntryEvent.DoneClicked)
|
||||
|
||||
assertThat(actions).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full code entered while adding sends the user on to name the app`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel(purpose = Purpose.Add)
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(AuthenticatorCodeEntryEvent.CodeChanged(FULL_CODE))
|
||||
viewModel.onEvent(AuthenticatorCodeEntryEvent.DoneClicked)
|
||||
|
||||
assertThat(actions.last()).isEqualTo(AuthenticatorCodeEntryAction.NavigateToNaming)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full code entered while removing removes the app and goes back to the list`() = runTest(testDispatcher) {
|
||||
val appId = AuthenticatorAppStore.addApp(name = "Bitwarden Authenticator", createdAt = 0)
|
||||
val viewModel = createViewModel(purpose = Purpose.Remove(appId))
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(AuthenticatorCodeEntryEvent.CodeChanged(FULL_CODE))
|
||||
viewModel.onEvent(AuthenticatorCodeEntryEvent.DoneClicked)
|
||||
|
||||
assertThat(AuthenticatorAppStore.getApps()).isEmpty()
|
||||
assertThat(actions).contains(AuthenticatorCodeEntryAction.ShowAuthenticatorAppRemoved)
|
||||
assertThat(actions.last()).isEqualTo(AuthenticatorCodeEntryAction.NavigateToAuthenticatorApps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateBackClicked leaves the screen`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(AuthenticatorCodeEntryEvent.NavigateBackClicked)
|
||||
|
||||
assertThat(actions.last()).isEqualTo(AuthenticatorCodeEntryAction.NavigateBack)
|
||||
}
|
||||
|
||||
private fun createViewModel(purpose: Purpose = Purpose.Add) = AuthenticatorCodeEntryViewModel(purpose = purpose)
|
||||
|
||||
private fun clearApps() {
|
||||
AuthenticatorAppStore.getApps().forEach { AuthenticatorAppStore.removeApp(it.id) }
|
||||
}
|
||||
|
||||
private fun TestScope.collectActions(actions: Flow<AuthenticatorCodeEntryAction>): List<AuthenticatorCodeEntryAction> {
|
||||
val collected = mutableListOf<AuthenticatorCodeEntryAction>()
|
||||
backgroundScope.launch { actions.toList(collected) }
|
||||
return collected
|
||||
}
|
||||
}
|
||||
-134
@@ -1,134 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.account.authenticator
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.contains
|
||||
import assertk.assertions.isEmpty
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isTrue
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.signal.appsettings.authenticatorapps.AuthenticatorApp
|
||||
import org.signal.appsettings.authenticatorname.AuthenticatorNameAction
|
||||
import org.signal.appsettings.authenticatorname.AuthenticatorNameEvent
|
||||
import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AuthenticatorNameViewModelTest {
|
||||
|
||||
companion object {
|
||||
private val EXISTING_APP = AuthenticatorApp(id = 7, name = "Twilio Authy", createdAt = 0)
|
||||
}
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
private val repository: AuthenticatorRepository = mockk(relaxed = true)
|
||||
|
||||
@get:Rule
|
||||
val dispatcherRule = CoroutineDispatcherRule(testDispatcher)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
|
||||
every { repository.getAuthenticatorApp(EXISTING_APP.id) } returns EXISTING_APP
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `naming a new app starts empty and isn't renaming`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel(appId = null)
|
||||
|
||||
assertThat(viewModel.state.value.name).isEqualTo("")
|
||||
assertThat(viewModel.state.value.renaming).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `renaming starts from the app's current name`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel(appId = EXISTING_APP.id)
|
||||
|
||||
assertThat(viewModel.state.value.name).isEqualTo(EXISTING_APP.name)
|
||||
assertThat(viewModel.state.value.renaming).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank name can't be submitted`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel(appId = null)
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(AuthenticatorNameEvent.NameChanged(" "))
|
||||
|
||||
assertThat(viewModel.state.value.canSubmit).isFalse()
|
||||
|
||||
viewModel.onEvent(AuthenticatorNameEvent.NextClicked)
|
||||
|
||||
assertThat(actions).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NextClicked adds a new app and goes back to the list`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel(appId = null)
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(AuthenticatorNameEvent.NameChanged(" Bitwarden Authenticator "))
|
||||
viewModel.onEvent(AuthenticatorNameEvent.NextClicked)
|
||||
|
||||
verify { repository.addAuthenticatorApp("Bitwarden Authenticator") }
|
||||
assertThat(actions).contains(AuthenticatorNameAction.ShowAuthenticatorAppSetUp)
|
||||
assertThat(actions.last()).isEqualTo(AuthenticatorNameAction.NavigateToAuthenticatorApps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NextClicked renames an existing app and goes back to the list`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel(appId = EXISTING_APP.id)
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(AuthenticatorNameEvent.NameChanged("Work Authenticator"))
|
||||
viewModel.onEvent(AuthenticatorNameEvent.NextClicked)
|
||||
|
||||
verify { repository.renameAuthenticatorApp(EXISTING_APP.id, "Work Authenticator") }
|
||||
assertThat(actions).contains(AuthenticatorNameAction.ShowAuthenticatorAppRenamed)
|
||||
assertThat(actions.last()).isEqualTo(AuthenticatorNameAction.NavigateToAuthenticatorApps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateBackClicked leaves the screen`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel(appId = null)
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(AuthenticatorNameEvent.NavigateBackClicked)
|
||||
|
||||
assertThat(actions.last()).isEqualTo(AuthenticatorNameAction.NavigateBack)
|
||||
}
|
||||
|
||||
private fun createViewModel(appId: Long?) = AuthenticatorNameViewModel(appId, repository)
|
||||
|
||||
private fun TestScope.collectActions(actions: Flow<AuthenticatorNameAction>): List<AuthenticatorNameAction> {
|
||||
val collected = mutableListOf<AuthenticatorNameAction>()
|
||||
backgroundScope.launch { actions.toList(collected) }
|
||||
return collected
|
||||
}
|
||||
}
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.account.authenticator
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Bundle
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.signal.appsettings.authenticatorcodeentry.AuthenticatorCodeEntryState.Purpose
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(application = Application::class)
|
||||
class AuthenticatorNavArgsTest {
|
||||
|
||||
@Test
|
||||
fun `no arguments means adding a new app`() {
|
||||
assertThat(AuthenticatorNavArgs.purpose(null)).isEqualTo(Purpose.Add)
|
||||
assertThat(AuthenticatorNavArgs.appId(null)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unset app id reads as null`() {
|
||||
val arguments = Bundle().apply { putLong(AuthenticatorNavArgs.ARG_APP_ID, AuthenticatorNavArgs.NO_APP_ID) }
|
||||
|
||||
assertThat(AuthenticatorNavArgs.appId(arguments)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a removal carries the app id it names`() {
|
||||
val arguments = Bundle().apply {
|
||||
putString(AuthenticatorNavArgs.ARG_PURPOSE, AuthenticatorNavArgs.PURPOSE_REMOVE)
|
||||
putLong(AuthenticatorNavArgs.ARG_APP_ID, 7)
|
||||
}
|
||||
|
||||
assertThat(AuthenticatorNavArgs.appId(arguments)).isEqualTo(7L)
|
||||
assertThat(AuthenticatorNavArgs.purpose(arguments)).isEqualTo(Purpose.Remove(7))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a removal without an app id falls back to adding rather than removing something unidentified`() {
|
||||
val arguments = Bundle().apply { putString(AuthenticatorNavArgs.ARG_PURPOSE, AuthenticatorNavArgs.PURPOSE_REMOVE) }
|
||||
|
||||
assertThat(AuthenticatorNavArgs.purpose(arguments)).isEqualTo(Purpose.Add)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unrecognized purpose falls back to adding instead of throwing`() {
|
||||
val arguments = Bundle().apply { putString(AuthenticatorNavArgs.ARG_PURPOSE, "nonsense") }
|
||||
|
||||
assertThat(AuthenticatorNavArgs.purpose(arguments)).isEqualTo(Purpose.Add)
|
||||
}
|
||||
}
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.account.authenticator
|
||||
|
||||
import android.app.Application
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.contains
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.signal.appsettings.authenticatorsetup.AuthenticatorSetupAction
|
||||
import org.signal.appsettings.authenticatorsetup.AuthenticatorSetupEvent
|
||||
import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(application = Application::class)
|
||||
class AuthenticatorSetupViewModelTest {
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
@get:Rule
|
||||
val dispatcherRule = CoroutineDispatcherRule(testDispatcher)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the setup key is available as soon as the screen opens`() = runTest(testDispatcher) {
|
||||
val viewModel = AuthenticatorSetupViewModel()
|
||||
|
||||
assertThat(viewModel.state.value.setupKey).isEqualTo(AuthenticatorAppStore.MOCK_SETUP_KEY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OpenAuthenticatorAppClicked hands off a link carrying the setup key`() = runTest(testDispatcher) {
|
||||
val viewModel = AuthenticatorSetupViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(AuthenticatorSetupEvent.OpenAuthenticatorAppClicked)
|
||||
|
||||
val action = actions.last()
|
||||
assertThat(action).isInstanceOf(AuthenticatorSetupAction.LaunchAuthenticatorApp::class)
|
||||
assertThat((action as AuthenticatorSetupAction.LaunchAuthenticatorApp).uri).contains(AuthenticatorAppStore.MOCK_SETUP_KEY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CopyKeyClicked copies the key and tells the user`() = runTest(testDispatcher) {
|
||||
val viewModel = AuthenticatorSetupViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(AuthenticatorSetupEvent.CopyKeyClicked)
|
||||
|
||||
assertThat(actions).contains(AuthenticatorSetupAction.CopyKeyToClipboard(AuthenticatorAppStore.MOCK_SETUP_KEY))
|
||||
assertThat(actions.last()).isEqualTo(AuthenticatorSetupAction.ShowKeyCopied)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ContinueClicked moves on to code entry`() = runTest(testDispatcher) {
|
||||
val viewModel = AuthenticatorSetupViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(AuthenticatorSetupEvent.ContinueClicked)
|
||||
|
||||
assertThat(actions.last()).isEqualTo(AuthenticatorSetupAction.NavigateToCodeEntry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NoAuthenticatorAppFound reports the failure`() = runTest(testDispatcher) {
|
||||
val viewModel = AuthenticatorSetupViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(AuthenticatorSetupEvent.NoAuthenticatorAppFound)
|
||||
|
||||
assertThat(actions.last()).isEqualTo(AuthenticatorSetupAction.ShowNoAuthenticatorAppFound)
|
||||
}
|
||||
|
||||
private fun TestScope.collectActions(actions: Flow<AuthenticatorSetupAction>): List<AuthenticatorSetupAction> {
|
||||
val collected = mutableListOf<AuthenticatorSetupAction>()
|
||||
backgroundScope.launch { actions.toList(collected) }
|
||||
return collected
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.account.authenticator
|
||||
|
||||
import assertk.assertFailure
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.hasSize
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Covers the parts of the stand-in that copy behaviour the service is strict about, since those are the parts most
|
||||
* likely to be wrong once there's a real service behind [TotpApi].
|
||||
*/
|
||||
class InMemoryTotpApiTest {
|
||||
|
||||
companion object {
|
||||
private const val NOW = 1_700_000_000_000L
|
||||
private const val CODE = 123456
|
||||
private val METADATA = TotpApi.Metadata(name = "Aegis", createdAt = Instant.ofEpochMilli(NOW))
|
||||
private val OTHER_METADATA = TotpApi.Metadata(name = "Aegis on my tablet", createdAt = Instant.ofEpochMilli(NOW))
|
||||
}
|
||||
|
||||
private val api = InMemoryTotpApi()
|
||||
|
||||
@Test
|
||||
fun `a generated key is 32 bytes`() = runTest {
|
||||
val result = api.generateKey()
|
||||
|
||||
assertThat(result).isInstanceOf(RequestResult.Success::class)
|
||||
assertThat((result as RequestResult.Success).result.key.size).isEqualTo(32)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pending key doesn't show up until it's confirmed`() = runTest {
|
||||
api.generateKey()
|
||||
|
||||
assertThat(listedKeys()).hasSize(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a confirmed key is assigned the lowest free id`() = runTest {
|
||||
val first = confirmNewKey()
|
||||
val second = confirmNewKey()
|
||||
|
||||
assertThat(first).isEqualTo(0)
|
||||
assertThat(second).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an id freed by a removal is handed out again rather than counting upwards`() = runTest {
|
||||
confirmNewKey()
|
||||
val second = confirmNewKey()
|
||||
api.removeKey(second)
|
||||
|
||||
assertThat(confirmNewKey()).isEqualTo(second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirming with no pending key doesn't confirm anything`() = runTest {
|
||||
assertThat(api.confirmKey(oneTimePassword = CODE, metadata = METADATA)).isEqualTo(RequestResult.NonSuccess(TotpApi.ConfirmKeyError.NotVerified))
|
||||
assertThat(listedKeys()).hasSize(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an account at its limit can't generate another key`() = runTest {
|
||||
repeat(TotpApi.MAX_KEYS) { confirmNewKey() }
|
||||
|
||||
assertThat(api.generateKey()).isEqualTo(RequestResult.NonSuccess(TotpApi.GenerateKeyError.TooManyKeys))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removing a key makes room for another`() = runTest {
|
||||
repeat(TotpApi.MAX_KEYS) { confirmNewKey() }
|
||||
api.removeKey(0)
|
||||
|
||||
assertThat(api.generateKey()).isInstanceOf(RequestResult.Success::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metadata can be replaced on a confirmed key`() = runTest {
|
||||
val keyId = confirmNewKey()
|
||||
|
||||
assertThat(api.setKeyMetadata(keyId, OTHER_METADATA)).isEqualTo(RequestResult.Success(Unit))
|
||||
assertThat(listedKeys().first().metadata).isEqualTo(OTHER_METADATA)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metadata for a key that isn't there is reported rather than created`() = runTest {
|
||||
assertThat(api.setKeyMetadata(7, METADATA)).isEqualTo(RequestResult.NonSuccess(TotpApi.SetKeyMetadataError.KeyNotFound))
|
||||
}
|
||||
|
||||
/** The service leaves a fixed amount of room for the name, so a name that doesn't fit is the caller's bug. */
|
||||
@Test
|
||||
fun `a name longer than the room the service leaves is refused`() = runTest {
|
||||
val keyId = confirmNewKey()
|
||||
val tooLong = METADATA.copy(name = "a".repeat(TotpApi.Metadata.NAME_MAX_LENGTH + 1))
|
||||
|
||||
assertFailure { api.setKeyMetadata(keyId, tooLong) }.isInstanceOf(IllegalArgumentException::class)
|
||||
}
|
||||
|
||||
/** The service reports success either way, so a retried removal looks like the original. */
|
||||
@Test
|
||||
fun `removing a key that isn't there still succeeds`() = runTest {
|
||||
assertThat(api.removeKey(7)).isEqualTo(RequestResult.Success(Unit))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keys are listed in ascending id order`() = runTest {
|
||||
confirmNewKey()
|
||||
confirmNewKey()
|
||||
|
||||
assertThat(listedKeys().map { it.keyId }).isEqualTo(listOf(0, 1))
|
||||
}
|
||||
|
||||
private suspend fun confirmNewKey(): Int {
|
||||
api.generateKey()
|
||||
return (api.confirmKey(oneTimePassword = CODE, metadata = METADATA) as RequestResult.Success).result
|
||||
}
|
||||
|
||||
private suspend fun listedKeys(): List<TotpApi.RemoteKey> = (api.listKeys() as RequestResult.Success).result
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.account.authenticator
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.containsExactly
|
||||
import assertk.assertions.isEmpty
|
||||
import assertk.assertions.isEqualTo
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.signal.appsettings.totpapplist.TotpApp
|
||||
import org.signal.appsettings.totpapplist.TotpAppListAction
|
||||
import org.signal.appsettings.totpapplist.TotpAppListEvent
|
||||
import org.signal.appsettings.totpapplist.TotpAppListState.Dialog
|
||||
import org.signal.appsettings.totpapplist.TotpAppListState.LoadState
|
||||
import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class TotpAppListViewModelTest {
|
||||
|
||||
companion object {
|
||||
private val APP_ONE = TotpApp(id = 1, name = "Bitwarden Authenticator", createdAt = 0)
|
||||
private val APP_TWO = TotpApp(id = 2, name = "Twilio Authy", createdAt = 0)
|
||||
}
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
private val repository: TotpRepository = mockk(relaxed = true)
|
||||
|
||||
@get:Rule
|
||||
val dispatcherRule = CoroutineDispatcherRule(testDispatcher)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
|
||||
every { repository.getMaxApps() } returns 2
|
||||
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(emptyList())
|
||||
coEvery { repository.removeTotpApp(any()) } returns TotpRepository.UpdateResult.Success
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the configured apps are read on creation`() = runTest(testDispatcher) {
|
||||
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE))
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
assertThat(viewModel.state.value.apps).containsExactly(APP_ONE)
|
||||
}
|
||||
|
||||
/** An empty list says nothing on its own, so the screen leans on [LoadState] to know we haven't heard back yet. */
|
||||
@Test
|
||||
fun `the state is LOADING until we've heard back about the account`() = runTest(testDispatcher) {
|
||||
coEvery { repository.getTotpApps() } coAnswers { awaitCancellation() }
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
assertThat(viewModel.state.value.loadState).isEqualTo(LoadState.LOADING)
|
||||
assertThat(viewModel.state.value.apps).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a service we couldn't reach clears the list and says so`() = runTest(testDispatcher) {
|
||||
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.NetworkFailure
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
assertThat(viewModel.state.value.loadState).isEqualTo(LoadState.NETWORK_FAILURE)
|
||||
assertThat(viewModel.state.value.apps).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a load that succeeds after one that failed clears the failure`() = runTest(testDispatcher) {
|
||||
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.NetworkFailure
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE))
|
||||
viewModel.onEvent(TotpAppListEvent.ScreenResumed)
|
||||
|
||||
assertThat(viewModel.state.value.loadState).isEqualTo(LoadState.LOADED)
|
||||
assertThat(viewModel.state.value.apps).containsExactly(APP_ONE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ScreenResumed picks up apps added elsewhere`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
assertThat(viewModel.state.value.apps).isEmpty()
|
||||
|
||||
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE))
|
||||
viewModel.onEvent(TotpAppListEvent.ScreenResumed)
|
||||
|
||||
assertThat(viewModel.state.value.apps).containsExactly(APP_ONE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AddTotpAppClicked opens setup when there's room for another app`() = runTest(testDispatcher) {
|
||||
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE))
|
||||
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(TotpAppListEvent.AddTotpAppClicked)
|
||||
|
||||
assertThat(actions.last()).isEqualTo(TotpAppListAction.NavigateToSetup)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AddTotpAppClicked explains the limit when there's no room for another app`() = runTest(testDispatcher) {
|
||||
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE, APP_TWO))
|
||||
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(TotpAppListEvent.AddTotpAppClicked)
|
||||
|
||||
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.MaxAppsReached)
|
||||
assertThat(actions).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RenameAppClicked opens the naming screen for that app`() = runTest(testDispatcher) {
|
||||
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE))
|
||||
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(TotpAppListEvent.RenameAppClicked(APP_ONE.id))
|
||||
|
||||
assertThat(actions.last()).isEqualTo(TotpAppListAction.NavigateToRename(APP_ONE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RemoveAppClicked asks the user to confirm first`() = runTest(testDispatcher) {
|
||||
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE))
|
||||
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(TotpAppListEvent.RemoveAppClicked(APP_ONE.id))
|
||||
|
||||
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.ConfirmRemove(APP_ONE.id))
|
||||
assertThat(actions).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RemoveAppConfirmed removes the app and says so`() = runTest(testDispatcher) {
|
||||
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE))
|
||||
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(TotpAppListEvent.RemoveAppClicked(APP_ONE.id))
|
||||
viewModel.onEvent(TotpAppListEvent.RemoveAppConfirmed(APP_ONE.id))
|
||||
|
||||
coVerify { repository.removeTotpApp(APP_ONE.id) }
|
||||
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None)
|
||||
assertThat(actions.last()).isEqualTo(TotpAppListAction.ShowTotpAppRemoved)
|
||||
}
|
||||
|
||||
/** The list is what tells the user the app is gone, so it has to be read again rather than assumed. */
|
||||
@Test
|
||||
fun `a removal re-reads the list`() = runTest(testDispatcher) {
|
||||
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE))
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(emptyList())
|
||||
viewModel.onEvent(TotpAppListEvent.RemoveAppConfirmed(APP_ONE.id))
|
||||
|
||||
assertThat(viewModel.state.value.apps).isEmpty()
|
||||
}
|
||||
|
||||
/** Removing a key the service has already forgotten is the outcome the user wanted, so it isn't an error. */
|
||||
@Test
|
||||
fun `removing an app the service doesn't have still counts as removed`() = runTest(testDispatcher) {
|
||||
coEvery { repository.removeTotpApp(any()) } returns TotpRepository.UpdateResult.AppNotFound
|
||||
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(TotpAppListEvent.RemoveAppConfirmed(APP_ONE.id))
|
||||
|
||||
assertThat(actions.last()).isEqualTo(TotpAppListAction.ShowTotpAppRemoved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a removal that didn't go through says so rather than pretending the app is gone`() = runTest(testDispatcher) {
|
||||
coEvery { repository.removeTotpApp(any()) } returns TotpRepository.UpdateResult.NetworkFailure
|
||||
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(TotpAppListEvent.RemoveAppConfirmed(APP_ONE.id))
|
||||
|
||||
assertThat(actions.last()).isEqualTo(TotpAppListAction.ShowRemovalFailed)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the confirm button actually does: the dialog dismisses itself before it reports the confirmation, so the
|
||||
* removal has to survive arriving after the dialog is already gone.
|
||||
*/
|
||||
@Test
|
||||
fun `RemoveAppConfirmed removes the app even though the dialog dismissed itself first`() = runTest(testDispatcher) {
|
||||
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE))
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.onEvent(TotpAppListEvent.RemoveAppClicked(APP_ONE.id))
|
||||
viewModel.onEvent(TotpAppListEvent.DialogDismissed)
|
||||
viewModel.onEvent(TotpAppListEvent.RemoveAppConfirmed(APP_ONE.id))
|
||||
|
||||
coVerify { repository.removeTotpApp(APP_ONE.id) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DialogDismissed clears the dialog`() = runTest(testDispatcher) {
|
||||
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE))
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.onEvent(TotpAppListEvent.RemoveAppClicked(APP_ONE.id))
|
||||
viewModel.onEvent(TotpAppListEvent.DialogDismissed)
|
||||
|
||||
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateBackClicked leaves the screen`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(TotpAppListEvent.NavigateBackClicked)
|
||||
|
||||
assertThat(actions.last()).isEqualTo(TotpAppListAction.NavigateBack)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LearnMoreClicked opens the support article`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(TotpAppListEvent.LearnMoreClicked)
|
||||
|
||||
assertThat(actions.last()).isEqualTo(TotpAppListAction.OpenLearnMore)
|
||||
}
|
||||
|
||||
private fun createViewModel() = TotpAppListViewModel(repository)
|
||||
|
||||
private fun TestScope.collectActions(actions: Flow<TotpAppListAction>): List<TotpAppListAction> {
|
||||
val collected = mutableListOf<TotpAppListAction>()
|
||||
backgroundScope.launch { actions.toList(collected) }
|
||||
return collected
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.account.authenticator
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEmpty
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.signal.appsettings.totpcodeentry.TotpCodeEntryAction
|
||||
import org.signal.appsettings.totpcodeentry.TotpCodeEntryEvent
|
||||
import org.signal.appsettings.totpcodeentry.TotpCodeEntryState.Error
|
||||
import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class TotpCodeEntryViewModelTest {
|
||||
|
||||
companion object {
|
||||
private const val FULL_CODE = "123456"
|
||||
private const val APP_ID = 3L
|
||||
}
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
private val repository: TotpRepository = mockk(relaxed = true)
|
||||
|
||||
@get:Rule
|
||||
val dispatcherRule = CoroutineDispatcherRule(testDispatcher)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
|
||||
coEvery { repository.confirmPendingApp(any()) } returns TotpRepository.ConfirmResult.Success(APP_ID)
|
||||
coEvery { repository.removeTotpApp(any()) } returns TotpRepository.UpdateResult.Success
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-digits are dropped and the code is capped at six digits`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.onEvent(TotpCodeEntryEvent.CodeChanged("12a34 5678"))
|
||||
|
||||
assertThat(viewModel.state.value.code).isEqualTo(FULL_CODE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a partial code can't be submitted`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(TotpCodeEntryEvent.CodeChanged("123"))
|
||||
|
||||
assertThat(viewModel.state.value.canSubmit).isFalse()
|
||||
|
||||
viewModel.onEvent(TotpCodeEntryEvent.DoneClicked)
|
||||
|
||||
assertThat(actions).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a confirmed code sends the user on to name the app the service just created`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
submit(viewModel)
|
||||
|
||||
assertThat(actions.last()).isEqualTo(TotpCodeEntryAction.NavigateToNaming(APP_ID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rejected code is reported and the user stays put to try again`() = runTest(testDispatcher) {
|
||||
coEvery { repository.confirmPendingApp(any()) } returns TotpRepository.ConfirmResult.IncorrectCode
|
||||
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
submit(viewModel)
|
||||
|
||||
assertThat(viewModel.state.value.error).isEqualTo(Error.IncorrectCode)
|
||||
assertThat(viewModel.state.value.submitting).isFalse()
|
||||
assertThat(actions).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an account that filled up mid-setup goes back to setup, which explains the limit`() = runTest(testDispatcher) {
|
||||
coEvery { repository.confirmPendingApp(any()) } returns TotpRepository.ConfirmResult.TooManyApps
|
||||
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
submit(viewModel)
|
||||
|
||||
assertThat(viewModel.state.value.submitting).isFalse()
|
||||
assertThat(actions.last()).isEqualTo(TotpCodeEntryAction.NavigateToSetup)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `typing again clears the last error`() = runTest(testDispatcher) {
|
||||
coEvery { repository.confirmPendingApp(any()) } returns TotpRepository.ConfirmResult.IncorrectCode
|
||||
|
||||
val viewModel = createViewModel()
|
||||
submit(viewModel)
|
||||
|
||||
viewModel.onEvent(TotpCodeEntryEvent.CodeChanged("1"))
|
||||
|
||||
assertThat(viewModel.state.value.error).isEqualTo(Error.None)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateBackClicked leaves the screen`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(TotpCodeEntryEvent.NavigateBackClicked)
|
||||
|
||||
assertThat(actions.last()).isEqualTo(TotpCodeEntryAction.NavigateBack)
|
||||
}
|
||||
|
||||
private fun submit(viewModel: TotpCodeEntryViewModel) {
|
||||
viewModel.onEvent(TotpCodeEntryEvent.CodeChanged(FULL_CODE))
|
||||
viewModel.onEvent(TotpCodeEntryEvent.DoneClicked)
|
||||
}
|
||||
|
||||
private fun createViewModel() = TotpCodeEntryViewModel(repository = repository)
|
||||
|
||||
private fun TestScope.collectActions(actions: Flow<TotpCodeEntryAction>): List<TotpCodeEntryAction> {
|
||||
val collected = mutableListOf<TotpCodeEntryAction>()
|
||||
backgroundScope.launch { actions.toList(collected) }
|
||||
return collected
|
||||
}
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.account.authenticator
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.contains
|
||||
import assertk.assertions.isEmpty
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isTrue
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.signal.appsettings.totpapplist.TotpApp
|
||||
import org.signal.appsettings.totpnameentry.TotpNameEntryAction
|
||||
import org.signal.appsettings.totpnameentry.TotpNameEntryEvent
|
||||
import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class TotpNameEntryViewModelTest {
|
||||
|
||||
companion object {
|
||||
private val EXISTING_APP = TotpApp(id = 7, name = "Twilio Authy", createdAt = 0)
|
||||
|
||||
/** The id the service assigned when the code was confirmed, before the app had a name. */
|
||||
private const val NEW_APP_ID = 1L
|
||||
}
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
private val repository: TotpRepository = mockk(relaxed = true)
|
||||
|
||||
@get:Rule
|
||||
val dispatcherRule = CoroutineDispatcherRule(testDispatcher)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
|
||||
every { repository.getMaxApps() } returns 2
|
||||
coEvery { repository.nameNewTotpApp(any(), any()) } returns TotpRepository.UpdateResult.Success
|
||||
coEvery { repository.renameTotpApp(any(), any()) } returns TotpRepository.UpdateResult.Success
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `naming a new app starts empty and isn't renaming`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel(appId = NEW_APP_ID)
|
||||
|
||||
assertThat(viewModel.state.value.name).isEqualTo("")
|
||||
assertThat(viewModel.state.value.renaming).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `renaming starts from the app's current name`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel(appId = EXISTING_APP.id, renamedApp = EXISTING_APP)
|
||||
|
||||
assertThat(viewModel.state.value.name).isEqualTo(EXISTING_APP.name)
|
||||
assertThat(viewModel.state.value.renaming).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank name can't be submitted`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel(appId = NEW_APP_ID)
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(TotpNameEntryEvent.NameChanged(" "))
|
||||
|
||||
assertThat(viewModel.state.value.canSubmit).isFalse()
|
||||
|
||||
viewModel.onEvent(TotpNameEntryEvent.NextClicked)
|
||||
|
||||
assertThat(actions).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NextClicked names the newly confirmed app and goes back to the list`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel(appId = NEW_APP_ID)
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(TotpNameEntryEvent.NameChanged(" Bitwarden Authenticator "))
|
||||
viewModel.onEvent(TotpNameEntryEvent.NextClicked)
|
||||
|
||||
coVerify { repository.nameNewTotpApp(NEW_APP_ID, "Bitwarden Authenticator") }
|
||||
assertThat(actions).contains(TotpNameEntryAction.ShowTotpAppSetUp)
|
||||
assertThat(actions.last()).isEqualTo(TotpNameEntryAction.NavigateToTotpAppList)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NextClicked renames an existing app and goes back to the list`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel(appId = EXISTING_APP.id, renamedApp = EXISTING_APP)
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(TotpNameEntryEvent.NameChanged("Work Authenticator"))
|
||||
viewModel.onEvent(TotpNameEntryEvent.NextClicked)
|
||||
|
||||
coVerify { repository.renameTotpApp(EXISTING_APP, "Work Authenticator") }
|
||||
assertThat(actions).contains(TotpNameEntryAction.ShowTotpAppRenamed)
|
||||
assertThat(actions.last()).isEqualTo(TotpNameEntryAction.NavigateToTotpAppList)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateBackClicked leaves the screen`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel(appId = NEW_APP_ID)
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(TotpNameEntryEvent.NavigateBackClicked)
|
||||
|
||||
assertThat(actions.last()).isEqualTo(TotpNameEntryAction.NavigateBack)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `entry is capped at the grapheme limit rather than rejected`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel(appId = NEW_APP_ID)
|
||||
|
||||
viewModel.onEvent(TotpNameEntryEvent.NameChanged("a".repeat(TotpRepository.MAX_NAME_LENGTH_GRAPHEMES + 20)))
|
||||
|
||||
assertThat(viewModel.state.value.name).isEqualTo("a".repeat(TotpRepository.MAX_NAME_LENGTH_GRAPHEMES))
|
||||
assertThat(viewModel.state.value.canSubmit).isTrue()
|
||||
}
|
||||
|
||||
/**
|
||||
* The case the byte trim exists for: thirty emoji are inside the grapheme cap and well past the 98 bytes the service
|
||||
* leaves room for, so the grapheme cap alone would let an unencryptable name through.
|
||||
*/
|
||||
@Test
|
||||
fun `entry is also capped in bytes, which the grapheme limit does not guarantee`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel(appId = NEW_APP_ID)
|
||||
|
||||
viewModel.onEvent(TotpNameEntryEvent.NameChanged("\uD83D\uDD10".repeat(TotpRepository.MAX_NAME_LENGTH_GRAPHEMES)))
|
||||
|
||||
val name = viewModel.state.value.name
|
||||
assertThat(name.toByteArray(Charsets.UTF_8).size <= TotpRepository.MAX_NAME_LENGTH_BYTES).isTrue()
|
||||
assertThat(name.isNotEmpty()).isTrue()
|
||||
}
|
||||
|
||||
/** Trimming to a byte budget must not leave half a character behind. */
|
||||
@Test
|
||||
fun `capping in bytes does not split a character`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel(appId = NEW_APP_ID)
|
||||
|
||||
viewModel.onEvent(TotpNameEntryEvent.NameChanged("\uD83D\uDD10".repeat(TotpRepository.MAX_NAME_LENGTH_GRAPHEMES)))
|
||||
|
||||
val name = viewModel.state.value.name
|
||||
assertThat(name.toByteArray(Charsets.UTF_8).toString(Charsets.UTF_8)).isEqualTo(name)
|
||||
assertThat(name.length % 2).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a name that didn't save leaves the user on the screen to try again`() = runTest(testDispatcher) {
|
||||
coEvery { repository.nameNewTotpApp(any(), any()) } returns TotpRepository.UpdateResult.NetworkFailure
|
||||
|
||||
val viewModel = createViewModel(appId = NEW_APP_ID)
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(TotpNameEntryEvent.NameChanged("Aegis"))
|
||||
viewModel.onEvent(TotpNameEntryEvent.NextClicked)
|
||||
|
||||
assertThat(actions.last()).isEqualTo(TotpNameEntryAction.ShowNameNotSaved)
|
||||
assertThat(viewModel.state.value.submitting).isFalse()
|
||||
}
|
||||
|
||||
private fun createViewModel(appId: Long, renamedApp: TotpApp? = null) = TotpNameEntryViewModel(appId = appId, renamedApp = renamedApp, repository = repository)
|
||||
|
||||
private fun TestScope.collectActions(actions: Flow<TotpNameEntryAction>): List<TotpNameEntryAction> {
|
||||
val collected = mutableListOf<TotpNameEntryAction>()
|
||||
backgroundScope.launch { actions.toList(collected) }
|
||||
return collected
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.account.authenticator
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Bundle
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.signal.appsettings.totpapplist.TotpApp
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(application = Application::class)
|
||||
class TotpNavArgsTest {
|
||||
|
||||
companion object {
|
||||
private val APP = TotpApp(id = 7, name = "Aegis", createdAt = 1_700_000_000_000L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no arguments means the screen is acting on a newly paired app`() {
|
||||
assertThat(TotpNavArgs.appId(null)).isNull()
|
||||
assertThat(TotpNavArgs.renamedApp(null)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unset app id reads as null`() {
|
||||
val arguments = Bundle().apply { putLong(TotpNavArgs.ARG_APP_ID, TotpNavArgs.NO_APP_ID) }
|
||||
|
||||
assertThat(TotpNavArgs.appId(arguments)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a newly paired app carries its id but no renamed app`() {
|
||||
val arguments = Bundle().apply { putLong(TotpNavArgs.ARG_APP_ID, 7) }
|
||||
|
||||
assertThat(TotpNavArgs.appId(arguments)).isEqualTo(7L)
|
||||
assertThat(TotpNavArgs.renamedApp(arguments)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rename carries the whole app the list already had`() {
|
||||
val arguments = Bundle().apply { TotpNavArgs.putRenamedApp(this, APP) }
|
||||
|
||||
assertThat(TotpNavArgs.appId(arguments)).isEqualTo(APP.id)
|
||||
assertThat(TotpNavArgs.renamedApp(arguments)).isEqualTo(APP)
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.account.authenticator
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.contains
|
||||
import assertk.assertions.hasSize
|
||||
import assertk.assertions.isEmpty
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import assertk.assertions.startsWith
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.signal.appsettings.totpapplist.TotpApp
|
||||
import org.thoughtcrime.securesms.components.settings.app.account.authenticator.TotpRepository.AppsResult
|
||||
import org.thoughtcrime.securesms.components.settings.app.account.authenticator.TotpRepository.BeginSetupResult
|
||||
import org.thoughtcrime.securesms.components.settings.app.account.authenticator.TotpRepository.ConfirmResult
|
||||
import org.thoughtcrime.securesms.components.settings.app.account.authenticator.TotpRepository.UpdateResult
|
||||
|
||||
class TotpRepositoryTest {
|
||||
|
||||
companion object {
|
||||
private const val NOW = 1_700_000_000_000L
|
||||
private const val ACCOUNT_NAME = "8B4A1F0C"
|
||||
private const val CODE = "123456"
|
||||
}
|
||||
|
||||
private var now = NOW
|
||||
private val api = InMemoryTotpApi()
|
||||
private val repository = TotpRepository(api = api, clock = { now })
|
||||
|
||||
@Test
|
||||
fun `beginSetup returns a link and a key in both the forms the screen needs`() = runTest {
|
||||
val result = repository.beginSetup(ACCOUNT_NAME) as BeginSetupResult.Success
|
||||
|
||||
assertThat(result.setupUri).startsWith("otpauth://totp/Signal:$ACCOUNT_NAME?")
|
||||
assertThat(result.setupUri).contains("secret=${result.clipboardKey}")
|
||||
assertThat(result.displayKey).isEqualTo(result.clipboardKey.chunked(4).joinToString(" "))
|
||||
}
|
||||
|
||||
/** The issuer and the account name have to differ, or an app that shows both renders "Signal: Signal". */
|
||||
@Test
|
||||
fun `beginSetup names the entry after the account, under the issuer`() = runTest {
|
||||
val result = repository.beginSetup(ACCOUNT_NAME) as BeginSetupResult.Success
|
||||
|
||||
assertThat(result.setupUri).startsWith("otpauth://totp/Signal:$ACCOUNT_NAME?")
|
||||
assertThat(result.setupUri).contains("issuer=Signal&")
|
||||
}
|
||||
|
||||
/** Nothing should reach this without an ACI, but a bare issuer beats a label ending in a colon if anything does. */
|
||||
@Test
|
||||
fun `beginSetup falls back to the issuer alone when there's no account name`() = runTest {
|
||||
val result = repository.beginSetup("") as BeginSetupResult.Success
|
||||
|
||||
assertThat(result.setupUri).startsWith("otpauth://totp/Signal?")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `beginSetup treats an account name of nothing but whitespace as no account name`() = runTest {
|
||||
val result = repository.beginSetup(" ") as BeginSetupResult.Success
|
||||
|
||||
assertThat(result.setupUri).startsWith("otpauth://totp/Signal?")
|
||||
}
|
||||
|
||||
/** A space has to become %20 rather than the + form encoding would produce, or apps render it literally. */
|
||||
@Test
|
||||
fun `beginSetup percent-encodes the account name`() = runTest {
|
||||
val result = repository.beginSetup("+1 555") as BeginSetupResult.Success
|
||||
|
||||
assertThat(result.setupUri).startsWith("otpauth://totp/Signal:%2B1%20555?")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setup asks for the parameters every authenticator app supports`() = runTest {
|
||||
val result = repository.beginSetup(ACCOUNT_NAME) as BeginSetupResult.Success
|
||||
|
||||
assertThat(result.setupUri).contains("algorithm=SHA1")
|
||||
assertThat(result.setupUri).contains("digits=6")
|
||||
assertThat(result.setupUri).contains("period=30")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an account at its limit is told rather than handed a key`() = runTest {
|
||||
repeat(TotpApi.MAX_KEYS) { confirmNewApp() }
|
||||
|
||||
assertThat(repository.beginSetup(ACCOUNT_NAME)).isEqualTo(BeginSetupResult.TooManyApps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a confirmed app shows up in the list with the time it was confirmed`() = runTest {
|
||||
val appId = confirmNewApp()
|
||||
|
||||
val apps = (repository.getTotpApps() as AppsResult.Success).apps
|
||||
|
||||
assertThat(apps).hasSize(1)
|
||||
assertThat(apps.first().id).isEqualTo(appId)
|
||||
assertThat(apps.first().createdAt).isEqualTo(NOW)
|
||||
}
|
||||
|
||||
/** The service wants metadata at confirmation time, and the user hasn't been asked for a name yet. */
|
||||
@Test
|
||||
fun `a newly confirmed app starts out with no name`() = runTest {
|
||||
confirmNewApp()
|
||||
|
||||
val apps = (repository.getTotpApps() as AppsResult.Success).apps
|
||||
|
||||
assertThat(apps.first().name).isEqualTo("")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `naming a new app names it`() = runTest {
|
||||
val appId = confirmNewApp()
|
||||
|
||||
assertThat(repository.nameNewTotpApp(appId, "Aegis")).isEqualTo(UpdateResult.Success)
|
||||
|
||||
assertThat(listedApp(appId)?.name).isEqualTo("Aegis")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `renaming keeps the time the app was confirmed`() = runTest {
|
||||
val appId = confirmNewApp()
|
||||
repository.nameNewTotpApp(appId, "Aegis")
|
||||
now += 60_000
|
||||
|
||||
assertThat(repository.renameTotpApp(listedApp(appId)!!, "Aegis on my tablet")).isEqualTo(UpdateResult.Success)
|
||||
|
||||
val app = listedApp(appId)
|
||||
assertThat(app?.name).isEqualTo("Aegis on my tablet")
|
||||
assertThat(app?.createdAt).isEqualTo(NOW)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `renaming an app that isn't there is reported rather than creating one`() = runTest {
|
||||
val gone = TotpApp(id = 7, name = "Aegis", createdAt = NOW)
|
||||
|
||||
assertThat(repository.renameTotpApp(gone, "Aegis on my tablet")).isEqualTo(UpdateResult.AppNotFound)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a removed app leaves the list`() = runTest {
|
||||
val appId = confirmNewApp()
|
||||
|
||||
assertThat(repository.removeTotpApp(appId)).isEqualTo(UpdateResult.Success)
|
||||
assertThat((repository.getTotpApps() as AppsResult.Success).apps).isEmpty()
|
||||
}
|
||||
|
||||
/** The service can't tell a wrong code from a missing pending key, so neither can we. */
|
||||
@Test
|
||||
fun `confirming with nothing pending is just a wrong code`() = runTest {
|
||||
assertThat(repository.confirmPendingApp(CODE)).isEqualTo(ConfirmResult.IncorrectCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a code that isn't a number is reported as a wrong code`() = runTest {
|
||||
repository.beginSetup(ACCOUNT_NAME)
|
||||
|
||||
assertThat(repository.confirmPendingApp("abcdef")).isEqualTo(ConfirmResult.IncorrectCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a code confirms the app`() = runTest {
|
||||
repository.beginSetup(ACCOUNT_NAME)
|
||||
|
||||
assertThat(repository.confirmPendingApp(CODE)).isInstanceOf(ConfirmResult.Success::class)
|
||||
}
|
||||
|
||||
private suspend fun listedApp(appId: Long): TotpApp? {
|
||||
return (repository.getTotpApps() as AppsResult.Success).apps.firstOrNull { it.id == appId }
|
||||
}
|
||||
|
||||
private suspend fun confirmNewApp(): Long {
|
||||
repository.beginSetup(ACCOUNT_NAME)
|
||||
return (repository.confirmPendingApp(CODE) as ConfirmResult.Success).appId
|
||||
}
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.account.authenticator
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isTrue
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.signal.appsettings.totpsetup.TotpSetupAction
|
||||
import org.signal.appsettings.totpsetup.TotpSetupEvent
|
||||
import org.signal.appsettings.totpsetup.TotpSetupState.Dialog
|
||||
import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule
|
||||
import java.util.UUID
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class TotpSetupViewModelTest {
|
||||
|
||||
companion object {
|
||||
private const val ACCOUNT_NAME = "8B4A1F0C"
|
||||
private const val SETUP_URI = "otpauth://totp/Signal:%2B15551234567?secret=MZXW6YTBOI"
|
||||
private const val DISPLAY_KEY = "MZXW 6YTB OI"
|
||||
private const val CLIPBOARD_KEY = "MZXW6YTBOI"
|
||||
|
||||
private val SETUP_SUCCESS = TotpRepository.BeginSetupResult.Success(
|
||||
setupUri = SETUP_URI,
|
||||
displayKey = DISPLAY_KEY,
|
||||
clipboardKey = CLIPBOARD_KEY
|
||||
)
|
||||
}
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
private val repository: TotpRepository = mockk(relaxed = true)
|
||||
|
||||
@get:Rule
|
||||
val dispatcherRule = CoroutineDispatcherRule(testDispatcher)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
|
||||
every { repository.getMaxApps() } returns 2
|
||||
coEvery { repository.beginSetup(any()) } returns SETUP_SUCCESS
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the screen asks for a key as soon as it opens and shows it grouped`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
assertThat(viewModel.state.value.setupKey).isEqualTo(DISPLAY_KEY)
|
||||
assertThat(viewModel.state.value.loading).isFalse()
|
||||
assertThat(viewModel.state.value.canContinue).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OpenTotpAppClicked hands off the setup link rather than the displayed key`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(TotpSetupEvent.OpenTotpAppClicked)
|
||||
|
||||
assertThat(actions.last()).isEqualTo(TotpSetupAction.LaunchTotpApp(SETUP_URI))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CopyKeyClicked copies the unbroken key rather than the grouped one`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(TotpSetupEvent.CopyKeyClicked)
|
||||
|
||||
assertThat(actions.first()).isEqualTo(TotpSetupAction.CopyKeyToClipboard(CLIPBOARD_KEY))
|
||||
assertThat(actions.last()).isEqualTo(TotpSetupAction.ShowKeyCopied)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ContinueClicked moves on to code entry`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(TotpSetupEvent.ContinueClicked)
|
||||
|
||||
assertThat(actions.last()).isEqualTo(TotpSetupAction.NavigateToCodeEntry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NoTotpAppFound reports the failure`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(TotpSetupEvent.NoTotpAppFound)
|
||||
|
||||
assertThat(actions.last()).isEqualTo(TotpSetupAction.ShowNoTotpAppFound)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `backing out leaves the screen`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(TotpSetupEvent.NavigateBackClicked)
|
||||
|
||||
assertThat(actions.last()).isEqualTo(TotpSetupAction.NavigateBack)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an account at its limit is told so rather than shown an empty key`() = runTest(testDispatcher) {
|
||||
coEvery { repository.beginSetup(any()) } returns TotpRepository.BeginSetupResult.TooManyApps
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.MaxAppsReached(2))
|
||||
assertThat(viewModel.state.value.canContinue).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a network failure is reported rather than left spinning`() = runTest(testDispatcher) {
|
||||
coEvery { repository.beginSetup(any()) } returns TotpRepository.BeginSetupResult.NetworkFailure
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.NetworkFailure)
|
||||
assertThat(viewModel.state.value.loading).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dismissing a failure dialog leaves the screen, since there's nothing to retry here`() = runTest(testDispatcher) {
|
||||
coEvery { repository.beginSetup(any()) } returns TotpRepository.BeginSetupResult.NetworkFailure
|
||||
|
||||
val viewModel = createViewModel()
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(TotpSetupEvent.DialogDismissed)
|
||||
|
||||
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None)
|
||||
assertThat(actions.last()).isEqualTo(TotpSetupAction.NavigateBack)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accountNameFor - takes the first hunk of the ACI, uppercased`() {
|
||||
val aci = UUID.fromString("8b4a1f0c-2d3e-4a5b-9c7d-1e2f3a4b5c6d")
|
||||
|
||||
assertThat(TotpSetupViewModel.accountNameFor(aci)).isEqualTo("8B4A1F0C")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accountNameFor - has nothing to say without an ACI, which leaves the issuer as the whole label`() {
|
||||
assertThat(TotpSetupViewModel.accountNameFor(null)).isEqualTo("")
|
||||
}
|
||||
|
||||
private fun createViewModel() = TotpSetupViewModel(repository = repository, accountName = ACCOUNT_NAME)
|
||||
|
||||
private fun TestScope.collectActions(actions: Flow<TotpSetupAction>): List<TotpSetupAction> {
|
||||
val collected = mutableListOf<TotpSetupAction>()
|
||||
backgroundScope.launch { actions.toList(collected) }
|
||||
return collected
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user