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 <noreply@anthropic.com> Pulled-From: curated/curated@88577bb5c3
This commit is contained in:
@@ -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<MantraDatabase>())
|
||||
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<WalletId>()
|
||||
|
||||
@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<WalletId, StoredIdentity> {
|
||||
val done = CompletableDeferred<Unit>()
|
||||
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<WalletId>()
|
||||
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<Unit> {
|
||||
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<StoredIdentity.Mnemonic>(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<SignInToProfileViewModel.Outcome.SignedIn>(
|
||||
signIn.commit(SignInCredential.NostrPublicKey(keyB.nostrPublicKeyHex()))
|
||||
)
|
||||
val storedB = assertIs<StoredIdentity.NostrPublic>(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<NavigationUIState.UnqueuedProfileSynchronization>(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<StoredIdentity.NostrSecret>(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<StoredIdentity.Mnemonic>(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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user