mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+154
@@ -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") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user