diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServerTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServerTest.kt index a55393b32..5a96efe46 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServerTest.kt +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServerTest.kt @@ -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( + 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?, + ) { + 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( + kotlinx.coroutines.channels.Channel.UNLIMITED, + ) + val gotEose = + kotlinx.coroutines.channels.Channel( + 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?, + ) { + ch.trySend(event) + } + + override fun onEose( + relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl, + forFilters: List?, + ) { + 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") + } } diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip01ComplianceTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip01ComplianceTest.kt index 4a656781c..b6e1398b2 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip01ComplianceTest.kt +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip01ComplianceTest.kt @@ -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(UNLIMITED) + val ch2 = Channel(UNLIMITED) + val eose1 = Channel(UNLIMITED) + val eose2 = Channel(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?, + ) { + ch1.trySend(event) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + 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?, + ) { + ch2.trySend(event) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + 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): Pair, Boolean> { + val ch = Channel(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?, + ) { + ch.trySend(Either.Ev(event)) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + ch.trySend(Either.Eose) + } + }, + ) + val events = mutableListOf() + 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, diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip09DeletionTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip09DeletionTest.kt new file mode 100644 index 000000000..5bd0a1c77 --- /dev/null +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip09DeletionTest.kt @@ -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 { + val ch = kotlinx.coroutines.channels.Channel(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?, + ) { + ch.trySend(Either.Ev(event)) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + ch.trySend(Either.Eose) + } + }, + ) + val events = mutableListOf() + 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", + ) + } +} diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip40ExpirationTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip40ExpirationTest.kt new file mode 100644 index 000000000..535e4944b --- /dev/null +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip40ExpirationTest.kt @@ -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", + ) + } +} diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip62VanishTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip62VanishTest.kt new file mode 100644 index 000000000..201f312c9 --- /dev/null +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip62VanishTest.kt @@ -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") + } +}