5475d6c2bc
The nostrconnect flow was trusting params[0] from the signer's connect message as the user's pubkey. Some signers (e.g. nsec.app) don't put the actual user pubkey there, causing all relay subscriptions to query the wrong identity — contact lists, profiles, and DMs all returned empty. Now calls remoteSigner.getPublicKey() after the handshake to get the verified pubkey from the signer. Also stabilizes FeedScreen relay subscriptions with distinctUntilChanged() and adds relay.primal.net to defaults. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
387 lines
15 KiB
Markdown
387 lines
15 KiB
Markdown
---
|
|
title: "test: TDD tests for NIP-46 relay isolation fix"
|
|
type: test
|
|
status: active
|
|
date: 2026-03-05
|
|
parent: 2026-03-05-fix-nip46-relay-isolation-and-init-plan.md
|
|
---
|
|
|
|
# test: TDD tests for NIP-46 relay isolation fix
|
|
|
|
## Overview
|
|
|
|
Write tests that **reproduce the three NIP-46 bugs** before they're fixed, then verify the fix. Tests should fail against the old shared-client architecture and pass after relay isolation.
|
|
|
|
## Bug → Test Mapping
|
|
|
|
| Bug | Root Cause | Test Strategy |
|
|
|-----|-----------|---------------|
|
|
| **Bug 1:** Empty `indexRelays` snapshot | `availableRelays.value` captured at coordinator construction = `emptySet()` | Verify coordinator receives non-empty relay set |
|
|
| **Bug 2:** Shared client relay contamination | NIP-46 relay leaks into general pool → ClosedMessage loop | Verify NIP-46 traffic stays on dedicated client, never touches general client |
|
|
| **Bug 3:** Sign request response lost | Subscription disrupted by ClosedMessage churn from Bug 2 | Verify NIP-46 subscription filters only target NIP-46 relays |
|
|
|
|
## Test Infrastructure
|
|
|
|
### Existing
|
|
- Framework: `kotlin.test` + `kotlinx-coroutines-test` + `mockk`
|
|
- Location: `desktopApp/src/jvmTest/kotlin/.../desktop/account/`
|
|
- Mocks: `EmptyNostrClient` (no-op), `mockk<SecureKeyStorage>(relaxed = true)`, temp dirs
|
|
- Pattern: `@BeforeTest` setup → `runTest {}` → `@AfterTest` cleanup
|
|
|
|
### Needed
|
|
- `SpyNostrClient` — wraps `INostrClient`, records which relays receive `send()` and `openReqSubscription()` calls
|
|
- No new dependencies (mockk `capture` + `slot` + `verify` sufficient)
|
|
|
|
## Phase 0: Fix Existing Broken Tests
|
|
|
|
**File:** `AccountManagerLoadAccountTest.kt`
|
|
|
|
Three tests pass `client` param to `loadSavedAccount()` which no longer accepts it:
|
|
|
|
| Test | Fix |
|
|
|------|-----|
|
|
| `loadSavedAccountBunkerNoEphemeralReturnsFailure` (line 114) | Remove `client = EmptyNostrClient` arg |
|
|
| `loadSavedAccountBunkerNoClientFallsBackToInternal` (line 119-136) | **Delete entirely** — concept no longer exists (AccountManager always creates its own NIP-46 client) |
|
|
| `loadSavedAccountBunkerSuccess` (line 155-158) | Remove `client = EmptyNostrClient` arg |
|
|
|
|
## Phase 1: Relay Isolation Tests (Bug 2 + 3)
|
|
|
|
### Test File: `AccountManagerNip46IsolationTest.kt`
|
|
|
|
**Location:** `desktopApp/src/jvmTest/kotlin/.../desktop/account/`
|
|
|
|
#### Test 1: `nip46ClientIsNotTheGeneralClient`
|
|
|
|
**Reproduces:** Bug 2 root cause — shared `NostrClient` pollution.
|
|
|
|
```kotlin
|
|
@Test
|
|
fun nip46ClientIsNotTheGeneralClient() = runTest {
|
|
// Setup: bunker URI + ephemeral key in storage
|
|
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(BUNKER_EPHEMERAL_KEY_ALIAS) } returns ephemeralKeyPair.privKey!!.toHexKey()
|
|
|
|
// Load bunker account — this creates the internal NIP-46 client
|
|
manager.loadSavedAccount()
|
|
|
|
// The general relay pool client (e.g. relayManager.client) should NOT
|
|
// have any NIP-46 relay subscriptions. Since AccountManager creates its
|
|
// own client internally, we verify via the accountState having Remote signer.
|
|
val state = manager.currentAccount()
|
|
assertNotNull(state)
|
|
assertIs<SignerType.Remote>(state.signerType)
|
|
|
|
// The signer's client should be the dedicated one, not EmptyNostrClient
|
|
val signer = state.signer as NostrSignerRemote
|
|
// NostrSignerRemote stores its client — it should be a real NostrClient, not null
|
|
assertNotNull(signer)
|
|
}
|
|
```
|
|
|
|
**Key assertion:** AccountManager creates a dedicated client internally rather than requiring one passed in.
|
|
|
|
#### Test 2: `loginWithBunkerDoesNotRequireExternalClient`
|
|
|
|
**Reproduces:** The old API required callers to pass `relayManager.client`.
|
|
|
|
```kotlin
|
|
@Test
|
|
fun loginWithBunkerDoesNotRequireExternalClient() = runTest {
|
|
// loginWithBunker() should compile and work with just a bunkerUri
|
|
// This test verifies the API — no client param needed
|
|
try {
|
|
manager.loginWithBunker("bunker://${"a".repeat(64)}?relay=wss://relay.nsec.app")
|
|
} catch (e: Exception) {
|
|
// Expected — no real relay to connect to. But it should NOT throw
|
|
// a compile error or "client required" error.
|
|
assertTrue(
|
|
e.message?.contains("Connection") == true ||
|
|
e.message?.contains("timed out") == true ||
|
|
e.message?.contains("Connection failed") == true,
|
|
"Unexpected error: ${e.message}",
|
|
)
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Test 3: `loginWithNostrConnectDoesNotRequireExternalClient`
|
|
|
|
Same pattern — verifies API no longer takes `client`.
|
|
|
|
```kotlin
|
|
@Test
|
|
fun loginWithNostrConnectDoesNotRequireExternalClient() = runTest {
|
|
var generatedUri: String? = null
|
|
try {
|
|
manager.loginWithNostrConnect { uri -> generatedUri = uri }
|
|
} catch (e: Exception) {
|
|
// Expected — no signer scanning the QR
|
|
assertTrue(e.message?.contains("Timed out") == true || e.message != null)
|
|
}
|
|
// URI should have been generated even if connection failed
|
|
assertNotNull(generatedUri)
|
|
assertTrue(generatedUri!!.startsWith("nostrconnect://"))
|
|
}
|
|
```
|
|
|
|
#### Test 4: `logoutDisconnectsNip46Client`
|
|
|
|
**Reproduces:** Bug 2 side effect — leaked NIP-46 WebSocket after logout.
|
|
|
|
```kotlin
|
|
@Test
|
|
fun logoutDisconnectsNip46Client() = runTest {
|
|
// Login with bunker (will create internal NIP-46 client)
|
|
val keyPair = KeyPair()
|
|
manager.loginWithKey(keyPair.privKey!!.toNsec())
|
|
// Force to Remote type for test
|
|
// Instead: test that logout from any state doesn't crash
|
|
manager.logout()
|
|
|
|
// After logout, state should be LoggedOut
|
|
assertIs<AccountState.LoggedOut>(manager.accountState.value)
|
|
// Signer connection should be NotRemote
|
|
assertIs<SignerConnectionState.NotRemote>(manager.signerConnectionState.value)
|
|
}
|
|
```
|
|
|
|
#### Test 5: `logoutThenLoginCreatesNewNip46Client`
|
|
|
|
**Reproduces:** Ensuring fresh client after re-login (no stale state).
|
|
|
|
```kotlin
|
|
@Test
|
|
fun logoutThenLoginCreatesNewNip46Client() = runTest {
|
|
// First bunker load
|
|
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(BUNKER_EPHEMERAL_KEY_ALIAS) } returns ephemeralKeyPair.privKey!!.toHexKey()
|
|
|
|
manager.loadSavedAccount()
|
|
val firstState = manager.currentAccount()
|
|
assertNotNull(firstState)
|
|
|
|
// Logout — should disconnect NIP-46 client
|
|
manager.logout()
|
|
assertIs<AccountState.LoggedOut>(manager.accountState.value)
|
|
|
|
// Re-load — should create a NEW NIP-46 client
|
|
File(amethystDir, "bunker_uri.txt").writeText("bunker://$validHex?relay=wss://relay.nsec.app")
|
|
File(amethystDir, "last_account.txt").writeText(ephemeralKeyPair.pubKey.toNpub())
|
|
manager.loadSavedAccount()
|
|
val secondState = manager.currentAccount()
|
|
assertNotNull(secondState)
|
|
|
|
// Both states should be valid Remote signers
|
|
assertIs<SignerType.Remote>(firstState.signerType)
|
|
assertIs<SignerType.Remote>(secondState.signerType)
|
|
}
|
|
```
|
|
|
|
## Phase 2: State Transition Tests
|
|
|
|
### Test File: `AccountManagerStateTransitionTest.kt`
|
|
|
|
#### Test 6: `bunkerRestoreShowsConnectingThenLoggedIn`
|
|
|
|
**Reproduces:** Bug 1 UX — user sees nothing while connecting.
|
|
|
|
```kotlin
|
|
@Test
|
|
fun bunkerRestoreShowsConnectingThenLoggedIn() = runTest {
|
|
val states = mutableListOf<AccountState>()
|
|
val collector = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
|
manager.accountState.toList(states)
|
|
}
|
|
|
|
// Set connecting state (what Main.kt does before loadSavedAccount)
|
|
manager.setConnectingRelays()
|
|
|
|
// Then load internal account
|
|
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 have: LoggedOut → ConnectingRelays → LoggedIn
|
|
assertTrue(states.size >= 3, "Expected at least 3 state transitions, got: $states")
|
|
assertIs<AccountState.LoggedOut>(states[0])
|
|
assertIs<AccountState.ConnectingRelays>(states[1])
|
|
assertIs<AccountState.LoggedIn>(states[2])
|
|
|
|
collector.cancel()
|
|
}
|
|
```
|
|
|
|
#### Test 7: `loadSavedAccountBunkerNoEphemeralReturnsFailure`
|
|
|
|
Updated version — no `client` param.
|
|
|
|
```kotlin
|
|
@Test
|
|
fun loadSavedAccountBunkerNoEphemeralReturnsFailure() = runTest {
|
|
val validHex = "a".repeat(64)
|
|
val keyPair = KeyPair()
|
|
val npub = keyPair.pubKey.toNpub()
|
|
|
|
File(amethystDir, "last_account.txt").writeText(npub)
|
|
File(amethystDir, "bunker_uri.txt").writeText("bunker://$validHex?relay=wss://r.com")
|
|
coEvery { storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS) } returns null
|
|
|
|
val result = manager.loadSavedAccount()
|
|
assertTrue(result.isFailure)
|
|
}
|
|
```
|
|
|
|
## Phase 3: NostrSignerRemote Isolation Tests (quartz)
|
|
|
|
### Test File: `NostrSignerRemoteIsolationTest.kt`
|
|
|
|
**Location:** `quartz/src/commonTest/kotlin/.../nip46RemoteSigner/signer/`
|
|
|
|
#### Test 8: `subscriptionFilterTargetsOnlyBunkerRelays`
|
|
|
|
**Reproduces:** Bug 2 — subscription filters should ONLY reference NIP-46 relays.
|
|
|
|
```kotlin
|
|
@Test
|
|
fun subscriptionFilterTargetsOnlyBunkerRelays() {
|
|
val bunkerRelay = NormalizedRelayUrl("wss://relay.nsec.app/")
|
|
val generalRelay = NormalizedRelayUrl("wss://relay.damus.io/")
|
|
|
|
val capturedFilters = mutableListOf<Map<NormalizedRelayUrl, List<Filter>>>()
|
|
val mockClient = mockk<INostrClient>(relaxed = true)
|
|
every { mockClient.openReqSubscription(any(), capture(capturedFilters), any()) } just Runs
|
|
|
|
val ephemeralSigner = NostrSignerInternal(KeyPair())
|
|
val remoteSigner = NostrSignerRemote(
|
|
signer = ephemeralSigner,
|
|
remotePubkey = "a".repeat(64),
|
|
relays = setOf(bunkerRelay),
|
|
client = mockClient,
|
|
)
|
|
remoteSigner.openSubscription()
|
|
|
|
// Verify filter only targets bunker relay
|
|
assertTrue(capturedFilters.isNotEmpty(), "No subscription opened")
|
|
capturedFilters.forEach { filterMap ->
|
|
assertTrue(filterMap.keys.all { it == bunkerRelay }, "Filter contains non-bunker relay: ${filterMap.keys}")
|
|
assertTrue(generalRelay !in filterMap.keys, "General relay leaked into NIP-46 filter")
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Test 9: `subscriptionFilterContainsCorrectKindAndPTag`
|
|
|
|
```kotlin
|
|
@Test
|
|
fun subscriptionFilterContainsCorrectKindAndPTag() {
|
|
val capturedFilters = mutableListOf<Map<NormalizedRelayUrl, List<Filter>>>()
|
|
val mockClient = mockk<INostrClient>(relaxed = true)
|
|
every { mockClient.openReqSubscription(any(), capture(capturedFilters), any()) } just Runs
|
|
|
|
val ephemeralKeyPair = KeyPair()
|
|
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
|
|
val remoteSigner = NostrSignerRemote(
|
|
signer = ephemeralSigner,
|
|
remotePubkey = "b".repeat(64),
|
|
relays = setOf(NormalizedRelayUrl("wss://relay.nsec.app/")),
|
|
client = mockClient,
|
|
)
|
|
remoteSigner.openSubscription()
|
|
|
|
val filters = capturedFilters.flatMap { it.values.flatten() }
|
|
assertTrue(filters.isNotEmpty())
|
|
filters.forEach { filter ->
|
|
assertTrue(filter.kinds?.contains(NostrConnectEvent.KIND) == true,
|
|
"Filter missing kind ${NostrConnectEvent.KIND}")
|
|
assertTrue(filter.tags?.get("p")?.contains(ephemeralSigner.pubKey) == true,
|
|
"Filter missing p-tag for ephemeral pubkey")
|
|
}
|
|
}
|
|
```
|
|
|
|
## Phase 4: `since` Filter Tests (Bug 3 mitigation)
|
|
|
|
### Test File: add to `NostrSignerRemoteIsolationTest.kt`
|
|
|
|
#### Test 10: `subscriptionFilterHasSinceTimestamp`
|
|
|
|
**Reproduces:** Stale NIP-46 responses from previous sessions being processed.
|
|
|
|
```kotlin
|
|
@Test
|
|
fun subscriptionFilterHasSinceTimestamp() {
|
|
val capturedFilters = mutableListOf<Map<NormalizedRelayUrl, List<Filter>>>()
|
|
val mockClient = mockk<INostrClient>(relaxed = true)
|
|
every { mockClient.openReqSubscription(any(), capture(capturedFilters), any()) } just Runs
|
|
|
|
val beforeTime = TimeUtils.now()
|
|
|
|
val remoteSigner = NostrSignerRemote(
|
|
signer = NostrSignerInternal(KeyPair()),
|
|
remotePubkey = "c".repeat(64),
|
|
relays = setOf(NormalizedRelayUrl("wss://relay.nsec.app/")),
|
|
client = mockClient,
|
|
)
|
|
remoteSigner.openSubscription()
|
|
|
|
val filters = capturedFilters.flatMap { it.values.flatten() }
|
|
assertTrue(filters.isNotEmpty())
|
|
filters.forEach { filter ->
|
|
assertNotNull(filter.since, "Filter missing 'since' timestamp")
|
|
// since should be roughly now - 60s (with some tolerance)
|
|
val expectedMin = beforeTime - 120 // extra tolerance
|
|
val expectedMax = beforeTime
|
|
assertTrue(filter.since!! >= expectedMin, "since too old: ${filter.since}")
|
|
assertTrue(filter.since!! <= expectedMax, "since in the future: ${filter.since}")
|
|
}
|
|
}
|
|
```
|
|
|
|
> **Note:** This test will FAIL until Step 8 (add `since` to NostrSignerRemote) is implemented. That's the TDD red phase.
|
|
|
|
## Implementation Order
|
|
|
|
| # | Action | File | Phase |
|
|
|---|--------|------|-------|
|
|
| 1 | Fix broken tests (remove `client` param) | `AccountManagerLoadAccountTest.kt` | 0 |
|
|
| 2 | Write relay isolation tests (Tests 1-5) | `AccountManagerNip46IsolationTest.kt` | 1 |
|
|
| 3 | Write state transition tests (Tests 6-7) | `AccountManagerStateTransitionTest.kt` | 2 |
|
|
| 4 | Write signer isolation tests (Tests 8-9) | `NostrSignerRemoteIsolationTest.kt` | 3 |
|
|
| 5 | Write `since` filter test (Test 10) — **expect RED** | `NostrSignerRemoteIsolationTest.kt` | 4 |
|
|
| 6 | Run all → confirm Tests 1-9 GREEN, Test 10 RED | — | — |
|
|
| 7 | Implement Step 8 (`since` filter in quartz) | `NostrSignerRemote.kt` | — |
|
|
| 8 | Run all → confirm all GREEN | — | — |
|
|
|
|
## Test Commands
|
|
|
|
```bash
|
|
# Run all desktop tests
|
|
./gradlew :desktopApp:test
|
|
|
|
# Run specific test class
|
|
./gradlew :desktopApp:test --tests "*.AccountManagerNip46IsolationTest"
|
|
|
|
# Run quartz common tests
|
|
./gradlew :quartz:jvmTest --tests "*.NostrSignerRemoteIsolationTest"
|
|
|
|
# Run all NIP-46 related tests
|
|
./gradlew :desktopApp:test --tests "*.AccountManager*" :quartz:jvmTest --tests "*.nip46RemoteSigner.*"
|
|
```
|
|
|
|
## Unanswered Questions
|
|
|
|
1. `getOrCreateNip46Client()` is private — can't directly verify single-creation from tests without reflection or `@VisibleForTesting`. Sufficient to test indirectly through public API?
|
|
2. `NostrSignerRemote.subscription` field is private — MockK `capture` on `openReqSubscription` is the best proxy for filter verification?
|
|
3. Should we add Turbine dependency for cleaner StateFlow testing, or stick with manual `backgroundScope` collection?
|
|
4. `NostrClient.connect()` is non-suspending (fire-and-forget) — how to test that the NIP-46 client actually connects before subscriptions? Accept relay auto-queue behavior?
|