test: verify the block actually mutates the kind-10006 correctly

The button had no test behind its central claim. NotifyRequestsCacheTest covered
the prompt bookkeeping and BlockedRelayFilteringClientTest the enforcement, but
nothing exercised BlockedRelayListState.addRelay — the step that decides what the
published block list contains.

That step is worth pinning because BlockedRelayListEvent.updateRelayList replaces
every relay tag with what it is handed, so addRelay must read the current list
before writing. Get it wrong and the second tap silently wipes the first block —
a data-loss bug the UI gives no sign of, since the dialog closes either way.

Drives the real thing: a real keypair, real NIP-51 encryption, LocalCache, and
the production decryption cache. AccountSettings is stubbed only because it reads
Resources.getSystem() for spoken languages, which is null outside an Android
runtime; Looper is mocked as the neighbouring LocalCache tests already do.

Covers: the list is created on the first block; the second block keeps the first;
re-blocking is idempotent; and the relays stay in encrypted private tags, never
public ones — a leak there would publish which paid relays the user walked away
from. Confirmed the wipe case fails when addRelay is reverted to writing only the
new relay.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U2GsUAheZmAXv6vk4m7m9T
This commit is contained in:
Claude
2026-09-02 00:30:28 +00:00
parent ea86c10adc
commit 5ba42b3439
@@ -0,0 +1,154 @@
/*
* 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.model.nip51Lists
import android.os.Looper
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* Drives the real kind-10006 mutation behind the NOTIFY dialog's "Block Relay" button.
*
* The button hands a single relay to [BlockedRelayListState.addRelay], which read-modify-writes the
* whole list — and [BlockedRelayListEvent.updateRelayList] *replaces* every relay tag with what it
* is given. So "does blocking work" is really two questions this pins: the new relay lands, and the
* relays already blocked survive. A regression here silently wipes the user's block list on their
* next tap, which no amount of UI testing would make obvious.
*
* Each case uses a fresh keypair so its kind-10006 lives at its own address in the process-wide
* [LocalCache].
*/
class BlockedRelayListAddRelayTest {
private val paid = RelayUrlNormalizer.normalize("wss://paid.example.com")
private val alsoPaid = RelayUrlNormalizer.normalize("wss://other.example.com")
@Before
fun setup() {
// LocalCache.consume refuses the main thread; plain JVM tests have no Looper,
// where null == null reads as "main". Distinct mocks make it a worker thread.
mockkStatic(Looper::class)
every { Looper.myLooper() } returns mockk<Looper>()
every { Looper.getMainLooper() } returns mockk<Looper>()
}
@After
fun tearDown() {
unmockkStatic(Looper::class)
}
private class Fixture {
val keyPair = KeyPair()
val signer = NostrSignerInternal(keyPair)
val decryptionCache = BlockedRelayListDecryptionCache(signer)
// Stubbed rather than real: AccountSettings reaches for Resources.getSystem() to read the
// user's spoken languages, which is null outside an Android runtime. The state only asks it
// for the backup list (none here) and hands it updates to persist.
val settings = mockk<AccountSettings>(relaxed = true) { every { backupBlockedRelayList } returns null }
val state =
BlockedRelayListState(
signer = signer,
cache = LocalCache,
decryptionCache = decryptionCache,
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
settings = settings,
)
/** Mirrors what the publish path does: the signed event goes into the cache it came from. */
fun land(event: BlockedRelayListEvent) = LocalCache.justConsumeMyOwnEvent(event)
suspend fun blockedIn(event: BlockedRelayListEvent) = BlockedRelayListDecryptionCache(signer).relays(event)
}
@Test
fun blockingTheFirstRelayCreatesTheList() =
runTest {
val f = Fixture()
val event = f.state.addRelay(paid)
assertEquals(BlockedRelayListEvent.KIND, event.kind)
assertEquals(setOf(paid), f.blockedIn(event))
}
/** The one that matters: a second tap must not throw away the first block. */
@Test
fun blockingASecondRelayKeepsTheFirst() =
runTest {
val f = Fixture()
f.land(f.state.addRelay(paid))
val event = f.state.addRelay(alsoPaid)
assertEquals(setOf(paid, alsoPaid), f.blockedIn(event))
}
@Test
fun blockingAnAlreadyBlockedRelayDoesNotDuplicateIt() =
runTest {
val f = Fixture()
f.land(f.state.addRelay(paid))
val event = f.state.addRelay(paid)
assertEquals(setOf(paid), f.blockedIn(event))
assertEquals(1, BlockedRelayListDecryptionCache(f.signer).relays(event).size)
}
/**
* Blocked relays are private tags. Leaking them to public tags would publish which paid relays
* the user walked away from to anyone who reads their kind-10006.
*/
@Test
fun blockedRelaysStayInEncryptedPrivateTags() =
runTest {
val f = Fixture()
f.land(f.state.addRelay(paid))
val event = f.state.addRelay(alsoPaid)
assertTrue("blocked relays must not appear in public tags", event.publicRelays().isEmpty())
assertTrue(
"no relay url may appear in the cleartext tag array",
event.tags.none { tag -> tag.any { it.contains("paid.example.com") || it.contains("other.example.com") } },
)
}
}