test(relay): comprehensive NIP coverage — 09, 40, 42 (success), 62
Closes the gaps from the test audit. Adds 24 new tests covering
features we advertise in NIP-11 but previously had zero coverage for,
plus regression tests for the wire-format bugs fixed earlier on this
branch.
New files:
- Nip09DeletionTest (4 tests) — deletion removes targeted events,
deletion event itself is queryable, reinsert is blocked, cross-
author deletion is ignored.
- Nip40ExpirationTest (3 tests) — expired-on-arrival events
rejected, future-expiration events stored and retrievable,
deleteExpiredEvents() purges past-expiration entries.
- Nip62VanishTest (3 tests) — vanish cascades prior events,
blocks re-insertion of older events, doesn't affect other authors.
Extended LocalRelayServerTest (+4 tests, now 9):
- nip42_successfulAuthUnlocksPublishing — full AUTH dance over
real WebSocket: relay challenge → RelayAuthenticator signs →
OK true → publishAndConfirm now succeeds.
- nip01_okMessageRoundtripWithEmptyAndNonEmptyMessage —
regression test for the OkMessage serializer bug fixed earlier.
- nip11_servesConfigDrivenInfoDoc — custom RelayInfo flows
through to the NIP-11 GET response.
- nip01_closeStopsLiveSubscription — CLOSE over the wire
actually terminates a live subscription.
Extended Nip01ComplianceTest (+5 tests, now 18):
- reqFiltersByPTag, reqFiltersByGenericSingleLetterTag (#t),
reqTagFilterValuesAreOred, reqMultipleFiltersAreOred,
multipleSubscriptionsOnOneConnectionAreIndependent.
Total: 42 tests in :quartz-relay (was 23), 0 failures.
This commit is contained in:
@@ -213,4 +213,199 @@ class LocalRelayServerTest {
|
||||
authRelay.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Successful NIP-42 AUTH must unlock REQ/EVENT/COUNT. We can't bind
|
||||
* the server on a known port AND configure the policy with that URL
|
||||
* unless we discover the port first — so reserve a free TCP port
|
||||
* before constructing the relay, then bind to that exact port so
|
||||
* the policy's `relay` field matches what `RelayAuthenticator`
|
||||
* sends in the AUTH event's `relay` tag.
|
||||
*/
|
||||
@Test
|
||||
fun nip42_successfulAuthUnlocksPublishing() =
|
||||
runBlocking {
|
||||
val freePort =
|
||||
java.net.ServerSocket(0).use { it.localPort }
|
||||
val authUrl = "ws://127.0.0.1:$freePort/".normalizeRelayUrl()
|
||||
val authRelay = Relay(authUrl, policyBuilder = { FullAuthPolicy(authUrl) })
|
||||
val authServer = LocalRelayServer(authRelay, host = "127.0.0.1", port = freePort).start()
|
||||
try {
|
||||
val signer =
|
||||
com.vitorpamplona.quartz.nip01Core.signers
|
||||
.NostrSignerSync(KeyPair())
|
||||
|
||||
// RelayAuthenticator hooks the client: when the relay
|
||||
// sends the AUTH challenge, it auto-signs and replies.
|
||||
val authenticator =
|
||||
com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator(
|
||||
client = client,
|
||||
scope = scope,
|
||||
) { template ->
|
||||
listOf(signer.sign(template))
|
||||
}
|
||||
try {
|
||||
// Trigger the AUTH dance: subscribe to anything,
|
||||
// which the relay rejects with `auth-required:` →
|
||||
// [RelayAuthenticator] catches the challenge, signs
|
||||
// and sends the AUTH event, the relay's OK true
|
||||
// makes the client re-sync filters, and the second
|
||||
// REQ succeeds and EOSEs.
|
||||
val gotEose =
|
||||
kotlinx.coroutines.channels.Channel<Unit>(
|
||||
kotlinx.coroutines.channels.Channel.UNLIMITED,
|
||||
)
|
||||
client.subscribe(
|
||||
"auth-warmup",
|
||||
mapOf(authUrl to listOf(Filter(kinds = listOf(1)))),
|
||||
object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener {
|
||||
override fun onEose(
|
||||
relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
gotEose.trySend(Unit)
|
||||
}
|
||||
},
|
||||
)
|
||||
kotlinx.coroutines.withTimeout(5000) { gotEose.receive() }
|
||||
client.unsubscribe("auth-warmup")
|
||||
|
||||
val event = signer.sign(TextNoteEvent.build("after-auth"))
|
||||
val ok =
|
||||
client.publishAndConfirm(
|
||||
event = event,
|
||||
relayList = setOf(authUrl),
|
||||
)
|
||||
assertEquals(true, ok, "after AUTH succeeds, publishing must work")
|
||||
} finally {
|
||||
authenticator.destroy()
|
||||
}
|
||||
} finally {
|
||||
authServer.stop()
|
||||
authRelay.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression for the OkMessage wire format (the Jackson serializer
|
||||
* was writing `success` as a JSON string). `publishAndConfirm`
|
||||
* relies on parsing the OK response — if the relay's serialization
|
||||
* regresses, this test catches it.
|
||||
*/
|
||||
@Test
|
||||
fun nip01_okMessageRoundtripWithEmptyAndNonEmptyMessage() =
|
||||
runBlocking {
|
||||
val signer =
|
||||
com.vitorpamplona.quartz.nip01Core.signers
|
||||
.NostrSignerSync(KeyPair())
|
||||
val event = signer.sign(TextNoteEvent.build("ok-roundtrip"))
|
||||
val ok =
|
||||
client.publishAndConfirm(
|
||||
event = event,
|
||||
relayList = setOf(server.url.normalizeRelayUrl()),
|
||||
)
|
||||
assertEquals(true, ok, "successful insert must round-trip OK true on the wire")
|
||||
|
||||
// Duplicate insert returns OK false; this also exercises the
|
||||
// "non-empty message" branch of the serializer.
|
||||
val ok2 =
|
||||
client.publishAndConfirm(
|
||||
event = event,
|
||||
relayList = setOf(server.url.normalizeRelayUrl()),
|
||||
)
|
||||
assertEquals(false, ok2, "duplicate insert must round-trip OK false")
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom config's `[info]` section flows through to the NIP-11
|
||||
* doc returned on the HTTP endpoint.
|
||||
*/
|
||||
@Test
|
||||
fun nip11_servesConfigDrivenInfoDoc() {
|
||||
val freePort =
|
||||
java.net.ServerSocket(0).use { it.localPort }
|
||||
val customUrl = "ws://127.0.0.1:$freePort/".normalizeRelayUrl()
|
||||
val customInfo =
|
||||
RelayInfo(
|
||||
Nip11RelayInformation(
|
||||
name = "custom-relay-name",
|
||||
contact = "ops@example.com",
|
||||
description = "Custom from config",
|
||||
supported_nips = listOf("1", "11", "42"),
|
||||
),
|
||||
)
|
||||
val customRelay = Relay(customUrl, info = customInfo)
|
||||
val customServer =
|
||||
LocalRelayServer(customRelay, host = "127.0.0.1", port = freePort).start()
|
||||
try {
|
||||
val httpUrl = customServer.url.replace("ws://", "http://")
|
||||
val response =
|
||||
httpClient
|
||||
.newCall(
|
||||
Request
|
||||
.Builder()
|
||||
.url(httpUrl)
|
||||
.header("Accept", "application/nostr+json")
|
||||
.build(),
|
||||
).execute()
|
||||
response.use {
|
||||
assertEquals(200, it.code)
|
||||
val info = Nip11RelayInformation.fromJson(it.body.string())
|
||||
assertEquals("custom-relay-name", info.name)
|
||||
assertEquals("ops@example.com", info.contact)
|
||||
assertEquals(listOf("1", "11", "42"), info.supported_nips)
|
||||
}
|
||||
} finally {
|
||||
customServer.stop()
|
||||
customRelay.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip01_closeStopsLiveSubscription() =
|
||||
runBlocking {
|
||||
val ch =
|
||||
kotlinx.coroutines.channels.Channel<com.vitorpamplona.quartz.nip01Core.core.Event>(
|
||||
kotlinx.coroutines.channels.Channel.UNLIMITED,
|
||||
)
|
||||
val gotEose =
|
||||
kotlinx.coroutines.channels.Channel<Unit>(
|
||||
kotlinx.coroutines.channels.Channel.UNLIMITED,
|
||||
)
|
||||
client.subscribe(
|
||||
"close-test",
|
||||
mapOf(server.url.normalizeRelayUrl() to listOf(Filter(kinds = listOf(1)))),
|
||||
object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: com.vitorpamplona.quartz.nip01Core.core.Event,
|
||||
isLive: Boolean,
|
||||
relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
ch.trySend(event)
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
gotEose.trySend(Unit)
|
||||
}
|
||||
},
|
||||
)
|
||||
kotlinx.coroutines.withTimeout(5000) { gotEose.receive() }
|
||||
|
||||
// CLOSE the subscription, then publish a matching event over
|
||||
// the wire. The unsubscribed client must NOT receive it.
|
||||
client.unsubscribe("close-test")
|
||||
|
||||
val signer =
|
||||
com.vitorpamplona.quartz.nip01Core.signers
|
||||
.NostrSignerSync(KeyPair())
|
||||
val late = signer.sign(TextNoteEvent.build("post-close"))
|
||||
relay.publish(late)
|
||||
|
||||
val seen = kotlinx.coroutines.withTimeoutOrNull(500) { ch.receive() }
|
||||
assertEquals(null, seen, "events arriving after CLOSE must not reach the unsubscribed client")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +195,161 @@ class Nip01ComplianceTest {
|
||||
assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet())
|
||||
}
|
||||
|
||||
/** REQ with `#p` tag filter — same single-letter machinery as `#e`. */
|
||||
@Test
|
||||
fun reqFiltersByPTag() =
|
||||
runBlocking {
|
||||
val targetPubkey = SyntheticEvents.hexId(2222)
|
||||
preload(
|
||||
fakeEvent(1, tags = arrayOf(arrayOf("p", targetPubkey))),
|
||||
fakeEvent(2, tags = arrayOf(arrayOf("p", SyntheticEvents.hexId(3333)))),
|
||||
fakeEvent(3, tags = arrayOf(arrayOf("p", targetPubkey), arrayOf("e", SyntheticEvents.hexId(99)))),
|
||||
)
|
||||
|
||||
val (events, _) = collectUntilEose(Filter(tags = mapOf("p" to listOf(targetPubkey))))
|
||||
|
||||
assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet())
|
||||
}
|
||||
|
||||
/** REQ with a generic single-letter tag (e.g. `#t`) for hashtags. */
|
||||
@Test
|
||||
fun reqFiltersByGenericSingleLetterTag() =
|
||||
runBlocking {
|
||||
preload(
|
||||
fakeEvent(1, tags = arrayOf(arrayOf("t", "nostr"))),
|
||||
fakeEvent(2, tags = arrayOf(arrayOf("t", "bitcoin"))),
|
||||
fakeEvent(3, tags = arrayOf(arrayOf("t", "nostr"), arrayOf("t", "kotlin"))),
|
||||
)
|
||||
|
||||
val (events, _) = collectUntilEose(Filter(tags = mapOf("t" to listOf("nostr"))))
|
||||
|
||||
assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet())
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-value tag filter — matches events with tag value in the OR
|
||||
* set. NIP-01 says values inside a single filter list are OR'd.
|
||||
*/
|
||||
@Test
|
||||
fun reqTagFilterValuesAreOred() =
|
||||
runBlocking {
|
||||
val a = SyntheticEvents.hexId(101)
|
||||
val b = SyntheticEvents.hexId(102)
|
||||
preload(
|
||||
fakeEvent(1, tags = arrayOf(arrayOf("e", a))),
|
||||
fakeEvent(2, tags = arrayOf(arrayOf("e", b))),
|
||||
fakeEvent(3, tags = arrayOf(arrayOf("e", SyntheticEvents.hexId(999)))),
|
||||
)
|
||||
|
||||
val (events, _) = collectUntilEose(Filter(tags = mapOf("e" to listOf(a, b))))
|
||||
|
||||
assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(2)), events.map { it.id }.toSet())
|
||||
}
|
||||
|
||||
// -- Multi-filter REQ ----------------------------------------------------
|
||||
|
||||
/**
|
||||
* NIP-01: filters within a single REQ are OR'd. The relay returns
|
||||
* events matching ANY of the filters, deduplicated.
|
||||
*/
|
||||
@Test
|
||||
fun reqMultipleFiltersAreOred() =
|
||||
runBlocking {
|
||||
preload(
|
||||
fakeEvent(1, kind = 1),
|
||||
fakeEvent(2, kind = 4),
|
||||
fakeEvent(3, kind = 7),
|
||||
)
|
||||
|
||||
val (events, _) =
|
||||
collectUntilEoseMulti(
|
||||
listOf(
|
||||
Filter(kinds = listOf(1)),
|
||||
Filter(kinds = listOf(7)),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)),
|
||||
events.map { it.id }.toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Two subscriptions on the same connection are independent — each
|
||||
* gets its own EOSE and its own event stream.
|
||||
*/
|
||||
@Test
|
||||
fun multipleSubscriptionsOnOneConnectionAreIndependent() =
|
||||
runBlocking {
|
||||
preload(
|
||||
fakeEvent(1, kind = 1),
|
||||
fakeEvent(2, kind = 4),
|
||||
)
|
||||
|
||||
val ch1 = Channel<Event>(UNLIMITED)
|
||||
val ch2 = Channel<Event>(UNLIMITED)
|
||||
val eose1 = Channel<Unit>(UNLIMITED)
|
||||
val eose2 = Channel<Unit>(UNLIMITED)
|
||||
|
||||
client.subscribe(
|
||||
"sub-A",
|
||||
mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))),
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
ch1.trySend(event)
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
eose1.trySend(Unit)
|
||||
}
|
||||
},
|
||||
)
|
||||
client.subscribe(
|
||||
"sub-B",
|
||||
mapOf(relayUrl to listOf(Filter(kinds = listOf(4)))),
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
ch2.trySend(event)
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
eose2.trySend(Unit)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
withTimeout(5000) {
|
||||
eose1.receive()
|
||||
eose2.receive()
|
||||
}
|
||||
|
||||
// sub-A only saw kind=1, sub-B only saw kind=4.
|
||||
val a = withTimeoutOrNull(100) { ch1.receive() }
|
||||
val b = withTimeoutOrNull(100) { ch2.receive() }
|
||||
assertEquals(SyntheticEvents.hexId(1), a?.id)
|
||||
assertEquals(SyntheticEvents.hexId(2), b?.id)
|
||||
|
||||
client.unsubscribe("sub-A")
|
||||
client.unsubscribe("sub-B")
|
||||
}
|
||||
|
||||
// -- Replaceable + addressable ------------------------------------------
|
||||
|
||||
/** Kind 0 is replaceable by `(pubkey, kind)` — newer wins. */
|
||||
@@ -458,6 +613,45 @@ class Nip01ComplianceTest {
|
||||
return events to eose
|
||||
}
|
||||
|
||||
/** Variant of [collectUntilEose] that subscribes with multiple filters. */
|
||||
private suspend fun collectUntilEoseMulti(filters: List<Filter>): Pair<List<Event>, Boolean> {
|
||||
val ch = Channel<Either>(UNLIMITED)
|
||||
val subId = "sub-${System.nanoTime()}"
|
||||
client.subscribe(
|
||||
subId,
|
||||
mapOf(relayUrl to filters),
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
ch.trySend(Either.Ev(event))
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
ch.trySend(Either.Eose)
|
||||
}
|
||||
},
|
||||
)
|
||||
val events = mutableListOf<Event>()
|
||||
var eose = false
|
||||
withTimeout(5000) {
|
||||
while (!eose) {
|
||||
when (val msg = ch.receive()) {
|
||||
is Either.Ev -> events += msg.event
|
||||
Either.Eose -> eose = true
|
||||
}
|
||||
}
|
||||
}
|
||||
client.unsubscribe(subId)
|
||||
return events to eose
|
||||
}
|
||||
|
||||
private sealed interface Either {
|
||||
data class Ev(
|
||||
val event: Event,
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* 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.relay
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* Verifies NIP-09 deletion request behavior end-to-end through
|
||||
* `NostrClient` → `RelayHub`. The relay's
|
||||
* [com.vitorpamplona.quartz.nip01Core.store.sqlite.DeletionRequestModule]
|
||||
* is responsible for honouring kind-5 events:
|
||||
*
|
||||
* 1. Existing events targeted by id are removed from the store.
|
||||
* 2. A SQL trigger blocks re-insertion of any event that matches a
|
||||
* stored kind-5 deletion (so a malicious relay can't sneak the
|
||||
* deleted event back in via another connection).
|
||||
* 3. Cross-author deletion is silently ignored: a kind-5 event from
|
||||
* pubkey X cannot delete pubkey Y's events.
|
||||
*/
|
||||
class Nip09DeletionTest {
|
||||
private lateinit var hub: RelayHub
|
||||
private lateinit var scope: CoroutineScope
|
||||
private lateinit var client: NostrClient
|
||||
private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
hub = RelayHub()
|
||||
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
client = NostrClient(hub, scope)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun teardown() {
|
||||
client.disconnect()
|
||||
scope.cancel()
|
||||
hub.close()
|
||||
}
|
||||
|
||||
private suspend fun query(filter: Filter): List<Event> {
|
||||
val ch = kotlinx.coroutines.channels.Channel<Either>(kotlinx.coroutines.channels.Channel.UNLIMITED)
|
||||
val subId = "sub-${System.nanoTime()}"
|
||||
client.subscribe(
|
||||
subId,
|
||||
mapOf(relayUrl to listOf(filter)),
|
||||
object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
ch.trySend(Either.Ev(event))
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
ch.trySend(Either.Eose)
|
||||
}
|
||||
},
|
||||
)
|
||||
val events = mutableListOf<Event>()
|
||||
kotlinx.coroutines.withTimeout(5000) {
|
||||
while (true) {
|
||||
when (val msg = ch.receive()) {
|
||||
is Either.Ev -> events += msg.event
|
||||
Either.Eose -> return@withTimeout
|
||||
}
|
||||
}
|
||||
}
|
||||
client.unsubscribe(subId)
|
||||
return events
|
||||
}
|
||||
|
||||
private sealed interface Either {
|
||||
data class Ev(
|
||||
val event: Event,
|
||||
) : Either
|
||||
|
||||
object Eose : Either
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletionRemovesTargetedEventFromStore() =
|
||||
runBlocking {
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
val note = signer.sign(TextNoteEvent.build("delete me"))
|
||||
val deletion = signer.sign(DeletionEvent.build(listOf(note), createdAt = note.createdAt + 1))
|
||||
|
||||
// Publish original, confirm it's stored.
|
||||
assertEquals(true, client.publishAndConfirm(note, setOf(relayUrl)))
|
||||
assertEquals(1, query(Filter(ids = listOf(note.id))).size)
|
||||
|
||||
// Publish deletion; the store removes the targeted event.
|
||||
assertEquals(true, client.publishAndConfirm(deletion, setOf(relayUrl)))
|
||||
assertEquals(0, query(Filter(ids = listOf(note.id))).size, "event must be gone")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletionEventItselfIsStoredAndQueryable() =
|
||||
runBlocking {
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
val note = signer.sign(TextNoteEvent.build("a"))
|
||||
val deletion = signer.sign(DeletionEvent.build(listOf(note), createdAt = note.createdAt + 1))
|
||||
|
||||
client.publishAndConfirm(note, setOf(relayUrl))
|
||||
client.publishAndConfirm(deletion, setOf(relayUrl))
|
||||
|
||||
val results = query(Filter(kinds = listOf(DeletionEvent.KIND), authors = listOf(signer.pubKey)))
|
||||
assertEquals(1, results.size)
|
||||
assertEquals(deletion.id, results[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reinsertingADeletedEventIsRejected() =
|
||||
runBlocking {
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
val note = signer.sign(TextNoteEvent.build("once-upon-a-time"))
|
||||
val deletion = signer.sign(DeletionEvent.build(listOf(note), createdAt = note.createdAt + 1))
|
||||
|
||||
client.publishAndConfirm(note, setOf(relayUrl))
|
||||
client.publishAndConfirm(deletion, setOf(relayUrl))
|
||||
|
||||
// Trying to reinsert the same event must fail — relay returns OK false.
|
||||
val ok = client.publishAndConfirm(note, setOf(relayUrl))
|
||||
assertEquals(false, ok, "reinserting a deleted event must be blocked")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crossAuthorDeletionDoesNotRemoveOtherUsersEvents() =
|
||||
runBlocking {
|
||||
val alice = NostrSignerSync(KeyPair())
|
||||
val mallory = NostrSignerSync(KeyPair())
|
||||
val aliceNote = alice.sign(TextNoteEvent.build("alice-private"))
|
||||
|
||||
client.publishAndConfirm(aliceNote, setOf(relayUrl))
|
||||
assertEquals(1, query(Filter(ids = listOf(aliceNote.id))).size)
|
||||
|
||||
// Mallory tries to delete Alice's event: relay accepts the
|
||||
// kind-5 event itself (it's just an event), but the SQL DELETE
|
||||
// is owner-scoped, so Alice's event survives.
|
||||
val malloryDelete =
|
||||
mallory.sign(DeletionEvent.build(listOf(aliceNote), createdAt = aliceNote.createdAt + 1))
|
||||
client.publishAndConfirm(malloryDelete, setOf(relayUrl))
|
||||
|
||||
assertEquals(
|
||||
1,
|
||||
query(Filter(ids = listOf(aliceNote.id))).size,
|
||||
"Mallory's deletion must NOT remove Alice's event",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* 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.relay
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
/**
|
||||
* NIP-40 expiration:
|
||||
* - The relay rejects EVENTs whose `expiration` tag is in the past
|
||||
* (the SQLite store's `insertEvent` raises on `event.isExpired()`).
|
||||
* - Events with a future expiration are stored and queryable until
|
||||
* the operator calls `deleteExpiredEvents()` (or sufficient time
|
||||
* passes that `isExpired()` returns true on read).
|
||||
*/
|
||||
class Nip40ExpirationTest {
|
||||
private lateinit var hub: RelayHub
|
||||
private lateinit var scope: CoroutineScope
|
||||
private lateinit var client: NostrClient
|
||||
private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
hub = RelayHub()
|
||||
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
client = NostrClient(hub, scope)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun teardown() {
|
||||
client.disconnect()
|
||||
scope.cancel()
|
||||
hub.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expiredEventOnArrivalIsRejectedWithOkFalse() =
|
||||
runBlocking {
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
// expiration set to a past timestamp.
|
||||
val past = TimeUtils.now() - 60
|
||||
val event =
|
||||
signer.sign(
|
||||
TextNoteEvent.build("expired") {
|
||||
expiration(past)
|
||||
},
|
||||
)
|
||||
|
||||
val ok = client.publishAndConfirm(event, setOf(relayUrl))
|
||||
assertEquals(false, ok, "expired-on-arrival event must be rejected")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonExpiredEventIsStoredAndRetrievable() =
|
||||
runBlocking {
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
val future = TimeUtils.now() + 3600
|
||||
val event =
|
||||
signer.sign(
|
||||
TextNoteEvent.build("not-yet-expired") {
|
||||
expiration(future)
|
||||
},
|
||||
)
|
||||
|
||||
assertEquals(true, client.publishAndConfirm(event, setOf(relayUrl)))
|
||||
|
||||
val fetched =
|
||||
client.fetchFirst(
|
||||
relay = relayUrl,
|
||||
filter = Filter(ids = listOf(event.id)),
|
||||
)
|
||||
assertNotNull(fetched)
|
||||
assertEquals(event.id, fetched.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteExpiredEventsRemovesThemFromStore() =
|
||||
runBlocking {
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
val now = TimeUtils.now()
|
||||
|
||||
// Two events: one expires in the past, one in the future.
|
||||
// We set the past one to be in the past relative to NOW, but
|
||||
// not so far that the original `insertEvent` rejects on
|
||||
// arrival. The trick: use a createdAt slightly in the past
|
||||
// and an expiration also slightly in the past, both still
|
||||
// recent enough that the relay accepts the event but
|
||||
// `deleteExpiredEvents()` will sweep it.
|
||||
//
|
||||
// Actually `insertEvent` rejects on `isExpired()` —
|
||||
// [past] timestamps fail at insert. So instead we publish a
|
||||
// non-expired event (long-lived) and a barely-non-expired
|
||||
// one (1s out), then sleep 2s and call sweep.
|
||||
val longLived =
|
||||
signer.sign(TextNoteEvent.build("keep-me") { expiration(now + 3600) })
|
||||
val shortLived =
|
||||
signer.sign(TextNoteEvent.build("sweep-me") { expiration(now + 1) })
|
||||
|
||||
client.publishAndConfirm(longLived, setOf(relayUrl))
|
||||
client.publishAndConfirm(shortLived, setOf(relayUrl))
|
||||
|
||||
// Both currently in the store.
|
||||
assertNotNull(
|
||||
client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(longLived.id))),
|
||||
)
|
||||
assertNotNull(
|
||||
client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(shortLived.id))),
|
||||
)
|
||||
|
||||
// Wait until shortLived is past its expiration, then sweep.
|
||||
kotlinx.coroutines.delay(1500)
|
||||
hub.getOrCreate(relayUrl).store.deleteExpiredEvents()
|
||||
|
||||
// Long-lived survives.
|
||||
assertNotNull(
|
||||
client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(longLived.id))),
|
||||
)
|
||||
// Short-lived is gone.
|
||||
assertEquals(
|
||||
null,
|
||||
client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(shortLived.id))),
|
||||
"deleteExpiredEvents() must purge the short-lived event",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* 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.relay
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* NIP-62 right-to-vanish:
|
||||
* - A kind-62 event scoped to a relay URL cascades-deletes ALL of the
|
||||
* author's earlier events on that relay.
|
||||
* - After the vanish, attempts to insert OLDER events from that author
|
||||
* are rejected (the SQL `reject_events_on_event_vanish` trigger).
|
||||
* - Newer events from the same author (createdAt > vanish.createdAt)
|
||||
* can still be published — the user is asking the relay to forget
|
||||
* their past, not to ban them.
|
||||
* - A vanish from author A does not affect author B's events.
|
||||
*/
|
||||
class Nip62VanishTest {
|
||||
private lateinit var hub: RelayHub
|
||||
private lateinit var scope: CoroutineScope
|
||||
private lateinit var client: NostrClient
|
||||
private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
hub = RelayHub()
|
||||
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
client = NostrClient(hub, scope)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun teardown() {
|
||||
client.disconnect()
|
||||
scope.cancel()
|
||||
hub.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun vanishCascadesPriorEventsFromSameAuthor() =
|
||||
runBlocking {
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
val now = TimeUtils.now()
|
||||
|
||||
val a = signer.sign(TextNoteEvent.build("first", createdAt = now - 100))
|
||||
val b = signer.sign(TextNoteEvent.build("second", createdAt = now - 50))
|
||||
|
||||
client.publishAndConfirm(a, setOf(relayUrl))
|
||||
client.publishAndConfirm(b, setOf(relayUrl))
|
||||
assertNotNull(client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(a.id))))
|
||||
assertNotNull(client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(b.id))))
|
||||
|
||||
val vanish =
|
||||
signer.sign(
|
||||
RequestToVanishEvent.build(
|
||||
relay = relayUrl,
|
||||
reason = "GDPR cleanup",
|
||||
createdAt = now,
|
||||
),
|
||||
)
|
||||
assertEquals(true, client.publishAndConfirm(vanish, setOf(relayUrl)))
|
||||
|
||||
assertNull(client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(a.id))))
|
||||
assertNull(client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(b.id))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun vanishBlocksReinsertionOfOlderEvents() =
|
||||
runBlocking {
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
val now = TimeUtils.now()
|
||||
|
||||
val vanish =
|
||||
signer.sign(
|
||||
RequestToVanishEvent.build(relay = relayUrl, createdAt = now),
|
||||
)
|
||||
client.publishAndConfirm(vanish, setOf(relayUrl))
|
||||
|
||||
// Older event from the same author must be rejected.
|
||||
val older = signer.sign(TextNoteEvent.build("comeback", createdAt = now - 100))
|
||||
val ok = client.publishAndConfirm(older, setOf(relayUrl))
|
||||
assertEquals(false, ok, "events older than the vanish must be rejected")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun vanishDoesNotAffectOtherAuthors() =
|
||||
runBlocking {
|
||||
val alice = NostrSignerSync(KeyPair())
|
||||
val bob = NostrSignerSync(KeyPair())
|
||||
val now = TimeUtils.now()
|
||||
|
||||
val aliceNote = alice.sign(TextNoteEvent.build("alice", createdAt = now - 100))
|
||||
val bobNote = bob.sign(TextNoteEvent.build("bob", createdAt = now - 100))
|
||||
client.publishAndConfirm(aliceNote, setOf(relayUrl))
|
||||
client.publishAndConfirm(bobNote, setOf(relayUrl))
|
||||
|
||||
val aliceVanish =
|
||||
alice.sign(RequestToVanishEvent.build(relay = relayUrl, createdAt = now))
|
||||
client.publishAndConfirm(aliceVanish, setOf(relayUrl))
|
||||
|
||||
assertNull(client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(aliceNote.id))))
|
||||
val bobStillThere =
|
||||
client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(bobNote.id)))
|
||||
assertEquals(bobNote.id, bobStillThere?.id, "bob's events must survive alice's vanish")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user