fix(desktop): address code review findings for NIP-46 bunker login

- Add bounds check in fromBunkerUri for missing query params (P2-1)
- Preserve bunker keys on transient force-logout (P2-3)
- Replace debug printlns with DebugConfig.log() (P2-4)
- Replace unused Unstable with Disconnected state (P2-5)
- Move validateBunkerUri to account package (P3-9)
- Fix Compose state update from IO thread (P3-11)
- Add disconnectNip46Client() to onDispose cleanup (P3-12)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-06 13:19:49 +02:00
parent 12c91aae3d
commit 3a1e38a2f2
24 changed files with 1101 additions and 324 deletions
@@ -111,30 +111,10 @@ class AccountManagerLoadAccountTest {
storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS)
} returns null
val result = manager.loadSavedAccount(client = com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient)
val result = manager.loadSavedAccount()
assertTrue(result.isFailure)
}
@Test
fun loadSavedAccountBunkerNoClientFallsBackToInternal() =
runTest {
val validHex = "a".repeat(64)
val keyPair = KeyPair()
val npub = keyPair.pubKey.toNpub()
val privKeyHex = keyPair.privKey!!.toHexKey()
File(amethystDir, "last_account.txt").writeText(npub)
File(amethystDir, "bunker_uri.txt").writeText(
"bunker://$validHex?relay=wss://r.com",
)
coEvery { storage.getPrivateKey(npub) } returns privKeyHex
// client=null → bunkerUri is found but ignored, falls back to internal
val result = manager.loadSavedAccount(client = null)
assertTrue(result.isSuccess)
assertIs<SignerType.Internal>(result.getOrThrow().signerType)
}
@Test
fun loadSavedAccountBunkerSuccess() =
runTest {
@@ -152,10 +132,7 @@ class AccountManagerLoadAccountTest {
storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS)
} returns ephemeralPrivKeyHex
val result =
manager.loadSavedAccount(
client = com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient,
)
val result = manager.loadSavedAccount()
assertTrue(result.isSuccess)
val state = result.getOrThrow()
assertIs<SignerType.Remote>(state.signerType)
@@ -0,0 +1,195 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.account
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import java.io.File
import kotlin.io.path.createTempDirectory
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* Tests verifying NIP-46 relay isolation — the dedicated NIP-46 client
* is created internally by AccountManager, not passed from callers.
*
* These tests reproduce the root cause of Bug 2 (shared client relay
* contamination) by verifying the API no longer accepts an external client.
*/
class AccountManagerNip46IsolationTest {
private lateinit var storage: SecureKeyStorage
private lateinit var tempDir: File
private lateinit var amethystDir: File
private lateinit var manager: AccountManager
@BeforeTest
fun setup() {
storage = mockk(relaxed = true)
tempDir = createTempDirectory("acctmgr-nip46-iso-test").toFile()
amethystDir = File(tempDir, ".amethyst")
amethystDir.mkdirs()
manager = AccountManager(storage, tempDir)
}
@AfterTest
fun teardown() {
tempDir.deleteRecursively()
}
@Test
fun bunkerLoadCreatesRemoteSignerInternally() =
runTest {
val validHex = "a".repeat(64)
val ephemeralKeyPair = KeyPair()
File(amethystDir, "bunker_uri.txt").writeText(
"bunker://$validHex?relay=wss://relay.nsec.app",
)
File(amethystDir, "last_account.txt").writeText(
ephemeralKeyPair.pubKey.toNpub(),
)
coEvery {
storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS)
} returns ephemeralKeyPair.privKey!!.toHexKey()
val result = manager.loadSavedAccount()
assertTrue(result.isSuccess)
val state = manager.currentAccount()
assertNotNull(state)
assertIs<SignerType.Remote>(state.signerType)
assertIs<NostrSignerRemote>(state.signer)
}
@Test
fun loginWithBunkerDoesNotRequireExternalClient() =
runTest {
// loginWithBunker() compiles with just bunkerUri — no client param
try {
manager.loginWithBunker(
"bunker://${"a".repeat(64)}?relay=wss://relay.nsec.app",
)
} catch (e: Exception) {
// Expected — no real relay. Verify it's a connection error, not API error.
assertTrue(
e.message?.contains("Connection") == true ||
e.message?.contains("timed out") == true ||
e.message?.contains("failed") == true,
"Unexpected error type: ${e.message}",
)
}
}
@Test
fun loginWithNostrConnectDoesNotRequireExternalClient() =
runTest {
var generatedUri: String? = null
try {
manager.loginWithNostrConnect { uri -> generatedUri = uri }
} catch (e: Exception) {
// Expected — no signer scanning the QR
assertNotNull(e.message)
}
// URI should have been generated even if connection timed out
assertNotNull(generatedUri, "nostrconnect URI was never generated")
assertTrue(
generatedUri!!.startsWith("nostrconnect://"),
"URI format wrong: $generatedUri",
)
}
@Test
fun logoutResetsStateAfterBunkerLoad() =
runTest {
val validHex = "a".repeat(64)
val ephemeralKeyPair = KeyPair()
File(amethystDir, "bunker_uri.txt").writeText(
"bunker://$validHex?relay=wss://relay.nsec.app",
)
File(amethystDir, "last_account.txt").writeText(
ephemeralKeyPair.pubKey.toNpub(),
)
coEvery {
storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS)
} returns ephemeralKeyPair.privKey!!.toHexKey()
manager.loadSavedAccount()
assertIs<AccountState.LoggedIn>(manager.accountState.value)
manager.logout()
assertIs<AccountState.LoggedOut>(manager.accountState.value)
assertIs<SignerConnectionState.NotRemote>(
manager.signerConnectionState.value,
)
}
@Test
fun logoutThenReloadCreatesFreshNip46Client() =
runTest {
val validHex = "a".repeat(64)
val ephemeralKeyPair = KeyPair()
val bunkerUri = "bunker://$validHex?relay=wss://relay.nsec.app"
fun writeBunkerFiles() {
File(amethystDir, "bunker_uri.txt").writeText(bunkerUri)
File(amethystDir, "last_account.txt").writeText(
ephemeralKeyPair.pubKey.toNpub(),
)
}
coEvery {
storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS)
} returns ephemeralKeyPair.privKey!!.toHexKey()
// First load
writeBunkerFiles()
manager.loadSavedAccount()
val firstSigner = manager.currentAccount()?.signer
assertNotNull(firstSigner)
// Logout — disconnects NIP-46 client
manager.logout()
assertIs<AccountState.LoggedOut>(manager.accountState.value)
// Second load — should create a fresh NIP-46 client
writeBunkerFiles()
manager.loadSavedAccount()
val secondSigner = manager.currentAccount()?.signer
assertNotNull(secondSigner)
// Both should be remote signers — different instances
assertIs<NostrSignerRemote>(firstSigner)
assertIs<NostrSignerRemote>(secondSigner)
assertTrue(
firstSigner !== secondSigner,
"Second load should create a new signer instance",
)
}
}
@@ -0,0 +1,132 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.account
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import java.io.File
import kotlin.io.path.createTempDirectory
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertIs
import kotlin.test.assertTrue
/**
* Tests verifying AccountManager state transitions during NIP-46 flows.
* Reproduces Bug 1 UX issue — user should see ConnectingRelays while
* the dedicated NIP-46 client is being set up.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class AccountManagerStateTransitionTest {
private lateinit var storage: SecureKeyStorage
private lateinit var tempDir: File
private lateinit var amethystDir: File
private lateinit var manager: AccountManager
@BeforeTest
fun setup() {
storage = mockk(relaxed = true)
tempDir = createTempDirectory("acctmgr-state-test").toFile()
amethystDir = File(tempDir, ".amethyst")
amethystDir.mkdirs()
manager = AccountManager(storage, tempDir)
}
@AfterTest
fun teardown() {
tempDir.deleteRecursively()
}
@Test
fun connectingRelaysThenLoggedInTransition() =
runTest {
val states = mutableListOf<AccountState>()
val collector =
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
manager.accountState.toList(states)
}
// Simulate Main.kt bunker restore flow
manager.setConnectingRelays()
// Then load an internal account (simulating successful load)
val keyPair = KeyPair()
val npub = keyPair.pubKey.toNpub()
File(amethystDir, "last_account.txt").writeText(npub)
coEvery { storage.getPrivateKey(npub) } returns keyPair.privKey!!.toHexKey()
manager.loadSavedAccount()
advanceUntilIdle()
// Should see: LoggedOut → ConnectingRelays → LoggedIn
assertTrue(
states.size >= 3,
"Expected at least 3 state transitions, got ${states.size}: $states",
)
assertIs<AccountState.LoggedOut>(states[0])
assertIs<AccountState.ConnectingRelays>(states[1])
assertIs<AccountState.LoggedIn>(states[2])
collector.cancel()
}
@Test
fun connectingRelaysThenFailureFallsBackToLoggedOut() =
runTest {
val states = mutableListOf<AccountState>()
val collector =
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
manager.accountState.toList(states)
}
// Simulate bunker restore that fails
manager.setConnectingRelays()
// loadSavedAccount fails → caller does logout
val result = manager.loadSavedAccount() // no saved account
assertTrue(result.isFailure)
manager.logout()
advanceUntilIdle()
// Should see: LoggedOut → ConnectingRelays → LoggedOut
assertTrue(
states.size >= 3,
"Expected at least 3 state transitions, got ${states.size}: $states",
)
assertIs<AccountState.LoggedOut>(states[0])
assertIs<AccountState.ConnectingRelays>(states[1])
assertIs<AccountState.LoggedOut>(states[2])
collector.cancel()
}
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.desktop.account
import com.vitorpamplona.amethyst.desktop.ui.auth.validateBunkerUri
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull