Add basic TOTP support with mocked creation.

This commit is contained in:
Greyson Parrelli
2026-09-02 16:11:32 -03:00
committed by Alex Hart
parent 6a032e4f27
commit 62897a309c
163 changed files with 6688 additions and 5119 deletions
@@ -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
@@ -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
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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)
}
}
@@ -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
}
}
@@ -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
}
@@ -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
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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)
}
}
@@ -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
}
}
@@ -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
}
}