diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NpubPreviewRoundTripJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NpubPreviewRoundTripJvmTest.kt new file mode 100644 index 00000000..0a389ce0 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NpubPreviewRoundTripJvmTest.kt @@ -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() + ) + 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 { + // 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(outcome) + + // 2. What startup lists. + val stored = assertIs(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(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(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 { + val signedIn = assertIs( + signInViewModel().commit(SignInCredential.NostrSecret(privateKey, publicKey)) + ) + val stored = assertIs(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) + 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 { + val readOnly = assertIs( + signInViewModel().commit(SignInCredential.NostrPublicKey(publicKey)) + ) + assertIs(listed()[readOnly.id]) + + val upgraded = assertIs( + 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(identities[upgraded.id]) + assertEquals(1, nostrRepository.getLocalAccounts().count { it.unsignedNostrEvent?.pubKey == publicKey }, "the second sign-in planted nothing") + } +}