From 72b9d3d2ed78531bc91d350007e4b81e045d426f Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 01:57:05 +0200 Subject: [PATCH] test(identity): several profiles, both ways, and a boot Phase 8 of docs/multiple-profiles.md: the seams of Phases 1 through 7 in a row, through the real view models on an in-memory database with a real key store. A is made from a phrase through the create view model and the seed writer, the way Landing makes the first profile: both files written, and listed once as a profile with a wallet attached, under the wallet's id, with the credential's key. A is opened with a node behind it -- a PhoenixBusiness that was never started, which is enough to make it one the switch has to stop. B's npub is signed in from inside through the sign-in view model; the switch stops A's node, B is open and read-only, the machine routes it from its placeholder, and nothing was ever started for a public key. A kind 1 queued for A while B is open waits three seconds unsigned; back to A, and it goes out, with nothing more stopped since B had no node. C is created from inside through the bare-key writer: a key, not a second wallet, routed, with no node, and the switch to it stops A's node again. C and B are forgotten through ForgetIdentity with the view model's writers, the default is cleared and the selector reset: A is listed alone, its credential is the only entry left in the file, B's and C's account rows went with them, and A itself cannot be forgotten because a wallet is attached. Then a fresh view model over the same directory, which is what a cold boot is: the repair finds nothing to do, A is listed from its credential rather than derived, and StartupChoice opens it without asking. A navigation state is matched to a profile by whichever step it is at -- UnsignedProfile, UnqueuedProfileSynchronization, UnannouncedProfile through the event the broadcast request names, ProfileLoaded -- because with the notary running, A's account moves from unsigned to announced-pending while the test looks, and what the test asserts is whose account it is, not which step it reached. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@88577bb5c3082e1765d2d33ada1271a947f245f1 --- .../MultipleProfilesRoundTripJvmTest.kt | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/MultipleProfilesRoundTripJvmTest.kt diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/MultipleProfilesRoundTripJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/MultipleProfilesRoundTripJvmTest.kt new file mode 100644 index 00000000..1669f3ba --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/MultipleProfilesRoundTripJvmTest.kt @@ -0,0 +1,282 @@ +package press.mantra.compose.identity + +import androidx.room3.Room +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.lightning.Lightning +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.data.EmptyWalletId +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.jvm.BusinessManager +import fr.acinq.phoenix.managers.DataStoreManager +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.managers.NostrCredentialManager +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.security.JvmKeyStore +import fr.acinq.phoenix.security.NostrCredential +import fr.acinq.phoenix.utils.PlatformContext +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.UnsignedNostrEvent +import press.mantra.compose.database.repository.DatabaseChatRepository +import press.mantra.compose.database.repository.DatabaseMarmotRepository +import press.mantra.compose.database.repository.DatabaseNostrRepository +import press.mantra.compose.extensions.getGlobalPrefs +import press.mantra.compose.ui.view.model.CreateProfileViewModel +import press.mantra.compose.ui.view.model.NavigationViewModel +import press.mantra.compose.ui.view.model.NotaryViewModel +import press.mantra.compose.ui.view.model.SignInToProfileViewModel +import press.mantra.compose.ui.view.model.SovereignWalletViewModel +import press.mantra.compose.ui.view.model.WritingSeedState +import press.mantra.compose.ui.view.model.bareKeyProfileWriter +import press.mantra.compose.ui.view.model.seedProfileWriter +import press.mantra.compose.ui.view.state.CreateProfileUIState +import press.mantra.compose.ui.view.state.NavigationUIState +import press.mantra.compose.ui.view.state.StartupChoice +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Clock + +/** + * Several profiles, both ways, and a boot -- Phase 8 of docs/multiple-profiles.md, the + * seams of Phases 1 through 7 in a row, through the real view models on an in-memory + * database with a real key store. + * + * A is made from a phrase, the way Landing makes the first profile: both files written, + * listed once with a wallet attached. A is opened with a node behind it -- a + * PhoenixBusiness that was never started, which is enough to make it one the switch + * has to stop. B's npub is signed in from inside; the switch stops A's node, B is open + * and read-only, and the machine routes it from its placeholder. A kind 1 queued for A + * while B is open waits, and goes out when A is back. C is created from inside as a bare + * key, with no node. B and C are forgotten; one profile remains, the default is cleared, + * and a fresh view model over the same directory -- what a cold boot is -- lists A alone, + * with its credential still the only entry in the file, and opens it without asking. + */ +class MultipleProfilesRoundTripJvmTest { + + private lateinit var storeDir: File + private lateinit var appDir: File + private lateinit var phoenixGlobal: PhoenixGlobal + + private val db: MantraDatabase = getRoomDatabase(Room.inMemoryDatabaseBuilder()) + private val scope = CoroutineScope(Job() + Dispatchers.IO) + private val nostrRepository = DatabaseNostrRepository(db, scope) + private val chatRepository = DatabaseChatRepository(db, scope) + private val marmotRepository = DatabaseMarmotRepository(db, scope) + + private val keyB = PrivateKey(Lightning.randomBytes(32)) + private val stopped = mutableListOf() + + @BeforeTest + fun setUp() { + storeDir = Files.createTempDirectory("mantra-profiles-store").toFile() + appDir = Files.createTempDirectory("mantra-profiles-app").toFile() + JvmKeyStore.lock() + JvmKeyStore.unlock("correct horse battery staple".toCharArray(), storeDir) + // No GlobalPrefs of the test's own -- see AddProfileFromInsideJvmTest.setUp. + phoenixGlobal = PhoenixGlobal(PlatformContext(applicationDir = appDir)) + } + + @AfterTest + fun tearDown() { + scope.cancel() + db.close() + JvmKeyStore.lock() + storeDir.deleteRecursively() + appDir.deleteRecursively() + } + + private val dataStoreManager by lazy { DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain) } + + private suspend fun listed(sovereign: SovereignWalletViewModel, size: Int): Map { + val done = CompletableDeferred() + sovereign.listIdentities { done.complete(Unit) } + withTimeout(15_000) { done.await() } + return sovereign.availableIdentities.first { it.size == size } + } + + /** What the create screen does with a writer, minus the screen. */ + private suspend fun create(writer: NewProfileWriter, name: String): WalletId { + val created = CompletableDeferred() + val viewModel = CreateProfileViewModel( + initialCreateProfileUIState = CreateProfileUIState.ConfirmInput(name = name, bio = "round trip"), + nostrRepository = nostrRepository, + marmotRepository = marmotRepository, + ) + viewModel.createProfileFormState.nameField.textFieldState.edit { append(name) } + viewModel.createProfileFormState.biographyField.textFieldState.edit { append("round trip") } + viewModel.createAccount(writer) { created.complete(it) } + return withTimeout(15_000) { created.await() } + } + + /** What startup does for each kind, minus the node: a wallet-attached profile gets a business it can stop. */ + private fun activated(stored: StoredIdentity): Identity = when (stored) { + is StoredIdentity.Mnemonic -> Identity.signing( + id = stored.id, kind = IdentityKind.Mnemonic, nostrPrivateKey = stored.privateKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(stored.id), + business = PhoenixBusiness(phoenixGlobal), + ) + is StoredIdentity.NostrSecret -> Identity.signing( + id = stored.id, kind = IdentityKind.NostrSecret, nostrPrivateKey = stored.privateKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(stored.id), + business = null, + ) + is StoredIdentity.NostrPublic -> Identity.readOnly( + id = stored.id, nostrPublicKey = stored.nostrPublicKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(stored.id), + ) + } + + /** Whether a navigation state is about [publicKey]'s account, whichever step it is at. */ + private suspend fun NavigationUIState.isFor(publicKey: String): Boolean = when (this) { + is NavigationUIState.UnsignedProfile -> unsignedNostrEvent.pubKey == publicKey + is NavigationUIState.UnqueuedProfileSynchronization -> unsignedNostrEvent.pubKey == publicKey + is NavigationUIState.UnsyncedProfile -> unsignedNostrEvent.pubKey == publicKey + is NavigationUIState.UnannouncedProfile -> db.nostrEventDao().getNostrEventById(broadcastNostrEventRequest.nostrEventId)?.pubKey == publicKey + is NavigationUIState.ProfileLoaded -> this.publicKey == publicKey + else -> false + } + + private suspend fun NavigationViewModel.awaitRoutedTo(publicKey: String): NavigationUIState = withTimeout(20_000) { + navigationUIState.first { it.isFor(publicKey) } + } + + private suspend fun queueNote(publicKey: String, content: String) { + val now = Clock.System.now() + db.unsignedNostrEventDao().upsert( + UnsignedNostrEvent(pubKey = publicKey, kind = 1, tags = emptyArray(), content = content, createdAt = now, updatedAt = now, savedAt = now) + ) + } + + private suspend fun notesQueuedFor(publicKey: String) = + nostrRepository.observeUnsignedNostrEvents(publicKey).first().filter { it.kind == 1 } + + @Test + fun `several profiles, both ways, and a boot`() = runBlocking { + val sovereign = SovereignWalletViewModel(phoenixGlobal, stopBusiness = { stopped += it }) + val navigation = NavigationViewModel( + activeIdentityStateFlow = sovereign.activeIdentity, + initialNavigationUIState = NavigationUIState.Loading(""), + nostrRepository = nostrRepository, + scope = scope, + ) + NotaryViewModel( + activeIdentityStateFlow = sovereign.activeIdentity, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + marmotRepository = marmotRepository, + scope = scope, + ) + val signIn = SignInToProfileViewModel( + nostrRepository = nostrRepository, + writeNostrKey = { key -> sovereign.writeNostrKey(key) }, + writeNostrPublicKey = { pubkey -> sovereign.writeNostrPublicKey(pubkey) }, + writeRecoveryPhrase = { _, _, onError -> onError(WritingSeedState.Error.CannotLoadSeedMap) }, + ) + + // A, from a phrase: both files written, listed once with a wallet attached. + val idA = create(sovereign.seedProfileWriter(), "Ada") + val storedA = assertIs(listed(sovereign, 1)[idA], "a profile with a wallet attached, under the wallet's id") + val credentials = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) + assertEquals(mapOf(storedA.nostrPublicKey to NostrCredential.Secret(storedA.privateKey)), credentials, "the seed's key is a credential") + sovereign.setActiveIdentity(activated(storedA)) + navigation.awaitRoutedTo(storedA.nostrPublicKey) + + // B, an npub, signed in from inside: the switch stops A's node; B is open, read-only, and routed. + val signedInB = assertIs( + signIn.commit(SignInCredential.NostrPublicKey(keyB.nostrPublicKeyHex())) + ) + val storedB = assertIs(listed(sovereign, 2)[signedInB.id]) + sovereign.switchToIdentity(signedInB.id) + withTimeout(5_000) { while (stopped.isEmpty()) delay(20) } + assertEquals(listOf(idA), stopped, "leaving A stopped A's node") + sovereign.setActiveIdentity(activated(storedB)) + assertIs(navigation.awaitRoutedTo(storedB.nostrPublicKey)) + assertTrue(BusinessManager.businessFlow.value.isEmpty(), "nothing was ever started for a public key") + + // What A queues while B is open waits for A. + queueNote(storedA.nostrPublicKey, "while B is open") + delay(3_000) + assertEquals(1, notesQueuedFor(storedA.nostrPublicKey).size, "not B's to sign") + + // Back to A: routed, and the note goes out. B had no node, so nothing more was stopped. + sovereign.switchToIdentity(idA) + sovereign.setActiveIdentity(activated(storedA)) + navigation.awaitRoutedTo(storedA.nostrPublicKey) + withTimeout(15_000) { nostrRepository.observeUnsignedNostrEvents(storedA.nostrPublicKey).first { rows -> rows.none { it.kind == 1 } } } + assertEquals(listOf(idA), stopped) + + // C, created from inside: a bare key, no node, and the switch stops A's node again. + val idC = create(sovereign.bareKeyProfileWriter(), "Cy") + val storedC = assertIs(listed(sovereign, 3)[idC], "a key, not a second wallet") + sovereign.switchToIdentity(idC) + withTimeout(5_000) { while (stopped.size < 2) delay(20) } + assertEquals(listOf(idA, idA), stopped) + sovereign.setActiveIdentity(activated(storedC)) + navigation.awaitRoutedTo(storedC.nostrPublicKey) + assertTrue(BusinessManager.businessFlow.value.isEmpty(), "no node for a bare key") + + // Forget C, the one that is open, then B: the forget tail, minus the navigation. + for (leaving in listOf(activated(storedC), activated(storedB))) { + val outcome = ForgetIdentity.forget( + identity = leaving, + nostrRepository = nostrRepository, + forgetNostrCredential = { sovereign.forgetNostrCredential(it) }, + hideIdentityMetadata = { sovereign.hideIdentityMetadata(it) }, + ) + assertEquals(ForgetIdentity.Outcome.Forgotten, outcome) + } + sovereign.forgetDefaultIdentity() + sovereign.resetToSelector() + assertNull(sovereign.activeIdentity.value) + assertEquals(setOf(idA), listed(sovereign, 1).keys) + assertEquals( + mapOf(storedA.nostrPublicKey to NostrCredential.Secret(storedA.privateKey)), + NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal), + "A's credential is the only entry left", + ) + assertEquals(1, nostrRepository.getLocalAccounts().size, "B's and C's account rows went with them") + val prefs = getGlobalPrefs(phoenixGlobal) + withTimeout(5_000) { prefs.getDefaultWallet.first { it == EmptyWalletId } } + + // And A cannot be forgotten: a wallet is attached. + assertEquals( + ForgetIdentity.Outcome.WalletAttached, + ForgetIdentity.forget(activated(storedA), nostrRepository, { sovereign.forgetNostrCredential(it) }, { sovereign.hideIdentityMetadata(it) }), + ) + + // A cold boot: a fresh view model over the same directory. The repair finds nothing + // to do, A is listed alone, and startup opens it without asking. + val booted = SovereignWalletViewModel(phoenixGlobal, stopBusiness = { stopped += it }) + val identities = withTimeout(15_000) { booted.availableIdentities.first { it.isNotEmpty() } } + assertEquals(setOf(idA), identities.keys) + assertEquals(storedA.privateKey, assertIs(identities[idA]).privateKey, "listed from the credential, not derived") + val opens = StartupChoice.resolve( + force = null, + desired = null, + startImmediately = true, + identities = identities, + default = prefs.getDefaultWallet.first() as? WalletId, + ) + assertEquals(idA, opens?.id) + } +}