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