test: add NIP-46 test suite for desktop, quartz, and fix pre-existing chess test errors

Add comprehensive test coverage for NIP-46 bunker login across quartz and desktopApp:

Quartz (4 files, 48 tests):
- ResponseParserTest: all 7 response parsers (success/error/unexpected)
- FromBunkerUriTest: URI parsing, validation, edge cases
- ConvertExceptionsTest: SignerResult→Exception mapping
- NostrConnectEventTest: canDecrypt, talkingWith, verifiedRecipientPubKey

Desktop (6 files, 45 tests):
- BunkerUriUtilsTest: validateBunkerUri + stripBunkerSecret
- AccountManagerKeyLoginTest: nsec/npub/invalid login, save, generate
- AccountManagerLogoutTest: logout, forceLogout, state transitions
- AccountManagerLoadAccountTest: internal/bunker/missing-key scenarios
- AccountManagerBunkerLoginTest: hasBunkerAccount, setConnectingRelays
- AccountManagerHeartbeatTest: start/stop, no-crash with internal signer

Production changes:
- AccountManager: constructor private→internal, add homeDir param for test injection, extract stripBunkerSecret to internal top-level, constants internal
- desktopApp/build.gradle.kts: add mockk test dependency

Fix pre-existing chess test compilation errors:
- ChessStateReconstructorTest: add missing jester subpackage imports
- ChessGameEventTest: altText()→alt() + add nip31Alts import

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-05 08:31:22 +02:00
parent 6c24c52104
commit 1821b9ff71
11 changed files with 1111 additions and 19 deletions
@@ -0,0 +1,152 @@
/*
* 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.quartz.nip46RemoteSigner
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Tests for NostrConnectEvent pure logic (canDecrypt, talkingWith, verifiedRecipientPubKey).
*
* Crypto-dependent tests (create + decrypt roundtrip) live in androidDeviceTest/Nip46Test.kt
* because NIP-44 encryption requires lazysodium which is only available on Android/device tests.
*/
class NostrConnectEventTest {
private val senderKey = NostrSignerInternal(KeyPair())
private val recipientKey = NostrSignerInternal(KeyPair())
private val thirdPartyKey = NostrSignerInternal(KeyPair())
/** Construct a NostrConnectEvent with known pubKey and p-tag, no real crypto needed */
private fun buildEvent(
authorPubKey: String,
recipientPubKey: String,
) = NostrConnectEvent(
id = "a".repeat(64),
pubKey = authorPubKey,
createdAt = 1L,
tags = arrayOf(arrayOf("p", recipientPubKey)),
content = "encrypted-placeholder",
sig = "b".repeat(128),
)
// --- canDecrypt ---
@Test
fun canDecryptAsSender() {
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
assertTrue(event.canDecrypt(senderKey))
}
@Test
fun canDecryptAsRecipient() {
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
assertTrue(event.canDecrypt(recipientKey))
}
@Test
fun canDecryptUnauthorizedReturnsFalse() {
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
assertFalse(event.canDecrypt(thirdPartyKey))
}
// --- talkingWith ---
@Test
fun talkingWithAsSenderReturnsRecipient() {
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
assertEquals(recipientKey.pubKey, event.talkingWith(senderKey.pubKey))
}
@Test
fun talkingWithAsRecipientReturnsSender() {
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
assertEquals(senderKey.pubKey, event.talkingWith(recipientKey.pubKey))
}
@Test
fun talkingWithUnknownReturnsSender() {
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
// When oneSideHex doesn't match pubKey, returns pubKey (sender)
assertEquals(senderKey.pubKey, event.talkingWith(thirdPartyKey.pubKey))
}
// --- verifiedRecipientPubKey ---
@Test
fun verifiedRecipientPubKeyWithValidHex() {
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
assertEquals(recipientKey.pubKey, event.verifiedRecipientPubKey())
}
@Test
fun verifiedRecipientPubKeyWithInvalidHex() {
val event =
NostrConnectEvent(
id = "a".repeat(64),
pubKey = senderKey.pubKey,
createdAt = 1L,
tags = arrayOf(arrayOf("p", "not-hex!")),
content = "encrypted",
sig = "b".repeat(128),
)
assertNull(event.verifiedRecipientPubKey())
}
@Test
fun verifiedRecipientPubKeyWithNoPTag() {
val event =
NostrConnectEvent(
id = "a".repeat(64),
pubKey = senderKey.pubKey,
createdAt = 1L,
tags = emptyArray(),
content = "encrypted",
sig = "b".repeat(128),
)
assertNull(event.verifiedRecipientPubKey())
}
// --- Kind ---
@Test
fun kindIs24133() {
assertEquals(24133, NostrConnectEvent.KIND)
}
@Test
fun eventHasCorrectKind() {
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
assertEquals(24133, event.kind)
}
// --- isContentEncoded ---
@Test
fun isContentEncodedReturnsTrue() {
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
assertTrue(event.isContentEncoded())
}
}
@@ -0,0 +1,95 @@
/*
* 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.quartz.nip46RemoteSigner.signer
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import kotlin.test.Test
import kotlin.test.assertIs
import kotlin.test.assertTrue
class ConvertExceptionsTest {
private val remote =
NostrSignerRemote.fromBunkerUri(
"bunker://${"a".repeat(64)}?relay=wss://r.com",
NostrSignerInternal(KeyPair()),
EmptyNostrClient,
)
@Test
fun successfulReturnsBug() {
val result = SignerResult.RequestAddressed.Successful(PingResult("pong"))
val ex = remote.convertExceptions("Test", result)
assertIs<IllegalStateException>(ex)
assertTrue(ex.message!!.contains("bug"))
}
@Test
fun rejectedReturnsManuallyUnauthorized() {
val result = SignerResult.RequestAddressed.Rejected<PingResult>()
val ex = remote.convertExceptions("Test", result)
assertIs<SignerExceptions.ManuallyUnauthorizedException>(ex)
}
@Test
fun timedOutReturnsTimedOutException() {
val result = SignerResult.RequestAddressed.TimedOut<PingResult>()
val ex = remote.convertExceptions("Test", result)
assertIs<SignerExceptions.TimedOutException>(ex)
}
@Test
fun couldNotPerformReturnsCouldNotPerformException() {
val result = SignerResult.RequestAddressed.ReceivedButCouldNotPerform<PingResult>("custom msg")
val ex = remote.convertExceptions("Test", result)
assertIs<SignerExceptions.CouldNotPerformException>(ex)
assertTrue(ex.message!!.contains("custom msg"))
}
@Test
fun couldNotParseReturnsIllegalState() {
val result = SignerResult.RequestAddressed.ReceivedButCouldNotParseEventFromResult<PingResult>("{bad}")
val ex = remote.convertExceptions("Test", result)
assertIs<IllegalStateException>(ex)
assertTrue(ex.message!!.contains("{bad}"))
}
@Test
fun couldNotVerifyReturnsIllegalState() {
val event =
Event(
id = "a".repeat(64),
pubKey = "b".repeat(64),
createdAt = 1L,
kind = 1,
tags = emptyArray(),
content = "",
sig = "c".repeat(128),
)
val result = SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent<PingResult>(event)
val ex = remote.convertExceptions("Test", result)
assertIs<IllegalStateException>(ex)
assertTrue(ex.message!!.contains("verify"))
}
}
@@ -0,0 +1,88 @@
/*
* 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.quartz.nip46RemoteSigner.signer
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
class FromBunkerUriTest {
private val signer = NostrSignerInternal(KeyPair())
private val client = EmptyNostrClient
private val validHex = "a".repeat(64)
@Test
fun validSingleRelay() {
val uri = "bunker://$validHex?relay=wss://relay.example.com"
val remote = NostrSignerRemote.fromBunkerUri(uri, signer, client)
assertEquals(1, remote.relays.size)
assertEquals(validHex, remote.remotePubkey)
assertNull(remote.secret)
}
@Test
fun validMultipleRelays() {
val uri = "bunker://$validHex?relay=wss://a.com&relay=wss://b.com"
val remote = NostrSignerRemote.fromBunkerUri(uri, signer, client)
assertEquals(2, remote.relays.size)
}
@Test
fun validWithSecret() {
val uri = "bunker://$validHex?relay=wss://r.com&secret=abc123"
val remote = NostrSignerRemote.fromBunkerUri(uri, signer, client)
assertEquals("abc123", remote.secret)
assertEquals(1, remote.relays.size)
}
@Test
fun missingSchemeThrows() {
assertFailsWith<Exception> {
NostrSignerRemote.fromBunkerUri("npub1abc", signer, client)
}
}
@Test
fun invalidHexPubkeyThrows() {
assertFailsWith<Exception> {
NostrSignerRemote.fromBunkerUri("bunker://notHex?relay=wss://r.com", signer, client)
}
}
@Test
fun missingQueryParamsThrows() {
assertFailsWith<Exception> {
NostrSignerRemote.fromBunkerUri("bunker://$validHex", signer, client)
}
}
@Test
fun skipsMalformedParams() {
val uri = "bunker://$validHex?relay=wss://r.com&badparam"
val remote = NostrSignerRemote.fromBunkerUri(uri, signer, client)
assertEquals(1, remote.relays.size)
assertNull(remote.secret)
}
}