test(identity): the restore round trip, and that no node ran
Phase 7 of docs/nsec-sign-in.md. Phases 2 through 6 each pin one seam; this is the seams in a row, with the network faked at the repository. NsecRestoreRoundTripJvmTest unlocks the jvm key store into a temporary directory, writes an nsec the way the sign-in screen's commit does, lists identities the way startup does (both stores, merged) and asserts the key is listed under the id the writer returned, builds the Identity the startup screen's NostrSecret branch builds -- business = null -- and hands NavigationViewModel a device holding the placeholder account the sign-in planted. It must land on UnqueuedProfileSynchronization: fetch, do not create. Then the device's account gains an indexed kind 0, as the rows would when the relays answer, and the same observer must move to ProfileLoaded. And the assertion nothing in the UI would reveal: no Lightning node ran. The identity has no business behind it, and the jvm BusinessManager's flow of running businesses is empty afterwards. An nsec identity that quietly started a node -- Electrum, the LSP peer, the watchers -- would be a bug with no visible symptom, which is why it is asserted here rather than noticed later. The device fake holds its account in a MutableStateFlow rather than a flowOf, so the second step is the observer seeing a change, as it would in the app, not a second call. Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (902 tests) and :composeApp:m3Audit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@647ee815c1
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
package press.mantra.compose.identity
|
||||
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import fr.acinq.bitcoin.PrivateKey
|
||||
import fr.acinq.lightning.Lightning
|
||||
import fr.acinq.phoenix.PhoenixGlobal
|
||||
import fr.acinq.phoenix.jvm.BusinessManager
|
||||
import fr.acinq.phoenix.managers.DataStoreManager
|
||||
import fr.acinq.phoenix.managers.NodeParamsManager
|
||||
import fr.acinq.phoenix.managers.NostrKeyManager
|
||||
import fr.acinq.phoenix.managers.SeedManager
|
||||
import fr.acinq.phoenix.managers.computePreferencePath
|
||||
import fr.acinq.phoenix.security.JvmKeyStore
|
||||
import fr.acinq.phoenix.utils.PlatformContext
|
||||
import fr.acinq.phoenix.utils.preferences.GlobalPrefs
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import press.mantra.compose.database.GENESIS_AT
|
||||
import press.mantra.compose.database.model.Profile
|
||||
import press.mantra.compose.database.model.UnsignedNostrEvent
|
||||
import press.mantra.compose.database.model.intermdiate.LocalAccount
|
||||
import press.mantra.compose.repository.NostrRepository
|
||||
import press.mantra.compose.ui.view.model.NavigationViewModel
|
||||
import press.mantra.compose.ui.view.state.NavigationUIState
|
||||
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.Instant
|
||||
|
||||
/**
|
||||
* The path a user takes, end to end, with the network faked at the repository.
|
||||
*
|
||||
* Unlock the key store into a temporary directory, write an nsec the way the sign-in
|
||||
* screen does, list identities the way startup does, activate the one that came back,
|
||||
* and hand NavigationViewModel a device holding the placeholder account the sign-in
|
||||
* planted: it must land on UnqueuedProfileSynchronization. Then the same account with
|
||||
* a kind 0 indexed: ProfileLoaded. Phases 2 through 5 each pin one seam; this is the
|
||||
* seams in a row.
|
||||
*
|
||||
* And the assertion nothing in the UI would reveal: no Lightning node ran. The jvm
|
||||
* BusinessManager's flow of running businesses is empty afterwards, and the identity
|
||||
* has no business behind it.
|
||||
*/
|
||||
class NsecRestoreRoundTripJvmTest {
|
||||
|
||||
private lateinit var storeDir: File
|
||||
private lateinit var appDir: File
|
||||
private lateinit var phoenixGlobal: PhoenixGlobal
|
||||
private lateinit var globalPrefs: GlobalPrefs
|
||||
|
||||
private val log = Logger.withTag("NsecRestoreRoundTripJvmTest")
|
||||
private val privateKey = PrivateKey(Lightning.randomBytes(32))
|
||||
private val at = Instant.fromEpochSeconds(1_700_000_000)
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
storeDir = Files.createTempDirectory("mantra-round-trip-store").toFile()
|
||||
appDir = Files.createTempDirectory("mantra-round-trip-app").toFile()
|
||||
JvmKeyStore.lock()
|
||||
JvmKeyStore.unlock("correct horse battery staple".toCharArray(), storeDir)
|
||||
phoenixGlobal = PhoenixGlobal(PlatformContext(applicationDir = appDir))
|
||||
globalPrefs = GlobalPrefs(
|
||||
PreferenceDataStoreFactory.createWithPath {
|
||||
computePreferencePath(phoenixGlobal.ctx, "globalprefs.preferences_pb")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
JvmKeyStore.lock()
|
||||
storeDir.deleteRecursively()
|
||||
appDir.deleteRecursively()
|
||||
}
|
||||
|
||||
/** A device whose one account can change under the observer, as rows do. */
|
||||
private class Device(initial: LocalAccount?) : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY {
|
||||
val account = MutableStateFlow(initial)
|
||||
override suspend fun getLocalAccounts(): List<LocalAccount> = listOfNotNull(account.value)
|
||||
override suspend fun observeLocalAccount(publicKey: HexKey): Flow<LocalAccount?> = account
|
||||
}
|
||||
|
||||
private fun placeholder(publicKey: HexKey) = LocalAccount(
|
||||
unsignedNostrEvent = UnsignedNostrEvent(
|
||||
id = 1, pubKey = publicKey, kind = 0, tags = emptyArray(), content = "{}",
|
||||
signedAt = GENESIS_AT, createdAt = at, updatedAt = at, savedAt = at,
|
||||
),
|
||||
nostrEvent = null,
|
||||
profile = null,
|
||||
broadcastNostrEventRequest = null,
|
||||
broadcastNostrEventReceipt = null,
|
||||
synchronizeNostrEventRequests = emptyList(),
|
||||
)
|
||||
|
||||
private fun synced(publicKey: HexKey) = placeholder(publicKey).copy(
|
||||
profile = Profile(publicKey = publicKey, nostrEventId = "e".repeat(64), createdAt = at, updatedAt = at, savedAt = at),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `an nsec written, listed, activated and routed, with no node started`() = runBlocking<Unit> {
|
||||
// 1. The sign-in screen's commit, minus the screen.
|
||||
val written = assertIs<IdentityWriter.WriteNostrKeyResult.Written>(
|
||||
IdentityWriter.writeNostrKey(log, phoenixGlobal, globalPrefs, privateKey, isTorEnabled = false, customElectrumServer = null)
|
||||
)
|
||||
|
||||
// 2. What startup lists: both stores, merged.
|
||||
val identities = StoredIdentity.merge(
|
||||
wallets = SeedManager.loadAndDecryptOrNull(phoenixGlobal) ?: error("seed store unreadable"),
|
||||
nostrKeys = NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal) ?: error("key store unreadable"),
|
||||
)
|
||||
val stored = assertIs<StoredIdentity.NostrSecret>(identities[written.id], "the imported key is listed under the id the writer returned")
|
||||
|
||||
// 3. What the startup screen's NostrSecret branch does: an identity, no node.
|
||||
val dataStoreManager = DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain)
|
||||
val identity = Identity(
|
||||
id = stored.id,
|
||||
kind = IdentityKind.NostrSecret,
|
||||
nostrPrivateKey = stored.privateKey,
|
||||
userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id),
|
||||
internalPrefs = dataStoreManager.loadInternalPrefsForWallet(stored.id),
|
||||
business = null,
|
||||
)
|
||||
val activeIdentity = MutableStateFlow<Identity?>(null)
|
||||
val device = Device(placeholder(identity.nostrPublicKey))
|
||||
val scope = CoroutineScope(Job())
|
||||
val navigation = NavigationViewModel(
|
||||
activeIdentityStateFlow = activeIdentity,
|
||||
initialNavigationUIState = NavigationUIState.Loading("Introducing... Torch"),
|
||||
nostrRepository = device,
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
try {
|
||||
activeIdentity.value = identity
|
||||
|
||||
// 4. The machine takes it from the placeholder: fetch, do not create.
|
||||
val first = withTimeout(15_000) {
|
||||
navigation.navigationUIState.first { it !is NavigationUIState.Loading }
|
||||
}
|
||||
assertEquals(
|
||||
NavigationUIState.UnqueuedProfileSynchronization(unsignedNostrEvent = placeholder(identity.nostrPublicKey).unsignedNostrEvent!!),
|
||||
first,
|
||||
)
|
||||
|
||||
// 5. The relays answered, the kind 0 was indexed: home.
|
||||
device.account.value = synced(identity.nostrPublicKey)
|
||||
val second = withTimeout(15_000) {
|
||||
navigation.navigationUIState.first { it is NavigationUIState.ProfileLoaded }
|
||||
}
|
||||
assertEquals(NavigationUIState.ProfileLoaded(publicKey = identity.nostrPublicKey), second)
|
||||
} finally {
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
// 6. Nothing Lightning ran.
|
||||
assertNull(identity.business)
|
||||
assertTrue(BusinessManager.businessFlow.value.isEmpty(), "no PhoenixBusiness was started for a bare key")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user