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:
Claude
2026-05-07 01:38:42 +00:00
parent e214143def
commit cbe5dbe07b
5 changed files with 885 additions and 0 deletions
@@ -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")
}
}