test(identity): the preview end to end, with nothing signed, and the upgrade

Phase 7 of docs/npub-sign-in.md.

NpubPreviewRoundTripJvmTest is the nsec round trip with the repository
made real: an in-memory database under the app's own DAOs, because the
assertion this exists for is about what is not in it. An npub is signed in
the way the sign-in screen does it, listed the way startup does, activated
as a read-only identity, and handed to NavigationViewModel: it lands on
UnqueuedProfileSynchronization. The kind 0 the relays would answer with is
indexed through the read-only key pair: ProfileLoaded. And with the real
NotaryViewModel watching the same rows for longer than its key package
delay, nothing was signed -- the only unsigned row for the pubkey is the
placeholder, still at genesis; nothing is queued for a signature; no key
package bundle; no broadcast request; no node.

The contrast that makes those assertions worth having: the same harness
as a signing identity does sign -- the notary makes a key package bundle
within its delay. Without it, "nothing was signed" could be true of a
harness in which nothing can be signed.

Then the upgrade: the nsec of the key held read-only signs in over it
through the same view model, under the same id, leaving one credential
that is now a secret, one listed identity, and one account -- the second
sign-in planted nothing.

The full jvm suites pass: 988 in the app, 159 in the library.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@315e93315a
This commit is contained in:
Kgothatso Ngako
2026-09-12 20:51:16 +02:00
parent 786ac15959
commit c53a6f77cc

View File

@@ -0,0 +1,262 @@
package press.mantra.compose.identity
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.room3.Room
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
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.NostrCredentialManager
import fr.acinq.phoenix.managers.SeedManager
import fr.acinq.phoenix.managers.computePreferencePath
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 fr.acinq.phoenix.utils.preferences.GlobalPrefs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.GENESIS_AT
import press.mantra.compose.database.builder.getRoomDatabase
import press.mantra.compose.database.model.NostrEvent
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.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.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.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
/**
* The preview, end to end, and then the upgrade -- Phase 7 of docs/npub-sign-in.md.
*
* Unlike `NsecRestoreRoundTripJvmTest`, the repository here is real: an in-memory
* database under the same DAOs the app runs on, because the assertion this exists for
* is about what is *not* in it. Sign in with an npub the way the sign-in screen does,
* list identities the way startup does, activate the read-only one, hand
* NavigationViewModel the device: it lands on UnqueuedProfileSynchronization. Index the
* kind 0 the relays would have answered with, through the read-only key pair: it lands
* on ProfileLoaded. And with a real NotaryViewModel running against the same database
* for longer than its key package delay: **nothing was signed** -- the only unsigned row
* for the pubkey is the placeholder, still at genesis; no key package bundle; no
* broadcast request; no node.
*
* Then the nsec of the same key is written the way the sign-in screen writes it: the
* same id, one entry that is now a secret, and one account -- the second sign-in
* planted nothing.
*/
class NpubPreviewRoundTripJvmTest {
private lateinit var storeDir: File
private lateinit var appDir: File
private lateinit var phoenixGlobal: PhoenixGlobal
private lateinit var globalPrefs: GlobalPrefs
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 log = Logger.withTag("NpubPreviewRoundTripJvmTest")
private val privateKey = PrivateKey(Lightning.randomBytes(32))
private val publicKey = privateKey.nostrPublicKeyHex()
private val at = Instant.fromEpochSeconds(1_700_000_000)
@BeforeTest
fun setUp() {
storeDir = Files.createTempDirectory("mantra-preview-store").toFile()
appDir = Files.createTempDirectory("mantra-preview-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() {
scope.cancel()
db.close()
JvmKeyStore.lock()
storeDir.deleteRecursively()
appDir.deleteRecursively()
}
private fun signInViewModel() = SignInToProfileViewModel(
nostrRepository = nostrRepository,
writeNostrKey = { key -> IdentityWriter.writeNostrKey(log, phoenixGlobal, globalPrefs, key, isTorEnabled = false, customElectrumServer = null) },
writeNostrPublicKey = { pubkey -> IdentityWriter.writeNostrPublicKey(log, phoenixGlobal, globalPrefs, pubkey, isTorEnabled = false, customElectrumServer = null) },
writeRecoveryPhrase = { _, _, onError -> onError(press.mantra.compose.ui.view.model.WritingSeedState.Error.CannotLoadSeedMap) },
)
private fun listed() = StoredIdentity.merge(
wallets = SeedManager.loadAndDecryptOrNull(phoenixGlobal) ?: error("seed store unreadable"),
credentials = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) ?: error("credentials unreadable"),
)
/** The kind 0 the relays would answer the sign-in sync with. */
private fun kind0() = NostrEvent(
id = "e".repeat(64),
pubKey = publicKey,
kind = MetadataEvent.KIND,
tags = emptyArray(),
content = """{"name":"Reader"}""",
sig = "0".repeat(128),
createdAt = at,
)
@Test
fun `an npub signed in, listed, activated, routed and watched by the notary, with nothing signed`() = runBlocking<Unit> {
// 1. The sign-in screen's commit, minus the screen: the credential, then the account.
val outcome = signInViewModel().commit(SignInCredential.NostrPublicKey(publicKey))
val signedIn = assertIs<SignInToProfileViewModel.Outcome.SignedIn>(outcome)
// 2. What startup lists.
val stored = assertIs<StoredIdentity.NostrPublic>(listed()[signedIn.id], "the public key is listed under the id the writer returned")
assertEquals(publicKey, stored.nostrPublicKey)
// 3. What the startup screen's NostrPublic branch does: an identity with nothing behind it.
val dataStoreManager = DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain)
val identity = Identity.readOnly(
id = stored.id,
nostrPublicKey = stored.nostrPublicKey,
userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id),
internalPrefs = dataStoreManager.loadInternalPrefsForWallet(stored.id),
)
assertFalse(identity.canSign)
assertNull(identity.nostrPrivateKey)
val activeIdentity = MutableStateFlow<Identity?>(null)
val navigation = NavigationViewModel(
activeIdentityStateFlow = activeIdentity,
initialNavigationUIState = NavigationUIState.Loading("Introducing... Torch"),
nostrRepository = nostrRepository,
scope = scope,
)
// The real notary, watching the same rows the app's would.
NotaryViewModel(
activeIdentityStateFlow = activeIdentity,
nostrRepository = nostrRepository,
chatRepository = chatRepository,
marmotRepository = marmotRepository,
scope = scope,
)
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 }
}
assertIs<NavigationUIState.UnqueuedProfileSynchronization>(first)
// 5. The relays answered; the kind 0 is indexed through the read-only pair: home.
db.nostrDao().storeNostrEvent(
nostrEvent = kind0(),
relayURL = "wss://relay.example",
synchronizationRelayURLs = listOf("wss://relay.example"),
level = 0,
activeKeyPair = identity.toKeyPair(),
)
val second = withTimeout(15_000) {
navigation.navigationUIState.first { it is NavigationUIState.ProfileLoaded }
}
assertEquals(NavigationUIState.ProfileLoaded(publicKey = publicKey), second)
// 6. Longer than the notary's key package delay, and then: nothing was signed.
delay(4_500)
val accounts = nostrRepository.getLocalAccounts().filter { it.unsignedNostrEvent?.pubKey == publicKey }
assertEquals(1, accounts.size, "one account for the pubkey")
assertEquals(GENESIS_AT, accounts.single().unsignedNostrEvent!!.signedAt, "the placeholder is still the placeholder")
assertEquals(emptyList(), nostrRepository.observeUnsignedNostrEvents(publicKey).first(), "nothing is queued for a signature")
assertEquals(emptyList(), db.marmotKeyPackageBundleDao().getAllMarmotKeyPackageBundles(publicKey), "no key package bundle was made")
assertNull(db.broadcastNostrEventRequestDao().observeBroadcastNostrEventRequestsByStatus("pending").first(), "nothing is waiting for a relay")
assertNull(identity.business)
assertTrue(BusinessManager.businessFlow.value.isEmpty(), "no PhoenixBusiness was started for a public key")
}
/**
* The contrast that makes the assertions above worth having: the same harness, as an
* identity that *can* sign, does sign -- the notary makes a key package bundle for it
* within its delay. Without this, "nothing was signed" could be true of a harness in
* which nothing can be signed.
*/
@Test
fun `the same harness as a signing identity does sign`() = runBlocking<Unit> {
val signedIn = assertIs<SignInToProfileViewModel.Outcome.SignedIn>(
signInViewModel().commit(SignInCredential.NostrSecret(privateKey, publicKey))
)
val stored = assertIs<StoredIdentity.NostrSecret>(listed()[signedIn.id])
val dataStoreManager = DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain)
val identity = Identity.signing(
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?>(identity)
NotaryViewModel(
activeIdentityStateFlow = activeIdentity,
nostrRepository = nostrRepository,
chatRepository = chatRepository,
marmotRepository = marmotRepository,
scope = scope,
)
withTimeout(20_000) {
marmotRepository.observeActiveMarmotKeyPackageBundle(publicKey).first { it != null }
}
assertTrue(db.marmotKeyPackageBundleDao().getAllMarmotKeyPackageBundles(publicKey).isNotEmpty())
}
@Test
fun `the nsec of a key held read-only signs in over it, under the same id, planting nothing new`() = runBlocking<Unit> {
val readOnly = assertIs<SignInToProfileViewModel.Outcome.SignedIn>(
signInViewModel().commit(SignInCredential.NostrPublicKey(publicKey))
)
assertIs<StoredIdentity.NostrPublic>(listed()[readOnly.id])
val upgraded = assertIs<SignInToProfileViewModel.Outcome.SignedIn>(
signInViewModel().commit(SignInCredential.NostrSecret(privateKey, publicKey))
)
assertEquals(readOnly.id, upgraded.id, "the identity keeps its id, and with it its preferences and metadata")
val credentials = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal)
assertEquals(mapOf(publicKey to NostrCredential.Secret(privateKey)), credentials, "one entry, now a secret")
val identities = listed()
assertEquals(1, identities.size)
assertIs<StoredIdentity.NostrSecret>(identities[upgraded.id])
assertEquals(1, nostrRepository.getLocalAccounts().count { it.unsignedNostrEvent?.pubKey == publicKey }, "the second sign-in planted nothing")
}
}