diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt index dd0e746311..e3cd10d987 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt @@ -20,16 +20,19 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite +import androidx.sqlite.SQLiteException import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertTrue class BasicTest : BaseDBTest() { @@ -253,6 +256,49 @@ class BasicTest : BaseDBTest() { db.assertQuery(note2, Filter(ids = listOf(note2.id))) } + @Test + fun testTransactionRollsBackOnTriggerAbort() = + forEachDB { db -> + // Pre-load a target event and a deletion that blocks re-insertion. + val deletedTarget = signer.sign(TextNoteEvent.build("target", createdAt = 1)) + val deletion = signer.sign(DeletionEvent.build(listOf(deletedTarget), createdAt = 100)) + db.insert(deletion) + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + + val keptInThisTx = signer.sign(TextNoteEvent.build("kept-in-tx", createdAt = 2)) + + // A trigger ABORT in the middle of a multi-insert transaction must + // roll BOTH inserts back — `reject_deleted_events` only undoes its + // own statement, but the wrapping `connection.transaction` extension + // is responsible for ROLLBACK-ing the whole batch. + assertFailsWith { + db.transaction { + insert(keptInThisTx) + insert(deletedTarget) // blocked by reject_deleted_events + } + } + + db.assertQuery(null, Filter(ids = listOf(keptInThisTx.id))) + db.assertQuery(null, Filter(ids = listOf(deletedTarget.id))) + // The deletion stored before the failed transaction must remain. + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + } + + @Test + fun testVacuumAndAnalyseSmoke() = + forEachDB { db -> + // Smoke test: VACUUM and ANALYZE must not throw on a populated DB + // and must leave existing rows intact. Also catches the comments + // for these two functions getting swapped again. + val note = signer.sign(TextNoteEvent.build("vacuum me")) + db.insert(note) + + db.store.analyse() + db.store.vacuum() + + db.assertQuery(note, Filter(ids = listOf(note.id))) + } + @Test fun testSchemaRecreateIsIdempotent() = forEachDB { db -> diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt index 9eb6f2844e..811e9b5323 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt @@ -415,6 +415,99 @@ class DeletionTest : BaseDBTest() { ) } + @Test + fun testDeletionByThirdPartyDoesNothing() = + forEachDB { db -> + val owner = NostrSignerSync() + val stranger = NostrSignerSync() + + val target = owner.sign(TextNoteEvent.build("private")) + db.insert(target) + db.assertQuery(target, Filter(ids = listOf(target.id))) + + // A deletion event from a *different* author must not remove the + // target — only the original author can delete via NIP-09. + val strangerDeletion = stranger.sign(DeletionEvent.build(listOf(target))) + db.insert(strangerDeletion) + + db.assertQuery(target, Filter(ids = listOf(target.id))) + db.assertQuery(strangerDeletion, Filter(ids = listOf(strangerDeletion.id))) + + // The owner's deletion still works after a failed third-party attempt. + val ownerDeletion = owner.sign(DeletionEvent.build(listOf(target))) + db.insert(ownerDeletion) + db.assertQuery(null, Filter(ids = listOf(target.id))) + } + + @Test + fun testKind5CanBeDeletedByAnotherKind5OfSameAuthor() = + forEachDB { db -> + // Pinning current behavior: a kind-5 event is treated like any + // other event for ownership purposes — the same author can issue + // a second deletion that removes the first. Cross-author + // deletion of a kind-5 must NOT work. + val target = signer.sign(TextNoteEvent.build("target")) + val firstDeletion = signer.sign(DeletionEvent.build(listOf(target), createdAt = 100)) + + db.insert(target) + db.insert(firstDeletion) + db.assertQuery(null, Filter(ids = listOf(target.id))) + db.assertQuery(firstDeletion, Filter(ids = listOf(firstDeletion.id))) + + // Stranger cannot delete the kind-5. + val stranger = NostrSignerSync() + val strangerDeletion = + stranger.sign(DeletionEvent.build(listOf(firstDeletion), createdAt = 200)) + db.insert(strangerDeletion) + db.assertQuery(firstDeletion, Filter(ids = listOf(firstDeletion.id))) + + // Same author can delete their previous kind-5. + val secondDeletion = + signer.sign(DeletionEvent.build(listOf(firstDeletion), createdAt = 300)) + db.insert(secondDeletion) + db.assertQuery(null, Filter(ids = listOf(firstDeletion.id))) + db.assertQuery(secondDeletion, Filter(ids = listOf(secondDeletion.id))) + + // First deletion now blocked from re-insertion (own e-tag in event_tags). + assertFailsWith { + db.insert(firstDeletion) + } + } + + @Test + fun testGiftWrapDeletionRequiresRecipient() = + forEachDB { db -> + // GiftWraps key their owner off the p-tag (recipient), since the + // outer wrap is signed by an ephemeral random key per NIP-59. The + // inner author isn't visible to the relay, so only the recipient + // can delete the wrap. + val recipient = NostrSignerSync() + val sender = NostrSignerSync() + val unrelated = NostrSignerSync() + + val inner = sender.sign(TextNoteEvent.build("secret")) + val wrap = GiftWrapEvent.create(inner, recipient.pubKey) + + db.insert(wrap) + db.assertQuery(wrap, Filter(ids = listOf(wrap.id))) + + // Inner author (the "sender") cannot delete a wrap addressed to + // someone else — sender pubkey != wrap's pubkey_owner_hash. + val senderDeletion = sender.sign(DeletionEvent.build(listOf(wrap))) + db.insert(senderDeletion) + db.assertQuery(wrap, Filter(ids = listOf(wrap.id))) + + // An unrelated third party can't delete it either. + val unrelatedDeletion = unrelated.sign(DeletionEvent.build(listOf(wrap))) + db.insert(unrelatedDeletion) + db.assertQuery(wrap, Filter(ids = listOf(wrap.id))) + + // The recipient (whose pubkey matches pubkey_owner_hash) can. + val recipientDeletion = recipient.sign(DeletionEvent.build(listOf(wrap))) + db.insert(recipientDeletion) + db.assertQuery(null, Filter(ids = listOf(wrap.id))) + } + @Test fun testDeletionDoesNotRemoveNewerAddressable() = forEachDB { db -> diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt index 8ba8e41448..c052d6190c 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt @@ -69,6 +69,37 @@ class RightToVanishTest : BaseDBTest() { db.assertQuery(note3, Filter(ids = listOf(note3.id))) } + @Test + fun testVanishForDifferentRelayIsNoOp() = + forEachDB { db -> + // The default EventStore is bound to wss://quartz.local. A vanish + // request that names a *different* relay must not delete the + // user's events on this relay — RightToVanishModule.insert checks + // shouldVanishFrom(relay) and returns without writing event_vanish. + val time = TimeUtils.now() + val note = signer.sign(TextNoteEvent.build("kept", createdAt = time)) + db.insert(note) + db.assertQuery(note, Filter(ids = listOf(note.id))) + + val vanishElsewhere = + signer.sign( + RequestToVanishEvent.build( + "wss://some-other-relay.example".normalizeRelayUrl(), + createdAt = time + 1, + ), + ) + db.insert(vanishElsewhere) + + // The vanish event itself is stored, but it must not delete + // events from this relay nor block re-insertion of new ones. + db.assertQuery(vanishElsewhere, Filter(ids = listOf(vanishElsewhere.id))) + db.assertQuery(note, Filter(ids = listOf(note.id))) + + val newNote = signer.sign(TextNoteEvent.build("still accepted", createdAt = time + 2)) + db.insert(newNote) + db.assertQuery(newNote, Filter(ids = listOf(newNote.id))) + } + @Test fun testInsertDeleteGiftWrap() = forEachDB { db -> diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt index 786d1a7568..dae55cfa68 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt @@ -22,11 +22,16 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.utils.TimeUtils import kotlin.test.Test class SearchTest : BaseDBTest() { + val signer = NostrSignerSync() + companion object { val profile = MetadataEvent( @@ -89,4 +94,39 @@ class SearchTest : BaseDBTest() { db.assertQuery(comment, Filter(search = "testing")) db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing")) } + + @Test + fun testFtsCleanedUpAfterReplaceableRotation() = + forEachDB { db -> + // The fts_foreign_key trigger fires on event_headers DELETE, so + // when an addressable is superseded its FTS row should also be + // removed. Otherwise stale content would keep matching searches. + val time = TimeUtils.now() + val v1 = + signer.sign( + LongTextNoteEvent.build( + "first version uniqalpha", + title = "blog title", + dTag = "fts-rotation", + createdAt = time, + ), + ) + val v2 = + signer.sign( + LongTextNoteEvent.build( + "second version uniqbeta", + title = "blog title", + dTag = "fts-rotation", + createdAt = time + 1, + ), + ) + + db.store.insertEvent(v1) + db.assertQuery(v1, Filter(search = "uniqalpha")) + + db.store.insertEvent(v2) + // v1's FTS row must be gone; v2's content takes over. + db.assertQuery(null, Filter(search = "uniqalpha")) + db.assertQuery(v2, Filter(search = "uniqbeta")) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilderTest.kt new file mode 100644 index 0000000000..3de7d00635 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilderTest.kt @@ -0,0 +1,68 @@ +/* + * 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.nip01Core.store.sqlite.sql + +import kotlin.test.Test +import kotlin.test.assertEquals + +class SqlSelectionBuilderTest { + @Test + fun equalsWithNullProducesIsNull() { + val clause = where { equals("col", null) } + assertEquals("col IS NULL", clause.conditions) + assertEquals(emptyList(), clause.args) + } + + @Test + fun notEqualsWithNullProducesIsNotNull() { + val clause = where { notEquals("col", null) } + assertEquals("col IS NOT NULL", clause.conditions) + assertEquals(emptyList(), clause.args) + } + + @Test + fun notEqualsWithValueProducesPlaceholder() { + val clause = where { notEquals("col", "x") } + assertEquals("col != ?", clause.conditions) + assertEquals(listOf("x"), clause.args) + } + + @Test + fun emptyInBecomesAlwaysFalse() { + val clause = where { isIn("col", emptyList()) } + assertEquals("1 = 0", clause.conditions) + assertEquals(emptyList(), clause.args) + } + + @Test + fun equalsOrInPicksEqualsForSingleton() { + val clause = where { equalsOrIn("col", listOf("x")) } + assertEquals("col = ?", clause.conditions) + assertEquals(listOf("x"), clause.args) + } + + @Test + fun equalsOrInPicksInForMultiple() { + val clause = where { equalsOrIn("col", listOf("a", "b", "c")) } + assertEquals("col IN (?, ?, ?)", clause.conditions) + assertEquals(listOf("a", "b", "c"), clause.args) + } +}