Merge branch 'mantra' into claude/groupkeystate-frost-proposal-206090
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
package press.mantra.compose.database.dao
|
||||
|
||||
import androidx.room3.Room
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.database.model.BroadcastNostrEventRequest
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The outbound queue. Nothing else drains this table, so a row it fails to hand back is an
|
||||
* event that is never sent to any relay -- and the failure is silent, because a queue that
|
||||
* returns nothing looks exactly like a queue that is empty.
|
||||
*
|
||||
* That is not hypothetical here. The observer's predicate used to carry a `createdAt > :now`
|
||||
* bound whose `now` was evaluated once, when the Flow was built. Instants persist at second
|
||||
* resolution, so it hid every broadcast enqueued during the observer's own start second -- the
|
||||
* whole profile-creation burst -- along with everything a previous session had left pending.
|
||||
*/
|
||||
class BroadcastNostrEventRequestDaoJvmTest {
|
||||
|
||||
private val db: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
|
||||
@AfterTest
|
||||
fun closeDb() = db.close()
|
||||
|
||||
private val author = "a".repeat(64)
|
||||
|
||||
private suspend fun queue(
|
||||
eventId: String,
|
||||
status: String = "pending",
|
||||
createdAt: Instant = Instant.fromEpochSeconds(1_000),
|
||||
relayURL: String = "wss://relay.example",
|
||||
): Long {
|
||||
val id = eventId.padEnd(64, '0')
|
||||
// BroadcastNostrEventRequest.nostrEventId is a foreign key onto NostrEvent: a broadcast
|
||||
// cannot be queued for an event that was never stored.
|
||||
if (db.nostrEventDao().getNostrEventById(id) == null) {
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = id,
|
||||
pubKey = author,
|
||||
kind = 1,
|
||||
tags = emptyArray(),
|
||||
content = "outbound",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
)
|
||||
}
|
||||
return db.broadcastNostrEventRequestDao().upsert(
|
||||
BroadcastNostrEventRequest(
|
||||
nostrEventId = id,
|
||||
relayURL = relayURL,
|
||||
status = status,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun statusOf(requestId: Long): String =
|
||||
db.broadcastNostrEventRequestDao().getAllBroadcastNostrEventRequests()
|
||||
.single { it.id == requestId }.status
|
||||
|
||||
private suspend fun pendingHead() =
|
||||
db.broadcastNostrEventRequestDao().observeBroadcastNostrEventRequestsByStatus("pending").first()
|
||||
|
||||
/**
|
||||
* A request is flipped to "processing" before the publish is attempted, and a timeout or a
|
||||
* dropped socket leaves it there. Nothing observes "processing" or "failed", so without
|
||||
* this sweep those rows are dead weight and the events never go out.
|
||||
*/
|
||||
@Test
|
||||
fun `an interrupted publish is requeued on startup`() = runBlocking {
|
||||
val processing = queue("1", status = "processing")
|
||||
val failed = queue("2", status = "failed")
|
||||
|
||||
val changed = db.broadcastNostrEventRequestDao()
|
||||
.requeueStaleBroadcastNostrEventRequests(Instant.fromEpochSeconds(2_000))
|
||||
|
||||
assertEquals(2, changed, "both stale rows should have been requeued")
|
||||
assertEquals("pending", statusOf(processing))
|
||||
assertEquals("pending", statusOf(failed))
|
||||
}
|
||||
|
||||
/** A row already pending is not stale; touching it would inflate the reported count. */
|
||||
@Test
|
||||
fun `a pending request is left alone by the sweep`() = runBlocking {
|
||||
queue("1", status = "pending")
|
||||
|
||||
val changed = db.broadcastNostrEventRequestDao()
|
||||
.requeueStaleBroadcastNostrEventRequests(Instant.fromEpochSeconds(2_000))
|
||||
|
||||
assertEquals(0, changed)
|
||||
}
|
||||
|
||||
/**
|
||||
* The reason the sweep is bounded at all. A publish running right now holds its row in
|
||||
* "processing"; requeueing that would hand the same event to a second publish while the
|
||||
* first is still in flight.
|
||||
*/
|
||||
@Test
|
||||
fun `a request newer than the cutoff is left for the process that owns it`() = runBlocking {
|
||||
val inFlight = queue("1", status = "processing", createdAt = Instant.fromEpochSeconds(3_000))
|
||||
|
||||
val changed = db.broadcastNostrEventRequestDao()
|
||||
.requeueStaleBroadcastNostrEventRequests(Instant.fromEpochSeconds(2_000))
|
||||
|
||||
assertEquals(0, changed)
|
||||
assertEquals("processing", statusOf(inFlight))
|
||||
}
|
||||
|
||||
/** The bound is `<=`, so a row stamped exactly on the cutoff second is swept. */
|
||||
@Test
|
||||
fun `a request stamped exactly on the cutoff is requeued`() = runBlocking {
|
||||
val onCutoff = queue("1", status = "processing", createdAt = Instant.fromEpochSeconds(2_000))
|
||||
|
||||
val changed = db.broadcastNostrEventRequestDao()
|
||||
.requeueStaleBroadcastNostrEventRequests(Instant.fromEpochSeconds(2_000))
|
||||
|
||||
assertEquals(1, changed)
|
||||
assertEquals("pending", statusOf(onCutoff))
|
||||
}
|
||||
|
||||
/**
|
||||
* The regression the observer's comment describes. A row enqueued before the observer was
|
||||
* built -- a previous session's leftovers -- has to come back. The old bound compared
|
||||
* against a `now` captured when the Flow was created, so it returned nothing here and the
|
||||
* queue looked empty forever.
|
||||
*/
|
||||
@Test
|
||||
fun `the queue hands back work enqueued before the observer existed`() = runBlocking {
|
||||
val old = queue("1", createdAt = Instant.fromEpochSeconds(1_000))
|
||||
|
||||
val head = assertNotNull(pendingHead(), "a previous session's pending row was not returned")
|
||||
|
||||
assertEquals(old, head.broadcastNostrEventRequest.id)
|
||||
}
|
||||
|
||||
/** Oldest first: the queue drains in the order things were enqueued. */
|
||||
@Test
|
||||
fun `the queue hands back the oldest pending request first`() = runBlocking {
|
||||
queue("2", createdAt = Instant.fromEpochSeconds(3_000))
|
||||
val oldest = queue("1", createdAt = Instant.fromEpochSeconds(1_000))
|
||||
|
||||
assertEquals(oldest, assertNotNull(pendingHead()).broadcastNostrEventRequest.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Instants persist at second resolution, so a burst enqueued in one second is all one
|
||||
* timestamp, and the head of the queue is decided by the `id ASC` tiebreak.
|
||||
*
|
||||
* Note what this test does and does not do. It pins the observable drain order, which is
|
||||
* what a caller depends on. It cannot fail if the tiebreak is deleted: `id` is an
|
||||
* autoGenerate primary key and therefore the rowid, so sqlite's own unspecified ordering
|
||||
* already coincides with it under this plan -- removing `, id ASC` leaves every assertion
|
||||
* here passing. That coincidence is exactly why the explicit tiebreak is worth keeping:
|
||||
* it is not contractual, and an index or a different query plan can change it.
|
||||
*/
|
||||
@Test
|
||||
fun `requests sharing a timestamp drain in insertion order`() = runBlocking {
|
||||
val sameSecond = Instant.fromEpochSeconds(1_000)
|
||||
val first = queue("1", createdAt = sameSecond)
|
||||
queue("2", createdAt = sameSecond)
|
||||
queue("3", createdAt = sameSecond)
|
||||
|
||||
assertEquals(first, assertNotNull(pendingHead()).broadcastNostrEventRequest.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the queue is empty when nothing is pending`() = runBlocking {
|
||||
queue("1", status = "processing")
|
||||
|
||||
assertNull(pendingHead(), "a processing row must not be handed out as pending work")
|
||||
}
|
||||
|
||||
/**
|
||||
* One event is queued once per target relay, so this returns the oldest of several rows
|
||||
* rather than the only one.
|
||||
*/
|
||||
@Test
|
||||
fun `the first request for an event is the oldest of its per-relay rows`() = runBlocking {
|
||||
val eventId = "1".padEnd(64, '0')
|
||||
val oldest = queue("1", createdAt = Instant.fromEpochSeconds(1_000), relayURL = "wss://one.example")
|
||||
queue("1", createdAt = Instant.fromEpochSeconds(2_000), relayURL = "wss://two.example")
|
||||
|
||||
val found = assertNotNull(
|
||||
db.broadcastNostrEventRequestDao().getFirstBroadcastNostrEventRequestByNostrEventId(eventId)
|
||||
)
|
||||
|
||||
assertEquals(oldest, found.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package press.mantra.compose.database.dao
|
||||
|
||||
import androidx.room3.Room
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.database.model.ChatMessage
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.MarmotGroupEvent
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import press.mantra.compose.database.model.Profile
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* `getResolvedMarmotGroupEventIds` is the query a reindex sweep subtracts from the room's
|
||||
* stored group events to decide what to replay, so its answer decides what work the sweep does.
|
||||
* Both ways of being wrong are quiet. Report an event as resolved when it is not and the replay
|
||||
* skips the one event that needed it -- the message stays missing from the feed with nothing
|
||||
* left to trigger another attempt. Report it as unresolved when it is resolved and every sweep
|
||||
* re-decrypts it forever.
|
||||
*
|
||||
* The distinction rests entirely on `messageType NOT IN (:unresolvedTypes)`, where the
|
||||
* unresolved types are the two placeholder lines that stand in for a message still to come
|
||||
* rather than reporting one.
|
||||
*/
|
||||
class ChatMessageDaoJvmTest {
|
||||
|
||||
private val db: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
|
||||
@AfterTest
|
||||
fun closeDb() = db.close()
|
||||
|
||||
private val user = "a".repeat(64)
|
||||
private val other = "b".repeat(64)
|
||||
private val roomOne = "11".repeat(32)
|
||||
private val roomTwo = "22".repeat(32)
|
||||
|
||||
private suspend fun seedRooms() {
|
||||
val nostrEventId = "c".repeat(64)
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = nostrEventId,
|
||||
pubKey = user,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
)
|
||||
db.profileDao().upsert(Profile(publicKey = user, userName = "user", nostrEventId = nostrEventId))
|
||||
listOf(roomOne, roomTwo).forEach { id ->
|
||||
db.chatRoomDao().upsert(
|
||||
ChatRoom(
|
||||
id = id,
|
||||
userPublicKey = user,
|
||||
subject = null,
|
||||
description = null,
|
||||
mlsGroupState = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A stored kind:445 plus its indexed row, which a chat line's foreign key hangs off. */
|
||||
private suspend fun seedGroupEvent(id: String, chatRoomId: String = roomOne): String {
|
||||
val eventId = id.padEnd(64, '0')
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = eventId,
|
||||
pubKey = user,
|
||||
kind = MarmotGroupEvent.KIND,
|
||||
tags = arrayOf(arrayOf("h", chatRoomId)),
|
||||
content = "ciphertext",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
)
|
||||
db.marmotGroupEventDao().upsert(
|
||||
MarmotGroupEvent(
|
||||
id = eventId,
|
||||
userPublicKey = user,
|
||||
publicKey = user,
|
||||
chatRoomId = chatRoomId,
|
||||
signature = "0".repeat(128),
|
||||
encryptedContent = "ciphertext",
|
||||
expiresAt = null,
|
||||
)
|
||||
)
|
||||
return eventId
|
||||
}
|
||||
|
||||
private suspend fun line(
|
||||
groupEventId: String?,
|
||||
messageType: String = ChatMessage.TYPE_DIRECT_MESSAGE,
|
||||
chatRoomId: String = roomOne,
|
||||
content: String = "a line",
|
||||
sender: String = user,
|
||||
createdAt: Instant = Instant.fromEpochSeconds(1_000),
|
||||
): Long = db.chatMessageDao().upsert(
|
||||
ChatMessage(
|
||||
content = content,
|
||||
chatRoomId = chatRoomId,
|
||||
senderPublicKey = sender,
|
||||
isUserMessage = sender == user,
|
||||
giftWrapPayloadId = null,
|
||||
marmotGroupEventId = groupEventId,
|
||||
marmotInnerEventId = null,
|
||||
messageType = messageType,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
)
|
||||
|
||||
private suspend fun resolved(chatRoomId: String = roomOne) =
|
||||
db.chatMessageDao().getResolvedMarmotGroupEventIds(
|
||||
chatRoomId = chatRoomId,
|
||||
unresolvedTypes = ChatMessage.UNRESOLVED_MARMOT_TYPES,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `an event with a real line is resolved`() = runBlocking {
|
||||
seedRooms()
|
||||
val eventId = seedGroupEvent("1")
|
||||
line(eventId)
|
||||
|
||||
assertEquals(listOf(eventId), resolved())
|
||||
}
|
||||
|
||||
/**
|
||||
* The two placeholder types. An undecryptable outer layer stands in for a message that
|
||||
* could not be opened yet, and a pending commit for one whose commit has not arrived --
|
||||
* both are exactly what a replay exists to retry, so neither may count as resolved.
|
||||
*/
|
||||
@Test
|
||||
fun `a placeholder line leaves its event unresolved`() = runBlocking {
|
||||
seedRooms()
|
||||
val undecryptable = seedGroupEvent("1")
|
||||
val pendingCommit = seedGroupEvent("2")
|
||||
line(undecryptable, messageType = ChatMessage.TYPE_UNDECRYPTABLE_OUTER_LAYER)
|
||||
line(pendingCommit, messageType = ChatMessage.TYPE_PENDING_COMMIT)
|
||||
|
||||
val found = resolved()
|
||||
|
||||
assertTrue(undecryptable !in found, "an undecryptable placeholder is not a resolution")
|
||||
assertTrue(pendingCommit !in found, "a pending commit is not a resolution")
|
||||
assertTrue(found.isEmpty())
|
||||
}
|
||||
|
||||
/** The sweep's subtraction: only the placeholder-backed event is left to replay. */
|
||||
@Test
|
||||
fun `a replay is left with exactly the events that still have nothing to show`() = runBlocking {
|
||||
seedRooms()
|
||||
val settled = seedGroupEvent("1")
|
||||
val stillWaiting = seedGroupEvent("2")
|
||||
val neverSeen = seedGroupEvent("3")
|
||||
line(settled)
|
||||
line(stillWaiting, messageType = ChatMessage.TYPE_PENDING_COMMIT)
|
||||
|
||||
val unresolved = listOf(settled, stillWaiting, neverSeen) - resolved().toSet()
|
||||
|
||||
assertEquals(listOf(stillWaiting, neverSeen), unresolved)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lines that are not about a group event -- a NIP-17 direct message, a locally written
|
||||
* line -- carry a null marmotGroupEventId, and `IS NOT NULL` keeps them out. Without it
|
||||
* the result would carry nulls into a set the sweep subtracts with.
|
||||
*/
|
||||
@Test
|
||||
fun `lines with no group event are not reported as resolutions`() = runBlocking {
|
||||
seedRooms()
|
||||
line(groupEventId = null)
|
||||
|
||||
assertTrue(resolved().isEmpty())
|
||||
}
|
||||
|
||||
/** A sweep runs per room, so another room's resolved lines must not shorten its work. */
|
||||
@Test
|
||||
fun `resolutions are scoped to their own room`() = runBlocking {
|
||||
seedRooms()
|
||||
val mine = seedGroupEvent("1", chatRoomId = roomOne)
|
||||
val theirs = seedGroupEvent("2", chatRoomId = roomTwo)
|
||||
line(mine, chatRoomId = roomOne)
|
||||
line(theirs, chatRoomId = roomTwo)
|
||||
|
||||
assertEquals(listOf(mine), resolved(roomOne))
|
||||
assertEquals(listOf(theirs), resolved(roomTwo))
|
||||
}
|
||||
|
||||
/**
|
||||
* A placeholder replaced by a real line resolves the event. This is the transition the
|
||||
* sweep is trying to cause, so it has to be visible through this query -- the row is
|
||||
* upserted in place, keeping its id.
|
||||
*/
|
||||
@Test
|
||||
fun `a placeholder that becomes a real line resolves its event`() = runBlocking {
|
||||
seedRooms()
|
||||
val eventId = seedGroupEvent("1")
|
||||
val lineId = line(eventId, messageType = ChatMessage.TYPE_PENDING_COMMIT)
|
||||
assertTrue(resolved().isEmpty(), "precondition: the placeholder is unresolved")
|
||||
|
||||
val placeholder = assertNotNull(db.chatMessageDao().getChatMessagesByMarmotGroupEventId(eventId))
|
||||
db.chatMessageDao().upsert(
|
||||
placeholder.copy(id = lineId, messageType = ChatMessage.TYPE_DIRECT_MESSAGE, content = "decrypted")
|
||||
)
|
||||
|
||||
assertEquals(listOf(eventId), resolved())
|
||||
}
|
||||
|
||||
/**
|
||||
* The single-row lookups order newest first, which is what makes them a sensible "the line
|
||||
* for this event" rather than whichever row sqlite reached first.
|
||||
*/
|
||||
@Test
|
||||
fun `the newest line wins for a group event`() = runBlocking {
|
||||
seedRooms()
|
||||
val eventId = seedGroupEvent("1")
|
||||
line(eventId, content = "older", createdAt = Instant.fromEpochSeconds(1_000))
|
||||
line(eventId, content = "newer", createdAt = Instant.fromEpochSeconds(2_000))
|
||||
|
||||
assertEquals("newer", db.chatMessageDao().getChatMessagesByMarmotGroupEventId(eventId)?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a senders lines are counted per room`() = runBlocking {
|
||||
seedRooms()
|
||||
line(groupEventId = null, sender = user, chatRoomId = roomOne)
|
||||
line(groupEventId = null, sender = user, chatRoomId = roomOne)
|
||||
line(groupEventId = null, sender = other, chatRoomId = roomOne)
|
||||
line(groupEventId = null, sender = user, chatRoomId = roomTwo)
|
||||
|
||||
assertEquals(2, db.chatMessageDao().countChatMessagesBySenderPublicKey(roomOne, user))
|
||||
assertEquals(1, db.chatMessageDao().countChatMessagesBySenderPublicKey(roomOne, other))
|
||||
assertEquals(0, db.chatMessageDao().countChatMessagesBySenderPublicKey(roomTwo, other))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package press.mantra.compose.database.dao
|
||||
|
||||
import androidx.room3.Room
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.DkgParticipantMessage
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import press.mantra.compose.database.model.Profile
|
||||
import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The state a DKG ritual is resumed from. Two properties here decide whether a ceremony can
|
||||
* finish, and neither is visible to the compiler.
|
||||
*
|
||||
* The first is that a participant gets one message per round, enforced by the composite key
|
||||
* (sessionId, participantPublicKey, kind) rather than by any code that writes to it. Rounds
|
||||
* advance on `countMessagesByKind` reaching the participant count, so if a redelivered message
|
||||
* added a second row instead of replacing the first, the count would reach the threshold with
|
||||
* fewer real participants than the ritual requires -- and the ritual would proceed on a set it
|
||||
* never actually assembled.
|
||||
*
|
||||
* The second is that a key-holding session needs *both* the threshold public key and the secret
|
||||
* share. A ceremony that stored one and not the other is a failed ceremony, and offering it as
|
||||
* a signing key would mean attempting to sign with half a result.
|
||||
*/
|
||||
class DkgSessionDaoJvmTest {
|
||||
|
||||
private val db: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
|
||||
@AfterTest
|
||||
fun closeDb() = db.close()
|
||||
|
||||
private val user = KeyPair().pubKey.toHexKey()
|
||||
private val alice = KeyPair().pubKey.toHexKey()
|
||||
private val bob = KeyPair().pubKey.toHexKey()
|
||||
private val roomOne = "11".repeat(32)
|
||||
private val roomTwo = "22".repeat(32)
|
||||
|
||||
private val hostKeyKind = 1
|
||||
private val round1Kind = 2
|
||||
|
||||
private suspend fun seedRooms() {
|
||||
val nostrEventId = "c".repeat(64)
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = nostrEventId,
|
||||
pubKey = user,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
)
|
||||
db.profileDao().upsert(Profile(publicKey = user, userName = "user", nostrEventId = nostrEventId))
|
||||
listOf(roomOne, roomTwo).forEach { id ->
|
||||
db.chatRoomDao().upsert(
|
||||
ChatRoom(
|
||||
id = id,
|
||||
userPublicKey = user,
|
||||
subject = null,
|
||||
description = null,
|
||||
mlsGroupState = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun session(
|
||||
id: String,
|
||||
chatRoomId: String = roomOne,
|
||||
createdAt: Instant = Instant.fromEpochSeconds(1_000),
|
||||
thresholdPublicKey: String? = null,
|
||||
secretShare: String? = null,
|
||||
stage: DkgRitualStage = DkgRitualStage.COLLECTING_HOST_KEYS,
|
||||
): DkgSession = DkgSession(
|
||||
id = id,
|
||||
chatRoomId = chatRoomId,
|
||||
coordinatorPublicKey = user,
|
||||
userPublicKey = user,
|
||||
threshold = 2,
|
||||
participantCount = 3,
|
||||
stage = stage,
|
||||
hostPublicKey = user,
|
||||
round1Random = "aa".repeat(32),
|
||||
round2AuxRandom = "bb".repeat(32),
|
||||
thresholdPublicKey = thresholdPublicKey,
|
||||
secretShare = secretShare,
|
||||
createdAt = createdAt,
|
||||
).also { db.dkgSessionDao().upsert(it) }
|
||||
|
||||
private suspend fun message(
|
||||
sessionId: String,
|
||||
participant: String,
|
||||
kind: Int = hostKeyKind,
|
||||
payload: String = "aa".repeat(32),
|
||||
createdAt: Instant = Instant.fromEpochSeconds(1_000),
|
||||
) = db.dkgSessionDao().upsert(
|
||||
DkgParticipantMessage(
|
||||
sessionId = sessionId,
|
||||
participantPublicKey = participant,
|
||||
kind = kind,
|
||||
payload = payload,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* "A group may have abandoned earlier attempts; the live one is the most recent." A resume
|
||||
* that picked up an abandoned ritual would wait forever on participants who have moved on
|
||||
* to the newer one.
|
||||
*/
|
||||
@Test
|
||||
fun `the live ritual for a room is the newest one`() = runBlocking {
|
||||
seedRooms()
|
||||
session("abandoned", createdAt = Instant.fromEpochSeconds(1_000))
|
||||
session("live", createdAt = Instant.fromEpochSeconds(2_000))
|
||||
|
||||
assertEquals("live", db.dkgSessionDao().getLatestSessionForChatRoom(roomOne)?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rituals in another room are not offered as this rooms`() = runBlocking {
|
||||
seedRooms()
|
||||
session("theirs", chatRoomId = roomTwo, createdAt = Instant.fromEpochSeconds(2_000))
|
||||
session("mine", chatRoomId = roomOne, createdAt = Instant.fromEpochSeconds(1_000))
|
||||
|
||||
assertEquals("mine", db.dkgSessionDao().getLatestSessionForChatRoom(roomOne)?.id)
|
||||
assertNull(db.dkgSessionDao().getLatestSessionForChatRoom("33".repeat(32)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Half a ceremony is not a key. Both columns have to be present, because a session holding
|
||||
* only a threshold public key never derived a share to sign with, and one holding only a
|
||||
* share has no group key to sign against.
|
||||
*/
|
||||
@Test
|
||||
fun `only a ceremony that produced both halves counts as key holding`() = runBlocking {
|
||||
seedRooms()
|
||||
session("complete", thresholdPublicKey = "dd".repeat(32), secretShare = "ee".repeat(32))
|
||||
session("keyOnly", thresholdPublicKey = "dd".repeat(32), secretShare = null)
|
||||
session("shareOnly", thresholdPublicKey = null, secretShare = "ee".repeat(32))
|
||||
session("neither")
|
||||
|
||||
val holding = db.dkgSessionDao().getKeyHoldingSessions().map { it.id }
|
||||
|
||||
assertEquals(listOf("complete"), holding)
|
||||
}
|
||||
|
||||
/** Newest first, so the most recent ceremony's key is the one reached for. */
|
||||
@Test
|
||||
fun `key holding sessions come back newest first`() = runBlocking {
|
||||
seedRooms()
|
||||
session("older", createdAt = Instant.fromEpochSeconds(1_000), thresholdPublicKey = "dd".repeat(32), secretShare = "ee".repeat(32))
|
||||
session("newer", createdAt = Instant.fromEpochSeconds(2_000), thresholdPublicKey = "dd".repeat(32), secretShare = "ee".repeat(32))
|
||||
|
||||
assertEquals(listOf("newer", "older"), db.dkgSessionDao().getKeyHoldingSessions().map { it.id })
|
||||
}
|
||||
|
||||
/**
|
||||
* The property the round counter depends on. A relay redelivers, and a participant may
|
||||
* resend; either way the composite key means the row is replaced, not added. If it were
|
||||
* added, `countMessagesByKind` would reach the participant count while one member had
|
||||
* still never been heard from.
|
||||
*/
|
||||
@Test
|
||||
fun `a resent round message replaces the participants earlier one`() = runBlocking {
|
||||
seedRooms()
|
||||
session("s1")
|
||||
message("s1", alice, payload = "aa".repeat(32))
|
||||
|
||||
message("s1", alice, payload = "bb".repeat(32))
|
||||
|
||||
assertEquals(1, db.dkgSessionDao().countMessagesByKind("s1", hostKeyKind))
|
||||
assertEquals(
|
||||
"bb".repeat(32),
|
||||
db.dkgSessionDao().getMessage("s1", hostKeyKind, alice)?.payload,
|
||||
"the resent payload should have replaced the earlier one",
|
||||
)
|
||||
}
|
||||
|
||||
/** One participant can hold a message in each round without either replacing the other. */
|
||||
@Test
|
||||
fun `the same participant holds one message per round`() = runBlocking {
|
||||
seedRooms()
|
||||
session("s1")
|
||||
message("s1", alice, kind = hostKeyKind, payload = "aa".repeat(32))
|
||||
message("s1", alice, kind = round1Kind, payload = "cc".repeat(32))
|
||||
|
||||
assertEquals(1, db.dkgSessionDao().countMessagesByKind("s1", hostKeyKind))
|
||||
assertEquals(1, db.dkgSessionDao().countMessagesByKind("s1", round1Kind))
|
||||
assertEquals(
|
||||
"aa".repeat(32),
|
||||
db.dkgSessionDao().getMessage("s1", hostKeyKind, alice)?.payload,
|
||||
"the round-1 message overwrote the host key message",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered by participant public key, not by arrival. Every device has to assemble a round
|
||||
* in the same order to compute the same thing, and arrival order differs per device.
|
||||
*/
|
||||
@Test
|
||||
fun `a rounds messages are ordered by participant rather than by arrival`() = runBlocking {
|
||||
seedRooms()
|
||||
session("s1")
|
||||
val ordered = listOf(user, alice, bob).sorted()
|
||||
// Written in an order deliberately unlike the sorted one.
|
||||
message("s1", ordered[2], createdAt = Instant.fromEpochSeconds(1_000))
|
||||
message("s1", ordered[0], createdAt = Instant.fromEpochSeconds(2_000))
|
||||
message("s1", ordered[1], createdAt = Instant.fromEpochSeconds(3_000))
|
||||
|
||||
val found = db.dkgSessionDao().getMessagesByKind("s1", hostKeyKind).map { it.participantPublicKey }
|
||||
|
||||
assertEquals(ordered, found, "a round must assemble in canonical participant order")
|
||||
}
|
||||
|
||||
/** Rounds and sessions are counted apart, which is what makes the counter a round gate. */
|
||||
@Test
|
||||
fun `messages are counted per session and per round`() = runBlocking {
|
||||
seedRooms()
|
||||
session("s1")
|
||||
session("s2")
|
||||
message("s1", alice, kind = hostKeyKind)
|
||||
message("s1", bob, kind = hostKeyKind)
|
||||
message("s1", alice, kind = round1Kind)
|
||||
message("s2", alice, kind = hostKeyKind)
|
||||
|
||||
assertEquals(2, db.dkgSessionDao().countMessagesByKind("s1", hostKeyKind))
|
||||
assertEquals(1, db.dkgSessionDao().countMessagesByKind("s1", round1Kind))
|
||||
assertEquals(1, db.dkgSessionDao().countMessagesByKind("s2", hostKeyKind))
|
||||
assertNull(db.dkgSessionDao().getMessage("s2", round1Kind, alice))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package press.mantra.compose.database.dao
|
||||
|
||||
import androidx.room3.Room
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.FrostSignerMessage
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import press.mantra.compose.database.model.Profile
|
||||
import press.mantra.compose.database.model.types.FrostSigningStage
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The signing counterpart to [DkgSessionDaoJvmTest], and it differs from the DKG in one way
|
||||
* that shapes every query here: "unlike a DKG a group signs repeatedly, so there is no single
|
||||
* current one to observe". Sessions accumulate, which makes room scoping and ordering
|
||||
* load-bearing rather than incidental.
|
||||
*
|
||||
* The duplicate-suppression property is the same and matters for the same reason. The composite
|
||||
* key (sessionId, signerPublicKey, kind) is what makes a redelivered nonce or partial signature
|
||||
* replace its predecessor instead of adding a row, and `countMessagesByKind` is what decides
|
||||
* that enough signers have answered. A second row for one signer would let a session cross its
|
||||
* threshold while short a real participant.
|
||||
*/
|
||||
class FrostSigningSessionDaoJvmTest {
|
||||
|
||||
private val db: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
|
||||
@AfterTest
|
||||
fun closeDb() = db.close()
|
||||
|
||||
private val user = KeyPair().pubKey.toHexKey()
|
||||
private val alice = KeyPair().pubKey.toHexKey()
|
||||
private val bob = KeyPair().pubKey.toHexKey()
|
||||
private val roomOne = "11".repeat(32)
|
||||
private val roomTwo = "22".repeat(32)
|
||||
|
||||
private val nonceKind = 1
|
||||
private val partialSignatureKind = 2
|
||||
|
||||
private suspend fun seedRooms() {
|
||||
val nostrEventId = "c".repeat(64)
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = nostrEventId,
|
||||
pubKey = user,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
)
|
||||
db.profileDao().upsert(Profile(publicKey = user, userName = "user", nostrEventId = nostrEventId))
|
||||
listOf(roomOne, roomTwo).forEach { id ->
|
||||
db.chatRoomDao().upsert(
|
||||
ChatRoom(
|
||||
id = id,
|
||||
userPublicKey = user,
|
||||
subject = null,
|
||||
description = null,
|
||||
mlsGroupState = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun session(
|
||||
id: String,
|
||||
chatRoomId: String = roomOne,
|
||||
createdAt: Instant = Instant.fromEpochSeconds(1_000),
|
||||
stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES,
|
||||
signature: String? = null,
|
||||
): FrostSigningSession = FrostSigningSession(
|
||||
id = id,
|
||||
chatRoomId = chatRoomId,
|
||||
coordinatorPublicKey = user,
|
||||
userPublicKey = user,
|
||||
dkgSessionId = "dkg-1",
|
||||
threshold = 2,
|
||||
participantCount = 3,
|
||||
signerId = 1,
|
||||
stage = stage,
|
||||
unsignedEventJson = "{}",
|
||||
eventId = "ff".repeat(32),
|
||||
nonceRandom = "aa".repeat(32),
|
||||
signature = signature,
|
||||
createdAt = createdAt,
|
||||
).also { db.frostSigningSessionDao().upsert(it) }
|
||||
|
||||
private suspend fun message(
|
||||
sessionId: String,
|
||||
signer: String,
|
||||
kind: Int = nonceKind,
|
||||
payload: String = "aa".repeat(32),
|
||||
createdAt: Instant = Instant.fromEpochSeconds(1_000),
|
||||
) = db.frostSigningSessionDao().upsert(
|
||||
FrostSignerMessage(
|
||||
sessionId = sessionId,
|
||||
signerPublicKey = signer,
|
||||
kind = kind,
|
||||
payload = payload,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a signing session reads back by its id`() = runBlocking {
|
||||
seedRooms()
|
||||
session("s1", stage = FrostSigningStage.COLLECTING_PARTIAL_SIGNATURES)
|
||||
|
||||
val found = assertNotNull(db.frostSigningSessionDao().getSessionById("s1"))
|
||||
|
||||
assertEquals(FrostSigningStage.COLLECTING_PARTIAL_SIGNATURES, found.stage)
|
||||
assertNull(db.frostSigningSessionDao().getSessionById("nope"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sessions accumulate rather than replacing each other, so a room keeps a history and the
|
||||
* newest is the one a resume cares about.
|
||||
*/
|
||||
@Test
|
||||
fun `a rooms signing sessions accumulate newest first`() = runBlocking {
|
||||
seedRooms()
|
||||
session("first", createdAt = Instant.fromEpochSeconds(1_000))
|
||||
session("second", createdAt = Instant.fromEpochSeconds(2_000))
|
||||
session("third", createdAt = Instant.fromEpochSeconds(3_000))
|
||||
|
||||
assertEquals(
|
||||
listOf("third", "second", "first"),
|
||||
db.frostSigningSessionDao().getSessionsForChatRoom(roomOne).map { it.id },
|
||||
)
|
||||
assertEquals("third", db.frostSigningSessionDao().getLatestSessionForChatRoom(roomOne)?.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Signing happens in the #admins room, and a device can be in more than one. A session from
|
||||
* another room appearing here would have a signer answering a request its group never made.
|
||||
*/
|
||||
@Test
|
||||
fun `sessions are scoped to their own room`() = runBlocking {
|
||||
seedRooms()
|
||||
session("mine", chatRoomId = roomOne)
|
||||
session("theirs", chatRoomId = roomTwo, createdAt = Instant.fromEpochSeconds(9_000))
|
||||
|
||||
assertEquals(listOf("mine"), db.frostSigningSessionDao().getSessionsForChatRoom(roomOne).map { it.id })
|
||||
assertEquals("mine", db.frostSigningSessionDao().getLatestSessionForChatRoom(roomOne)?.id)
|
||||
assertEquals(emptyList(), db.frostSigningSessionDao().getSessionsForChatRoom("33".repeat(32)))
|
||||
}
|
||||
|
||||
/**
|
||||
* The threshold gate. A redelivered nonce must replace the signer's earlier one rather than
|
||||
* add a row, or the count crosses the threshold with fewer signers than the session
|
||||
* requires -- and the aggregation proceeds on a set that was never assembled.
|
||||
*/
|
||||
@Test
|
||||
fun `a resent nonce replaces the signers earlier one`() = runBlocking {
|
||||
seedRooms()
|
||||
session("s1")
|
||||
message("s1", alice, payload = "aa".repeat(32))
|
||||
|
||||
message("s1", alice, payload = "bb".repeat(32))
|
||||
|
||||
assertEquals(1, db.frostSigningSessionDao().countMessagesByKind("s1", nonceKind))
|
||||
assertEquals(
|
||||
"bb".repeat(32),
|
||||
db.frostSigningSessionDao().getMessage("s1", nonceKind, alice)?.payload,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A signer contributes to both rounds of a session -- a nonce and then a partial signature
|
||||
* -- and the kind in the composite key is what keeps the second from overwriting the first.
|
||||
*/
|
||||
@Test
|
||||
fun `a signer holds a nonce and a partial signature at once`() = runBlocking {
|
||||
seedRooms()
|
||||
session("s1")
|
||||
message("s1", alice, kind = nonceKind, payload = "aa".repeat(32))
|
||||
message("s1", alice, kind = partialSignatureKind, payload = "cc".repeat(32))
|
||||
|
||||
assertEquals(1, db.frostSigningSessionDao().countMessagesByKind("s1", nonceKind))
|
||||
assertEquals(1, db.frostSigningSessionDao().countMessagesByKind("s1", partialSignatureKind))
|
||||
assertEquals(
|
||||
"aa".repeat(32),
|
||||
db.frostSigningSessionDao().getMessage("s1", nonceKind, alice)?.payload,
|
||||
"the partial signature overwrote the nonce",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `messages are counted per session and per round`() = runBlocking {
|
||||
seedRooms()
|
||||
session("s1")
|
||||
session("s2")
|
||||
message("s1", alice, kind = nonceKind)
|
||||
message("s1", bob, kind = nonceKind)
|
||||
message("s2", alice, kind = nonceKind)
|
||||
|
||||
assertEquals(2, db.frostSigningSessionDao().countMessagesByKind("s1", nonceKind))
|
||||
assertEquals(1, db.frostSigningSessionDao().countMessagesByKind("s2", nonceKind))
|
||||
assertEquals(0, db.frostSigningSessionDao().countMessagesByKind("s1", partialSignatureKind))
|
||||
assertNull(db.frostSigningSessionDao().getMessage("s1", nonceKind, user))
|
||||
}
|
||||
|
||||
/**
|
||||
* Worth recording because it is the one place these two DAOs disagree: FROST orders a
|
||||
* round's messages by `createdAt`, where the DKG orders the same query by participant
|
||||
* public key. Arrival order is per-device, so this ordering is not canonical across the
|
||||
* group the way the DKG's is. Pinned as it stands rather than assumed to be either
|
||||
* deliberate or a slip -- a caller that needs a canonical signer order has to impose one.
|
||||
*/
|
||||
@Test
|
||||
fun `a rounds messages are ordered by arrival, unlike the dkg`() = runBlocking {
|
||||
seedRooms()
|
||||
session("s1")
|
||||
val byKey = listOf(alice, bob).sorted()
|
||||
message("s1", byKey[1], createdAt = Instant.fromEpochSeconds(1_000))
|
||||
message("s1", byKey[0], createdAt = Instant.fromEpochSeconds(2_000))
|
||||
|
||||
val found = db.frostSigningSessionDao().getMessagesByKind("s1", nonceKind).map { it.signerPublicKey }
|
||||
|
||||
assertEquals(listOf(byKey[1], byKey[0]), found, "FROST returns a round in arrival order")
|
||||
}
|
||||
|
||||
/** A finished session keeps its signature, which is what a resume reads to avoid re-signing. */
|
||||
@Test
|
||||
fun `a completed session keeps its signature`() = runBlocking {
|
||||
seedRooms()
|
||||
session("s1", stage = FrostSigningStage.COMPLETE, signature = "ab".repeat(32))
|
||||
|
||||
val found = assertNotNull(db.frostSigningSessionDao().getSessionById("s1"))
|
||||
|
||||
assertEquals(FrostSigningStage.COMPLETE, found.stage)
|
||||
assertEquals("ab".repeat(32), found.signature)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package press.mantra.compose.database.dao
|
||||
|
||||
import androidx.room3.Room
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.MantraArtifact
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import press.mantra.compose.database.model.Profile
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.nostr.nip30303.DialectEvent
|
||||
import press.mantra.compose.nostr.nip30303.SubmissionEvent
|
||||
import press.mantra.compose.repository.MantraRepository
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The nip30303 create-entity flow: every `add*` on [MantraDao] writes the entity and queues a
|
||||
* [SubmissionEvent] carrying the same event for the group, in one transaction.
|
||||
*
|
||||
* The invariant worth a test is the one `rumorOf` exists for. The entity's id is computed by
|
||||
* the `Mantra*.from*EventTemplate` factory and the payload's id is computed here, from the same
|
||||
* template -- so the row on disk and the payload on the wire are meant to be *the same event*,
|
||||
* not two copies of one. Nothing enforces that: both sides compile independently, both produce
|
||||
* a plausible 64-character id, and a divergence would only show up as a group that can never
|
||||
* match an arriving submission to the entity it was supposed to create.
|
||||
*/
|
||||
class MantraDaoJvmTest {
|
||||
|
||||
private val db: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
|
||||
@AfterTest
|
||||
fun closeDb() = db.close()
|
||||
|
||||
private val author = "a".repeat(64)
|
||||
private val roomId = "b".repeat(64)
|
||||
|
||||
/** ChatRoom -> Profile -> NostrEvent, the foreign key chain a room hangs off. */
|
||||
private suspend fun seedRoom(): LocalChatRoom {
|
||||
val nostrEventId = "c".repeat(64)
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = nostrEventId,
|
||||
pubKey = author,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
)
|
||||
db.profileDao().upsert(Profile(publicKey = author, userName = "author", nostrEventId = nostrEventId))
|
||||
val chatRoom = ChatRoom(
|
||||
id = roomId,
|
||||
userPublicKey = author,
|
||||
subject = "a translation room",
|
||||
description = null,
|
||||
mlsGroupState = null,
|
||||
)
|
||||
db.chatRoomDao().upsert(chatRoom)
|
||||
return LocalChatRoom(chatRoom = chatRoom)
|
||||
}
|
||||
|
||||
private suspend fun submissions() = db.marmotInnerEventDao()
|
||||
.getByChatRoomAndKinds(roomId, listOf(SubmissionEvent.KIND))
|
||||
|
||||
private suspend fun addDialect(name: String = "Sesotho") = db.mantraDao().addDialect(
|
||||
localChatRoom = seedRoom(),
|
||||
name = name,
|
||||
country = "ZA",
|
||||
language = "st",
|
||||
userPublicKey = author,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a dialect is stored and queued for the group in one call`() = runBlocking {
|
||||
val dialect = assertNotNull(addDialect(), "addDialect returned null")
|
||||
|
||||
assertEquals("Sesotho", dialect.name)
|
||||
assertEquals(roomId, dialect.chatRoomId)
|
||||
assertEquals(author, dialect.publicKey)
|
||||
assertEquals(1, submissions().size, "the dialect was stored without being submitted")
|
||||
}
|
||||
|
||||
/**
|
||||
* The `rumorOf` invariant, asserted across the seam: the submission records the payload's
|
||||
* id, and that id has to be the entity's own. If the two factories ever compute it
|
||||
* differently the group receives a submission whose payload matches nothing on disk.
|
||||
*/
|
||||
@Test
|
||||
fun `the stored dialect and the submitted payload are the same event`() = runBlocking {
|
||||
val dialect = assertNotNull(addDialect())
|
||||
|
||||
val submission = submissions().single()
|
||||
|
||||
assertEquals(
|
||||
dialect.id,
|
||||
submission.payloadEventId,
|
||||
"the entity id and the submitted payload id have diverged",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The envelope is not the payload. A submission's own id is the SubmissionEvent's, which is
|
||||
* why `deleteByPayloadEventId` exists -- a superseded nip30303 event cannot be un-queued by
|
||||
* its own id.
|
||||
*/
|
||||
@Test
|
||||
fun `the submission is an envelope with its own id`() = runBlocking {
|
||||
val dialect = assertNotNull(addDialect())
|
||||
|
||||
val submission = submissions().single()
|
||||
|
||||
assertEquals(SubmissionEvent.KIND, submission.kind)
|
||||
assertTrue(
|
||||
submission.id != dialect.id,
|
||||
"the envelope must not share the payload's id, or it could not be told apart",
|
||||
)
|
||||
assertEquals(author, submission.publicKey)
|
||||
assertEquals(roomId, submission.chatRoomId)
|
||||
}
|
||||
|
||||
/**
|
||||
* `marmotGroupEventId == null` is what makes the row unprocessed, which is the state the
|
||||
* outbound pipeline selects on to encrypt it into a kind:445. Filed as processed, it would
|
||||
* be stored and never sent, and the group would never learn about the dialect.
|
||||
*/
|
||||
@Test
|
||||
fun `the submission is queued unprocessed for the outbound pipeline`() = runBlocking {
|
||||
addDialect()
|
||||
|
||||
val submission = submissions().single()
|
||||
|
||||
assertNull(submission.marmotGroupEventId, "a queued submission must not look processed")
|
||||
}
|
||||
|
||||
/** The room's feed reads ChatMessage, so an added entity has to leave a line behind. */
|
||||
@Test
|
||||
fun `a chat message line is written so the room shows the change`() = runBlocking {
|
||||
addDialect(name = "isiZulu")
|
||||
|
||||
val submission = submissions().single()
|
||||
val chatMessage = db.chatMessageDao().getChatMessagesByMarmotInnerEventId(submission.id)
|
||||
|
||||
assertNotNull(chatMessage, "no chat line was written for the submission")
|
||||
assertEquals("Added isiZulu as a dialect", chatMessage.content)
|
||||
assertEquals(roomId, chatMessage.chatRoomId)
|
||||
assertTrue(chatMessage.isUserMessage)
|
||||
}
|
||||
|
||||
/**
|
||||
* A second entity type through the same path, because the store-and-submit shape is the
|
||||
* convention every `add*` follows rather than something `addDialect` does on its own.
|
||||
*/
|
||||
@Test
|
||||
fun `an artifact version follows the same store-and-submit shape`() = runBlocking {
|
||||
val localChatRoom = seedRoom()
|
||||
val dialect = assertNotNull(
|
||||
db.mantraDao().addDialect(
|
||||
localChatRoom = localChatRoom,
|
||||
name = "Setswana",
|
||||
country = "ZA",
|
||||
language = "tn",
|
||||
userPublicKey = author,
|
||||
)
|
||||
)
|
||||
val artifactId = "d".repeat(64)
|
||||
db.mantraArtifactDao().upsert(
|
||||
MantraArtifact(
|
||||
id = artifactId,
|
||||
publicKey = author,
|
||||
name = "a text",
|
||||
url = "https://example.invalid/text",
|
||||
visibility = MantraRepository.DEFAULT_VISIBILITY,
|
||||
dialectId = dialect.id,
|
||||
license = MantraRepository.DEFAULT_LICENSE,
|
||||
chatRoomId = roomId,
|
||||
signature = "",
|
||||
)
|
||||
)
|
||||
|
||||
val version = assertNotNull(
|
||||
db.mantraDao().addArtifactVersion(
|
||||
localChatRoom = localChatRoom,
|
||||
artifactId = artifactId,
|
||||
versionLabel = "first draft",
|
||||
userPublicKey = author,
|
||||
),
|
||||
"addArtifactVersion returned null",
|
||||
)
|
||||
|
||||
val versionSubmission = assertNotNull(
|
||||
submissions().singleOrNull { it.payloadEventId == version.id },
|
||||
"the artifact version was stored without a matching submission",
|
||||
)
|
||||
assertEquals(SubmissionEvent.KIND, versionSubmission.kind)
|
||||
assertNull(versionSubmission.marmotGroupEventId)
|
||||
assertEquals(2, submissions().size, "the dialect and the version should each be queued")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package press.mantra.compose.database.dao
|
||||
|
||||
import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519
|
||||
import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider
|
||||
import com.vitorpamplona.quartz.marmot.mls.crypto.X25519
|
||||
import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage
|
||||
import com.vitorpamplona.quartz.marmot.mls.tree.Capabilities
|
||||
import com.vitorpamplona.quartz.marmot.mls.tree.Credential
|
||||
import com.vitorpamplona.quartz.marmot.mls.tree.Extension
|
||||
import com.vitorpamplona.quartz.marmot.mls.tree.LeafNode
|
||||
import com.vitorpamplona.quartz.marmot.mls.tree.LeafNodeSource
|
||||
import com.vitorpamplona.quartz.marmot.mls.tree.Lifetime
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import press.mantra.compose.database.model.MarmotKeyPackage
|
||||
|
||||
/**
|
||||
* A real MLS key package for [publicKey], built the same way
|
||||
* `DatabaseMarmotRepository.generateKeyPackage` builds this device's own. Entirely local:
|
||||
* three key generations, a signed leaf node and a signed key package. No relay, no network,
|
||||
* nothing to stub.
|
||||
*/
|
||||
internal fun marmotKeyPackageFor(publicKey: HexKey): MarmotKeyPackage {
|
||||
val initKp = X25519.generateKeyPair()
|
||||
val encKp = X25519.generateKeyPair()
|
||||
val sigKp = Ed25519.generateKeyPair()
|
||||
val now = TimeUtils.now()
|
||||
|
||||
val unsignedLeaf = LeafNode(
|
||||
encryptionKey = encKp.publicKey,
|
||||
signatureKey = sigKp.publicKey,
|
||||
credential = Credential.Basic(publicKey.hexToByteArray()),
|
||||
capabilities = Capabilities(
|
||||
// LastResort, then NostrGroupData -- the group's RequiredCapabilities rejects a
|
||||
// leaf that does not advertise both, so a fixture without them is refused at
|
||||
// addMember rather than at decode.
|
||||
extensions = listOf(0x000A, 0xF2EE),
|
||||
proposals = listOf(0x000A),
|
||||
),
|
||||
leafNodeSource = LeafNodeSource.KEY_PACKAGE,
|
||||
lifetime = Lifetime(notBefore = now, notAfter = now + 60L * 60L * 24L * 90L),
|
||||
extensions = emptyList(),
|
||||
signature = ByteArray(0),
|
||||
)
|
||||
val leafNode = unsignedLeaf.copy(
|
||||
signature = MlsCryptoProvider.signWithLabel(
|
||||
sigKp.privateKey,
|
||||
"LeafNodeTBS",
|
||||
unsignedLeaf.encodeTbs(groupId = null, leafIndex = null),
|
||||
),
|
||||
)
|
||||
|
||||
val unsigned = MlsKeyPackage(
|
||||
initKey = initKp.publicKey,
|
||||
leafNode = leafNode,
|
||||
extensions = listOf(Extension(extensionType = 0x000A, extensionData = ByteArray(0))),
|
||||
signature = ByteArray(0),
|
||||
)
|
||||
val keyPackage = unsigned.copy(
|
||||
signature = MlsCryptoProvider.signWithLabel(
|
||||
sigKp.privateKey,
|
||||
"KeyPackageTBS",
|
||||
unsigned.encodeTbs(),
|
||||
),
|
||||
)
|
||||
|
||||
return MarmotKeyPackage(
|
||||
id = publicKey,
|
||||
publicKey = publicKey,
|
||||
tlsEncodedMarmotKeyPackage = keyPackage.toTlsBytes(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package press.mantra.compose.database.dao
|
||||
|
||||
import androidx.room3.Room
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.MarmotKeyPackage
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import press.mantra.compose.database.model.Profile
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.nostr.Relays
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
||||
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import press.mantra.compose.exceptions.MarmotMissingChatGroupException
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The membership guards on [MarmotOutboundDao].
|
||||
*
|
||||
* Both entry points that change a group's membership begin by restoring the MLS state off the
|
||||
* ChatRoom row, and a room restored from an inbound gift wrap has none -- there is nothing to
|
||||
* add a member to. The comment on `inviteMemberToChatRoom` says the point of the throw is to
|
||||
* "say so instead of silently doing nothing and letting the caller report success", which is a
|
||||
* claim about behaviour and therefore testable: a guard that returned quietly would still
|
||||
* compile, still look like it worked, and leave a room whose members believe someone was
|
||||
* invited.
|
||||
*
|
||||
* Past the guard, the tests build a real MLS group and a real peer key package with
|
||||
* [marmotKeyPackageFor], so the commit, the epoch advance and its persistence are exercised
|
||||
* rather than described. Nothing there needs a relay: a key package is three local key
|
||||
* generations and two signatures.
|
||||
*/
|
||||
class MarmotOutboundDaoJvmTest {
|
||||
|
||||
private val db: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
|
||||
@AfterTest
|
||||
fun closeDb() = db.close()
|
||||
|
||||
private val user = KeyPair().pubKey.toHexKey()
|
||||
private val peer = KeyPair().pubKey.toHexKey()
|
||||
private val roomId = "b".repeat(64)
|
||||
|
||||
/**
|
||||
* A room with `mlsGroupState = null` -- exactly the shape a room restored from an inbound
|
||||
* gift wrap has, which is the case the guard exists for.
|
||||
*/
|
||||
private suspend fun seedStatelessRoom(): LocalChatRoom {
|
||||
val nostrEventId = "c".repeat(64)
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = nostrEventId,
|
||||
pubKey = user,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
)
|
||||
db.profileDao().upsert(Profile(publicKey = user, userName = "user", nostrEventId = nostrEventId))
|
||||
val chatRoom = ChatRoom(
|
||||
id = roomId,
|
||||
userPublicKey = user,
|
||||
subject = "a restored room",
|
||||
description = null,
|
||||
mlsGroupState = null,
|
||||
)
|
||||
db.chatRoomDao().upsert(chatRoom)
|
||||
return LocalChatRoom(chatRoom = chatRoom)
|
||||
}
|
||||
|
||||
/**
|
||||
* Never decoded: the guard throws before the tests using it reach the MLS layer, so the
|
||||
* bytes only have to exist. The tests past the guard use [marmotKeyPackageFor] instead.
|
||||
*/
|
||||
private fun keyPackage() = MarmotKeyPackage(
|
||||
id = "d".repeat(64),
|
||||
publicKey = peer,
|
||||
tlsEncodedMarmotKeyPackage = ByteArray(0),
|
||||
)
|
||||
|
||||
/**
|
||||
* A room holding real MLS state, as a room this device created would.
|
||||
*
|
||||
* The peer gets a Profile row because Participant.participantPublicKey is a foreign key
|
||||
* onto it, and a successful invite writes a Participant. The guard tests above never reach
|
||||
* that write, which is why only they can get away without one.
|
||||
*/
|
||||
private suspend fun seedMlsRoom(): LocalChatRoom {
|
||||
val stateless = seedStatelessRoom()
|
||||
val peerEventId = "e".repeat(64)
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = peerEventId,
|
||||
pubKey = peer,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
)
|
||||
db.profileDao().upsert(Profile(publicKey = peer, userName = "peer", nostrEventId = peerEventId))
|
||||
val mlsGroup = MlsGroup.create(
|
||||
identity = user.hexToByteArray(),
|
||||
initialExtensions = listOf(
|
||||
MarmotGroupData.bootstrap(
|
||||
nostrGroupId = roomId,
|
||||
creatorPubKey = user,
|
||||
outboxRelays = Relays.DefaultDMRelayList.map { it.url },
|
||||
).toExtension()
|
||||
),
|
||||
)
|
||||
val chatRoom = stateless.chatRoom.copy(
|
||||
mlsGroupState = mlsGroup.saveState().encodeTls().toHex()
|
||||
)
|
||||
db.chatRoomDao().upsert(chatRoom)
|
||||
return LocalChatRoom(chatRoom = chatRoom)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inviting into a room with no mls state is refused rather than ignored`() = runBlocking<Unit> {
|
||||
val localChatRoom = seedStatelessRoom()
|
||||
|
||||
assertFailsWith<MarmotMissingChatGroupException> {
|
||||
db.marmotOutboundDao().inviteMemberToChatRoom(
|
||||
localChatRoom = localChatRoom,
|
||||
peerPublicKey = peer,
|
||||
peerKeyPackage = keyPackage(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The guard has to come before the Participant write, not after. `sealGiftWrapPayload`
|
||||
* walks a room's participants to decide who to wrap a Welcome for, so a participant row
|
||||
* left behind by a failed invite would make the room look like it has a member that no MLS
|
||||
* group knows about.
|
||||
*/
|
||||
@Test
|
||||
fun `a refused invite leaves no participant behind`() = runBlocking {
|
||||
val localChatRoom = seedStatelessRoom()
|
||||
|
||||
runCatching {
|
||||
db.marmotOutboundDao().inviteMemberToChatRoom(
|
||||
localChatRoom = localChatRoom,
|
||||
peerPublicKey = peer,
|
||||
peerKeyPackage = keyPackage(),
|
||||
)
|
||||
}
|
||||
|
||||
val participants = db.participantDao().findParticipantsByChatRoomId(roomId)
|
||||
assertTrue(
|
||||
participants.none { it.participantPublicKey == peer },
|
||||
"the invitee was persisted despite the invite being refused",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adding members to a room with no mls state is refused`() = runBlocking<Unit> {
|
||||
val localChatRoom = seedStatelessRoom()
|
||||
|
||||
assertFailsWith<MarmotMissingChatGroupException> {
|
||||
db.marmotOutboundDao().addMembersToChatRoom(
|
||||
localChatRoom = localChatRoom,
|
||||
peers = listOf(peer to keyPackage()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The empty-batch guard returns before the MLS state is even looked at, so it must not
|
||||
* throw on the same stateless room the two tests above reject. Adding nobody is not a
|
||||
* failure to add somebody.
|
||||
*/
|
||||
@Test
|
||||
fun `adding no members is not an error even without mls state`() = runBlocking {
|
||||
val localChatRoom = seedStatelessRoom()
|
||||
|
||||
val failed = db.marmotOutboundDao().addMembersToChatRoom(
|
||||
localChatRoom = localChatRoom,
|
||||
peers = emptyList(),
|
||||
)
|
||||
|
||||
assertEquals(emptyList(), failed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Past the guard. The invitee is persisted before the Welcome is sealed, because
|
||||
* sealGiftWrapPayload walks the room's participants to decide who to wrap for -- without
|
||||
* the row the Welcome produced no gift wraps at all and sat unsealed forever.
|
||||
*/
|
||||
@Test
|
||||
fun `inviting a member into a real group persists the invitee`() = runBlocking {
|
||||
val localChatRoom = seedMlsRoom()
|
||||
|
||||
db.marmotOutboundDao().inviteMemberToChatRoom(
|
||||
localChatRoom = localChatRoom,
|
||||
peerPublicKey = peer,
|
||||
peerKeyPackage = marmotKeyPackageFor(peer),
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
db.participantDao().findParticipantsByChatRoomId(roomId)
|
||||
.any { it.participantPublicKey == peer },
|
||||
"the invitee was not persisted",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The epoch advance has to reach the database. `addMember` moves the in-memory group to
|
||||
* the next epoch; without saving it back the creator keeps encrypting under the old one --
|
||||
* which the new member cannot decrypt -- and the next invite re-derives from stale state
|
||||
* and produces a conflicting commit.
|
||||
*/
|
||||
@Test
|
||||
fun `inviting a member persists the advanced group state`() = runBlocking {
|
||||
val localChatRoom = seedMlsRoom()
|
||||
val stateBefore = localChatRoom.chatRoom.mlsGroupState
|
||||
|
||||
db.marmotOutboundDao().inviteMemberToChatRoom(
|
||||
localChatRoom = localChatRoom,
|
||||
peerPublicKey = peer,
|
||||
peerKeyPackage = marmotKeyPackageFor(peer),
|
||||
)
|
||||
|
||||
val stateAfter = assertNotNull(db.chatRoomDao().findChatRoomById(roomId)).chatRoom.mlsGroupState
|
||||
assertNotNull(stateAfter)
|
||||
assertTrue(stateAfter != stateBefore, "the advanced epoch was never written back")
|
||||
val group = assertNotNull(
|
||||
db.chatRoomDao().findChatRoomById(roomId)!!.chatRoom.toMlsGroup(),
|
||||
"the persisted state no longer restores",
|
||||
)
|
||||
assertEquals(2, group.members().size.toInt(), "the invitee is not in the restored group")
|
||||
}
|
||||
|
||||
/**
|
||||
* The epoch the group is leaving is retained on the way past, so messages already sent
|
||||
* under it stay readable. Asserted here rather than only in the retention-window tests,
|
||||
* because this is the call that actually writes one.
|
||||
*/
|
||||
@Test
|
||||
fun `inviting a member retains the epoch being left behind`() = runBlocking {
|
||||
val localChatRoom = seedMlsRoom()
|
||||
|
||||
db.marmotOutboundDao().inviteMemberToChatRoom(
|
||||
localChatRoom = localChatRoom,
|
||||
peerPublicKey = peer,
|
||||
peerKeyPackage = marmotKeyPackageFor(peer),
|
||||
)
|
||||
|
||||
val retained = db.marmotRetainedEpochSecretDao()
|
||||
.getMarmotRetainedEpochSecretForChatRoomId(roomId)
|
||||
assertEquals(1, retained.size, "the pre-commit epoch was not retained")
|
||||
assertEquals(0L, retained.single().epoch, "a freshly created group is at epoch 0")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package press.mantra.compose.database.dao
|
||||
|
||||
import androidx.room3.Room
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.MarmotRetainedEpochSecret
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import press.mantra.compose.database.model.Profile
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Retained epoch secrets are what lets a member read a message sent under an epoch the group
|
||||
* has since moved past. Both directions of getting this wrong are bad and neither shows up as
|
||||
* an error: keep too few and old messages become permanently unreadable, keep too many and
|
||||
* secrets that should have been dropped stay on disk.
|
||||
*
|
||||
* The whole policy is one strict `<` in a query and an IGNORE on an insert, and nothing else
|
||||
* checks either.
|
||||
*/
|
||||
class MarmotRetainedEpochSecretDaoJvmTest {
|
||||
|
||||
private val db: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
|
||||
@AfterTest
|
||||
fun closeDb() = db.close()
|
||||
|
||||
private val user = "a".repeat(64)
|
||||
private val roomOne = "11".repeat(32)
|
||||
private val roomTwo = "22".repeat(32)
|
||||
|
||||
private suspend fun seedRooms() {
|
||||
val nostrEventId = "c".repeat(64)
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = nostrEventId,
|
||||
pubKey = user,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
)
|
||||
db.profileDao().upsert(Profile(publicKey = user, userName = "user", nostrEventId = nostrEventId))
|
||||
listOf(roomOne, roomTwo).forEach { id ->
|
||||
db.chatRoomDao().upsert(
|
||||
ChatRoom(
|
||||
id = id,
|
||||
userPublicKey = user,
|
||||
subject = null,
|
||||
description = null,
|
||||
mlsGroupState = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun retain(
|
||||
epoch: Long,
|
||||
chatRoomId: String = roomOne,
|
||||
secret: Byte = 1,
|
||||
) = MarmotRetainedEpochSecret(
|
||||
chatRoomId = chatRoomId,
|
||||
epoch = epoch,
|
||||
senderDataSecret = byteArrayOf(secret),
|
||||
encryptionSecret = byteArrayOf(secret),
|
||||
leafCount = 2,
|
||||
).also { db.marmotRetainedEpochSecretDao().insert(it) }
|
||||
|
||||
private suspend fun defenestratable(cutoff: Long, chatRoomId: String = roomOne) =
|
||||
db.marmotRetainedEpochSecretDao()
|
||||
.getDefenestratableMarmotRetainedEpochSecretForChatRoomId(chatRoomId, cutoff)
|
||||
|
||||
/**
|
||||
* The cutoff is strict. An epoch equal to it is still inside the retention window and
|
||||
* dropping it makes every message sent under that epoch unreadable -- an off-by-one here
|
||||
* destroys data rather than merely wasting space.
|
||||
*/
|
||||
@Test
|
||||
fun `the epoch on the cutoff is kept and only older ones can be dropped`() = runBlocking {
|
||||
seedRooms()
|
||||
retain(epoch = 3)
|
||||
retain(epoch = 4)
|
||||
retain(epoch = 5)
|
||||
|
||||
val droppable = defenestratable(cutoff = 4).map { it.epoch }.toSet()
|
||||
|
||||
assertEquals(setOf(3L), droppable, "only epochs strictly below the cutoff are droppable")
|
||||
assertTrue(4L !in droppable, "the epoch on the cutoff is still within the window")
|
||||
assertTrue(5L !in droppable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing is droppable when the cutoff precedes every retained epoch`() = runBlocking {
|
||||
seedRooms()
|
||||
retain(epoch = 7)
|
||||
|
||||
assertTrue(defenestratable(cutoff = 7).isEmpty())
|
||||
assertTrue(defenestratable(cutoff = 0).isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* Rooms advance epochs independently, so a sweep driven by one room's cutoff must never
|
||||
* reach another room's secrets. This query feeds a delete, so a leak here is not a stale
|
||||
* read -- it is another room losing its history.
|
||||
*/
|
||||
@Test
|
||||
fun `one rooms cutoff never reaches another rooms secrets`() = runBlocking {
|
||||
seedRooms()
|
||||
retain(epoch = 1, chatRoomId = roomOne)
|
||||
retain(epoch = 1, chatRoomId = roomTwo)
|
||||
|
||||
val droppable = defenestratable(cutoff = 9, chatRoomId = roomOne)
|
||||
|
||||
assertEquals(1, droppable.size)
|
||||
assertTrue(droppable.all { it.chatRoomId == roomOne })
|
||||
assertEquals(
|
||||
1,
|
||||
db.marmotRetainedEpochSecretDao().getMarmotRetainedEpochSecretForChatRoomId(roomTwo).size,
|
||||
"the other room's secret must be untouched",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert is IGNORE over the composite key (chatRoomId, epoch). Re-processing a commit --
|
||||
* a redelivery, or a replay -- must not overwrite the retained secret with a
|
||||
* re-derivation, because the stored one is what actually decrypts the messages already on
|
||||
* disk.
|
||||
*/
|
||||
@Test
|
||||
fun `re-retaining an epoch keeps the secret already stored`() = runBlocking {
|
||||
seedRooms()
|
||||
retain(epoch = 1, secret = 1)
|
||||
|
||||
retain(epoch = 1, secret = 9)
|
||||
|
||||
val stored = db.marmotRetainedEpochSecretDao()
|
||||
.getMarmotRetainedEpochSecretForChatRoomId(roomOne)
|
||||
assertEquals(1, stored.size, "the composite key should have kept this to one row")
|
||||
assertContentEquals(
|
||||
byteArrayOf(1),
|
||||
stored.single().encryptionSecret,
|
||||
"the original secret was overwritten by a re-derivation",
|
||||
)
|
||||
}
|
||||
|
||||
/** The same epoch number in two rooms is two different secrets, not a conflict. */
|
||||
@Test
|
||||
fun `the same epoch in two rooms is retained separately`() = runBlocking {
|
||||
seedRooms()
|
||||
retain(epoch = 1, chatRoomId = roomOne, secret = 1)
|
||||
retain(epoch = 1, chatRoomId = roomTwo, secret = 2)
|
||||
|
||||
assertContentEquals(
|
||||
byteArrayOf(1),
|
||||
db.marmotRetainedEpochSecretDao()
|
||||
.getMarmotRetainedEpochSecretForChatRoomId(roomOne).single().encryptionSecret,
|
||||
)
|
||||
assertContentEquals(
|
||||
byteArrayOf(2),
|
||||
db.marmotRetainedEpochSecretDao()
|
||||
.getMarmotRetainedEpochSecretForChatRoomId(roomTwo).single().encryptionSecret,
|
||||
)
|
||||
}
|
||||
|
||||
/** Defenestration removes what the sweep selected and nothing else. */
|
||||
@Test
|
||||
fun `defenestrating drops only the rows handed to it`() = runBlocking {
|
||||
seedRooms()
|
||||
retain(epoch = 1)
|
||||
retain(epoch = 2)
|
||||
val kept = retain(epoch = 3)
|
||||
|
||||
db.marmotRetainedEpochSecretDao().defenestrate(defenestratable(cutoff = 3))
|
||||
|
||||
val remaining = db.marmotRetainedEpochSecretDao()
|
||||
.getMarmotRetainedEpochSecretForChatRoomId(roomOne)
|
||||
assertEquals(listOf(kept.epoch), remaining.map { it.epoch })
|
||||
}
|
||||
|
||||
/**
|
||||
* The room's secrets are reachable as a set, which is what a rejoin or a full replay reads
|
||||
* before deciding what it can still decrypt.
|
||||
*/
|
||||
@Test
|
||||
fun `a rooms retained epochs are all readable`() = runBlocking {
|
||||
seedRooms()
|
||||
retain(epoch = 1)
|
||||
retain(epoch = 2)
|
||||
|
||||
val all = db.marmotRetainedEpochSecretDao().getMarmotRetainedEpochSecretForChatRoomId(roomOne)
|
||||
|
||||
assertEquals(setOf(1L, 2L), all.map { it.epoch }.toSet())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package press.mantra.compose.database.dao
|
||||
|
||||
import androidx.room3.Room
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.database.model.BroadcastNostrEventRequest
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import press.mantra.compose.database.model.Profile
|
||||
import press.mantra.compose.database.model.UnsignedNostrEvent
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* [NostrDao] is the funnel every event passes through, inbound and outbound, so its two
|
||||
* decisions are load-bearing for everything downstream: which of two copies of an event wins,
|
||||
* and what survives when the enrichment that follows a write fails.
|
||||
*
|
||||
* Both were reasoned about in comments rather than asserted. The publish path carries a
|
||||
* description of a bug that shipped -- indexing sharing the commit's transaction, so any throw
|
||||
* in it rolled back `signedAt` as well, leaving the notary to re-select the same unsigned row
|
||||
* forever and never sign anything queued behind it, including the MLS key package that goes
|
||||
* last.
|
||||
*/
|
||||
class NostrDaoJvmTest {
|
||||
|
||||
private val db: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
|
||||
@AfterTest
|
||||
fun closeDb() = db.close()
|
||||
|
||||
private val keyPair = KeyPair()
|
||||
private val author = keyPair.pubKey.toHexKey()
|
||||
private val relay = "wss://relay.example"
|
||||
|
||||
/** A kind nothing dispatches on, so these tests see the funnel and not a kind handler. */
|
||||
private val inertKind = 31_337
|
||||
|
||||
private fun event(
|
||||
id: String,
|
||||
createdAt: Instant,
|
||||
content: String = "original",
|
||||
kind: Int = inertKind,
|
||||
pubKey: String = author,
|
||||
unsignedNostrEventId: Long? = null,
|
||||
) = NostrEvent(
|
||||
id = id.padEnd(64, '0'),
|
||||
pubKey = pubKey,
|
||||
kind = kind,
|
||||
tags = emptyArray(),
|
||||
content = content,
|
||||
sig = "0".repeat(128),
|
||||
createdAt = createdAt,
|
||||
unsignedNostrEventId = unsignedNostrEventId,
|
||||
)
|
||||
|
||||
private suspend fun store(nostrEvent: NostrEvent) = db.nostrDao().storeNostrEvent(
|
||||
nostrEvent = nostrEvent,
|
||||
relayURL = relay,
|
||||
synchronizationRelayURLs = listOf(relay),
|
||||
level = 0,
|
||||
activeKeyPair = keyPair,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a first sighting of an event is stored`() = runBlocking {
|
||||
val incoming = event("1", Instant.fromEpochSeconds(1_000))
|
||||
|
||||
store(incoming)
|
||||
|
||||
assertEquals("original", db.nostrEventDao().getNostrEventById(incoming.id)?.content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Same id, later timestamp: the newer copy wins. Relays redeliver and negentropy re-syncs,
|
||||
* so an event arrives repeatedly and the funnel has to be idempotent in the right
|
||||
* direction.
|
||||
*/
|
||||
@Test
|
||||
fun `a strictly newer copy of an event replaces the stored one`() = runBlocking {
|
||||
val first = event("1", Instant.fromEpochSeconds(1_000), content = "original")
|
||||
store(first)
|
||||
|
||||
store(first.copy(createdAt = Instant.fromEpochSeconds(2_000), content = "newer"))
|
||||
|
||||
assertEquals("newer", db.nostrEventDao().getNostrEventById(first.id)?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an older copy of an event is ignored`() = runBlocking {
|
||||
val first = event("1", Instant.fromEpochSeconds(2_000), content = "original")
|
||||
store(first)
|
||||
|
||||
store(first.copy(createdAt = Instant.fromEpochSeconds(1_000), content = "older"))
|
||||
|
||||
assertEquals("original", db.nostrEventDao().getNostrEventById(first.id)?.content)
|
||||
}
|
||||
|
||||
/**
|
||||
* The boundary. The comparison is a strict `>`, so a redelivery of the *same* event -- same
|
||||
* id, same timestamp, which is what a second relay hands over -- is a no-op rather than a
|
||||
* rewrite.
|
||||
*/
|
||||
@Test
|
||||
fun `a redelivery at the same timestamp is a no-op`() = runBlocking {
|
||||
val first = event("1", Instant.fromEpochSeconds(1_000), content = "original")
|
||||
store(first)
|
||||
|
||||
store(first.copy(content = "from another relay"))
|
||||
|
||||
assertEquals("original", db.nostrEventDao().getNostrEventById(first.id)?.content)
|
||||
}
|
||||
|
||||
/**
|
||||
* An event from an author with no profile leaves a placeholder behind rather than nothing.
|
||||
* The placeholder is stamped GENESIS_AT, which is the marker the sync path looks for --
|
||||
* without the row there is no record that this pubkey was ever seen and needs fetching.
|
||||
*/
|
||||
@Test
|
||||
fun `an unknown author gets a placeholder profile to be synced later`() = runBlocking {
|
||||
val stranger = "f".repeat(64)
|
||||
|
||||
store(event("1", Instant.fromEpochSeconds(1_000), pubKey = stranger))
|
||||
|
||||
val profile = db.profileDao().getProfileByPublicKey(stranger)
|
||||
assertNotNull(profile, "no placeholder profile was created for an unseen author")
|
||||
assertEquals("LOADING...", profile.displayName)
|
||||
}
|
||||
|
||||
/**
|
||||
* The documented split. `commitPublishedNostrEvent` is the durable half and indexing is
|
||||
* best-effort enrichment in its own transaction, so a throw in indexing must leave the
|
||||
* commit standing.
|
||||
*
|
||||
* Indexing is made to fail here the way the code itself would fail it: publishing with no
|
||||
* target relays reaches `relayURLs.first()` inside the try, which throws. The assertion is
|
||||
* that everything the durable half wrote is still there afterwards -- above all `signedAt`,
|
||||
* because the notary drains one unsigned row at a time and a row whose `signedAt` was
|
||||
* rolled back is re-selected forever, blocking every event queued behind it.
|
||||
*/
|
||||
@Test
|
||||
fun `a failure while indexing does not roll back the published event`() = runBlocking<Unit> {
|
||||
val unsignedId = db.unsignedNostrEventDao().upsert(
|
||||
UnsignedNostrEvent(
|
||||
pubKey = author,
|
||||
kind = inertKind,
|
||||
tags = emptyArray(),
|
||||
content = "to be published",
|
||||
)
|
||||
)
|
||||
val unsigned = db.unsignedNostrEventDao().getAUnsignedNostrEvents().single { it.id == unsignedId }
|
||||
val signed = event("1", Instant.fromEpochSeconds(1_000), unsignedNostrEventId = unsignedId)
|
||||
|
||||
db.nostrDao().publishNostrEvent(
|
||||
unsignedNostrEvent = unsigned,
|
||||
nostrEvent = signed,
|
||||
relayURLs = emptyList(),
|
||||
activeKeyPair = keyPair,
|
||||
)
|
||||
|
||||
assertNotNull(
|
||||
db.unsignedNostrEventDao().getAUnsignedNostrEvents().single { it.id == unsignedId }.signedAt,
|
||||
"signedAt was rolled back, so the notary would re-select this row forever",
|
||||
)
|
||||
assertNotNull(
|
||||
db.nostrEventDao().getNostrEventById(signed.id),
|
||||
"the signed event was rolled back with the indexing failure",
|
||||
)
|
||||
}
|
||||
|
||||
/** The happy path of the same split, so the test above is not passing for the wrong reason. */
|
||||
@Test
|
||||
fun `a published event is stored and queued for every target relay`() = runBlocking {
|
||||
val unsignedId = db.unsignedNostrEventDao().upsert(
|
||||
UnsignedNostrEvent(
|
||||
pubKey = author,
|
||||
kind = inertKind,
|
||||
tags = emptyArray(),
|
||||
content = "to be published",
|
||||
)
|
||||
)
|
||||
val unsigned = db.unsignedNostrEventDao().getAUnsignedNostrEvents().single { it.id == unsignedId }
|
||||
val signed = event("1", Instant.fromEpochSeconds(1_000), unsignedNostrEventId = unsignedId)
|
||||
val relays = listOf("wss://one.example", "wss://two.example")
|
||||
|
||||
db.nostrDao().publishNostrEvent(
|
||||
unsignedNostrEvent = unsigned,
|
||||
nostrEvent = signed,
|
||||
relayURLs = relays,
|
||||
activeKeyPair = keyPair,
|
||||
)
|
||||
|
||||
assertNotNull(db.nostrEventDao().getNostrEventById(signed.id))
|
||||
val queued = db.broadcastNostrEventRequestDao().getAllBroadcastNostrEventRequests()
|
||||
.filter { it.nostrEventId == signed.id }
|
||||
assertEquals(
|
||||
relays.toSet(),
|
||||
queued.map { it.relayURL }.toSet(),
|
||||
"a broadcast request is queued per target relay",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rescheduling re-queues the broadcast and, where the event is a group message that already
|
||||
* has a chat line, re-links the two. Without the relation the line has no delivery state to
|
||||
* read and stays looking unsent no matter how the retry goes.
|
||||
*/
|
||||
@Test
|
||||
fun `rescheduling relinks a broadcast to the chat line it belongs to`() = runBlocking {
|
||||
val groupEventId = "e".repeat(64)
|
||||
seedRoomWithChatLine(marmotGroupEventId = groupEventId)
|
||||
|
||||
db.nostrDao().rescheduleBroadcastNostrEventRequests(
|
||||
listOf(BroadcastNostrEventRequest(nostrEventId = groupEventId, relayURL = relay))
|
||||
)
|
||||
|
||||
val request = assertNotNull(
|
||||
db.broadcastNostrEventRequestDao().getFirstBroadcastNostrEventRequestByNostrEventId(groupEventId),
|
||||
"the broadcast request was not queued",
|
||||
)
|
||||
val chatMessage = assertNotNull(
|
||||
db.chatMessageDao().getChatMessagesByMarmotGroupEventId(groupEventId)
|
||||
)
|
||||
val relation = assertNotNull(
|
||||
db.chatMessageBroadcastNostrEventRequestRelationDao()
|
||||
.getChatMessageBroadcastNostrEventRequestRelationByBroadcastRequest(request.id),
|
||||
"the re-queued broadcast was not linked back to its chat line",
|
||||
)
|
||||
assertEquals(chatMessage.id, relation.chatMessageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* The relation is conditional; the queueing is not. An event with no chat line -- anything
|
||||
* that is not a group message -- must still be re-queued for broadcast.
|
||||
*
|
||||
* The event itself has to exist: `BroadcastNostrEventRequest.nostrEventId` is a foreign key
|
||||
* onto NostrEvent, so "no chat line" is the only thing missing here. Writing this test
|
||||
* against an id that was never stored fails with SQLite 787 instead, which is worth knowing
|
||||
* -- a caller cannot schedule a broadcast for an event it has not saved.
|
||||
*/
|
||||
@Test
|
||||
fun `rescheduling an event with no chat line still queues the broadcast`() = runBlocking {
|
||||
val plainEventId = "9".repeat(64)
|
||||
db.nostrEventDao().upsert(event(plainEventId, Instant.fromEpochSeconds(1_000)))
|
||||
|
||||
db.nostrDao().rescheduleBroadcastNostrEventRequests(
|
||||
listOf(BroadcastNostrEventRequest(nostrEventId = plainEventId, relayURL = relay))
|
||||
)
|
||||
|
||||
val request = assertNotNull(
|
||||
db.broadcastNostrEventRequestDao().getFirstBroadcastNostrEventRequestByNostrEventId(plainEventId),
|
||||
"the broadcast was not queued just because there was no chat line to link",
|
||||
)
|
||||
assertNull(db.chatMessageDao().getChatMessagesByMarmotGroupEventId(plainEventId))
|
||||
assertNull(
|
||||
db.chatMessageBroadcastNostrEventRequestRelationDao()
|
||||
.getChatMessageBroadcastNostrEventRequestRelationByBroadcastRequest(request.id),
|
||||
"no chat line means no relation should have been invented",
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun seedRoomWithChatLine(marmotGroupEventId: String) {
|
||||
val profileEventId = "c".repeat(64)
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = profileEventId,
|
||||
pubKey = author,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
)
|
||||
db.profileDao().upsert(Profile(publicKey = author, userName = "author", nostrEventId = profileEventId))
|
||||
val roomId = "b".repeat(64)
|
||||
db.chatRoomDao().upsert(
|
||||
ChatRoom(
|
||||
id = roomId,
|
||||
userPublicKey = author,
|
||||
subject = null,
|
||||
description = null,
|
||||
mlsGroupState = null,
|
||||
)
|
||||
)
|
||||
db.nostrEventDao().upsert(
|
||||
event(marmotGroupEventId, Instant.fromEpochSeconds(1_000), kind = 445)
|
||||
)
|
||||
db.marmotGroupEventDao().upsert(
|
||||
press.mantra.compose.database.model.MarmotGroupEvent(
|
||||
id = marmotGroupEventId,
|
||||
userPublicKey = author,
|
||||
publicKey = author,
|
||||
chatRoomId = roomId,
|
||||
signature = "0".repeat(128),
|
||||
encryptedContent = "ciphertext",
|
||||
expiresAt = null,
|
||||
)
|
||||
)
|
||||
db.chatMessageDao().upsert(
|
||||
press.mantra.compose.database.model.ChatMessage(
|
||||
content = "a sent message",
|
||||
chatRoomId = roomId,
|
||||
senderPublicKey = author,
|
||||
isUserMessage = true,
|
||||
giftWrapPayloadId = null,
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
marmotInnerEventId = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package press.mantra.compose.database.dao
|
||||
|
||||
import androidx.room3.Room
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.database.model.MarmotGroupEvent
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The hand-written queries on [NostrEventDao], as SQLite actually runs them.
|
||||
*
|
||||
* Two of them carry a comment describing a bug that already shipped -- an expiry predicate
|
||||
* that kept exactly the expired rows and dropped the live ones, and a set of queries that
|
||||
* returned the oldest events under a limit instead of the newest. Both were reasoned about in
|
||||
* review and neither was caught by anything that runs, because a wrong `WHERE` clause is
|
||||
* still a valid query returning a plausible list. These pin the corrected behaviour so the
|
||||
* next edit to the predicate has to argue with a failing test.
|
||||
*/
|
||||
class NostrEventDaoJvmTest {
|
||||
|
||||
private val db: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
|
||||
@AfterTest
|
||||
fun closeDb() = db.close()
|
||||
|
||||
private val alice = "a".repeat(64)
|
||||
private val roomOne = "11".repeat(32)
|
||||
private val roomTwo = "22".repeat(32)
|
||||
|
||||
private val epoch = Instant.fromEpochSeconds(0)
|
||||
private val distantFuture = Instant.fromEpochSeconds(4_000_000_000)
|
||||
|
||||
private suspend fun storeEvent(
|
||||
id: String,
|
||||
kind: Int = MarmotGroupEvent.KIND,
|
||||
pubKey: String = alice,
|
||||
createdAt: Instant = Instant.fromEpochSeconds(1_000),
|
||||
tags: Array<Array<String>> = emptyArray(),
|
||||
content: String = "{}",
|
||||
): NostrEvent = NostrEvent(
|
||||
id = id.padEnd(64, '0'),
|
||||
pubKey = pubKey,
|
||||
kind = kind,
|
||||
tags = tags,
|
||||
content = content,
|
||||
sig = "0".repeat(128),
|
||||
createdAt = createdAt,
|
||||
).also { db.nostrEventDao().upsert(it) }
|
||||
|
||||
/**
|
||||
* `MarmotGroupEvent.id` is a foreign key onto `NostrEvent.id`, so the parent row has to
|
||||
* exist first -- the indexed row is a projection of an event that was stored.
|
||||
*/
|
||||
private suspend fun storeGroupEvent(
|
||||
id: String,
|
||||
chatRoomId: String = roomOne,
|
||||
createdAt: Instant = Instant.fromEpochSeconds(1_000),
|
||||
expiresAt: Instant? = null,
|
||||
): MarmotGroupEvent {
|
||||
val event = storeEvent(id, createdAt = createdAt, tags = arrayOf(arrayOf("h", chatRoomId)))
|
||||
return MarmotGroupEvent(
|
||||
id = event.id,
|
||||
userPublicKey = alice,
|
||||
publicKey = alice,
|
||||
chatRoomId = chatRoomId,
|
||||
signature = "0".repeat(128),
|
||||
encryptedContent = "ciphertext",
|
||||
expiresAt = expiresAt,
|
||||
createdAt = createdAt,
|
||||
).also { db.marmotGroupEventDao().upsert(it) }
|
||||
}
|
||||
|
||||
private suspend fun groupEvents(
|
||||
rooms: Array<String> = arrayOf(roomOne),
|
||||
since: Instant = epoch,
|
||||
until: Instant = distantFuture,
|
||||
limit: Int = 100,
|
||||
now: Instant,
|
||||
) = db.nostrEventDao().getMarmotGroupEvents(rooms, since, until, limit, now)
|
||||
|
||||
/**
|
||||
* The regression this query's own comment describes. `expiresAt > :now` keeps what a
|
||||
* relay would still serve; the `expiresAt < :now` it replaced kept precisely the events a
|
||||
* relay had stopped serving and dropped every live one, so the set handed to negentropy
|
||||
* was the complement of the relay's for the whole group-chat sync path.
|
||||
*/
|
||||
@Test
|
||||
fun `an expiring group event is served until it expires and not after`() = runBlocking {
|
||||
val now = Instant.fromEpochSeconds(2_000)
|
||||
val neverExpires = storeGroupEvent("1", expiresAt = null)
|
||||
val stillLive = storeGroupEvent("2", expiresAt = Instant.fromEpochSeconds(2_001))
|
||||
val expired = storeGroupEvent("3", expiresAt = Instant.fromEpochSeconds(1_999))
|
||||
val expiringNow = storeGroupEvent("4", expiresAt = now)
|
||||
|
||||
val found = groupEvents(now = now).map { it.id }
|
||||
|
||||
assertTrue(neverExpires.id in found, "an event with no expiry never stops being served")
|
||||
assertTrue(stillLive.id in found, "an event expiring in the future is still live")
|
||||
assertTrue(expired.id !in found, "an expired event must not be served")
|
||||
assertTrue(
|
||||
expiringNow.id !in found,
|
||||
"the bound is strict: an event expiring exactly now has stopped being served",
|
||||
)
|
||||
}
|
||||
|
||||
/** NIP-01 bounds are inclusive, so an event stamped on either bound is inside the window. */
|
||||
@Test
|
||||
fun `the since and until bounds are inclusive`() = runBlocking {
|
||||
val now = Instant.fromEpochSeconds(10)
|
||||
val before = storeGroupEvent("1", createdAt = Instant.fromEpochSeconds(999))
|
||||
val onSince = storeGroupEvent("2", createdAt = Instant.fromEpochSeconds(1_000))
|
||||
val onUntil = storeGroupEvent("3", createdAt = Instant.fromEpochSeconds(2_000))
|
||||
val after = storeGroupEvent("4", createdAt = Instant.fromEpochSeconds(2_001))
|
||||
|
||||
val found = groupEvents(
|
||||
since = Instant.fromEpochSeconds(1_000),
|
||||
until = Instant.fromEpochSeconds(2_000),
|
||||
now = now,
|
||||
).map { it.id }
|
||||
|
||||
assertEquals(setOf(onSince.id, onUntil.id), found.toSet())
|
||||
assertTrue(before.id !in found)
|
||||
assertTrue(after.id !in found)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only the requested rooms come back`() = runBlocking {
|
||||
val now = Instant.fromEpochSeconds(10)
|
||||
val mine = storeGroupEvent("1", chatRoomId = roomOne)
|
||||
val theirs = storeGroupEvent("2", chatRoomId = roomTwo)
|
||||
|
||||
assertEquals(listOf(mine.id), groupEvents(rooms = arrayOf(roomOne), now = now).map { it.id })
|
||||
assertEquals(
|
||||
setOf(mine.id, theirs.id),
|
||||
groupEvents(rooms = arrayOf(roomOne, roomTwo), now = now).map { it.id }.toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Newest first under a limit, which is the window a relay would hand back. */
|
||||
@Test
|
||||
fun `group events come back newest first and a limit keeps the newest`() = runBlocking {
|
||||
val now = Instant.fromEpochSeconds(10)
|
||||
val oldest = storeGroupEvent("1", createdAt = Instant.fromEpochSeconds(1_000))
|
||||
val middle = storeGroupEvent("2", createdAt = Instant.fromEpochSeconds(2_000))
|
||||
val newest = storeGroupEvent("3", createdAt = Instant.fromEpochSeconds(3_000))
|
||||
|
||||
assertEquals(
|
||||
listOf(newest.id, middle.id, oldest.id),
|
||||
groupEvents(now = now).map { it.id },
|
||||
)
|
||||
assertEquals(listOf(newest.id, middle.id), groupEvents(limit = 2, now = now).map { it.id })
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay order. A commit stream has to be applied in the order it was sent, so this query
|
||||
* is the one place in the DAO that deliberately orders ascending.
|
||||
*/
|
||||
@Test
|
||||
fun `a rooms group events replay oldest first`() = runBlocking {
|
||||
val third = storeEvent("3", createdAt = Instant.fromEpochSeconds(3_000), tags = arrayOf(arrayOf("h", roomOne)))
|
||||
val first = storeEvent("1", createdAt = Instant.fromEpochSeconds(1_000), tags = arrayOf(arrayOf("h", roomOne)))
|
||||
val second = storeEvent("2", createdAt = Instant.fromEpochSeconds(2_000), tags = arrayOf(arrayOf("h", roomOne)))
|
||||
|
||||
val found = db.nostrEventDao().getMarmotGroupNostrEventsByChatRoomId(roomOne).map { it.id }
|
||||
|
||||
assertEquals(listOf(first.id, second.id, third.id), found)
|
||||
}
|
||||
|
||||
/**
|
||||
* The reason this query reads `NostrEvent` rather than joining `MarmotGroupEvent`: an
|
||||
* event whose indexing failed part way was stored without ever reaching that table, and
|
||||
* those are exactly the ones a replay exists to pick up. A join would skip precisely the
|
||||
* rows worth replaying.
|
||||
*/
|
||||
@Test
|
||||
fun `an event that never reached the index is still replayed`() = runBlocking {
|
||||
val indexed = storeGroupEvent("1", createdAt = Instant.fromEpochSeconds(1_000))
|
||||
val neverIndexed = storeEvent(
|
||||
"2",
|
||||
createdAt = Instant.fromEpochSeconds(2_000),
|
||||
tags = arrayOf(arrayOf("h", roomOne)),
|
||||
)
|
||||
|
||||
val found = db.nostrEventDao().getMarmotGroupNostrEventsByChatRoomId(roomOne).map { it.id }
|
||||
|
||||
assertEquals(listOf(indexed.id, neverIndexed.id), found)
|
||||
assertTrue(
|
||||
db.nostrEventDao().getMarmotGroupEvents(arrayOf(roomOne), epoch, distantFuture, 100, epoch)
|
||||
.none { it.id == neverIndexed.id },
|
||||
"the un-indexed event is genuinely absent from the indexed table",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a replay ignores events of other kinds`() = runBlocking {
|
||||
val groupEvent = storeEvent("1", tags = arrayOf(arrayOf("h", roomOne)))
|
||||
storeEvent("2", kind = 1, tags = arrayOf(arrayOf("h", roomOne)))
|
||||
|
||||
val found = db.nostrEventDao().getMarmotGroupNostrEventsByChatRoomId(roomOne).map { it.id }
|
||||
|
||||
assertEquals(listOf(groupEvent.id), found)
|
||||
}
|
||||
|
||||
/**
|
||||
* Documented contract, not an accident: the `LIKE` is a prefilter over serialised tags, so
|
||||
* a room id sitting in some other tag position matches too. Callers must confirm the
|
||||
* event's own `h` tag before treating a row as this room's. Pinned so that anyone
|
||||
* tightening the query knows a caller may already depend on the loose behaviour, and
|
||||
* anyone loosening a caller knows why the check is there.
|
||||
*/
|
||||
@Test
|
||||
fun `the replay prefilter can over-match and callers must confirm the h tag`() = runBlocking {
|
||||
val real = storeEvent("1", tags = arrayOf(arrayOf("h", roomOne)))
|
||||
val coincidence = storeEvent("2", tags = arrayOf(arrayOf("e", roomOne)))
|
||||
|
||||
val found = db.nostrEventDao().getMarmotGroupNostrEventsByChatRoomId(roomOne).map { it.id }
|
||||
|
||||
assertEquals(setOf(real.id, coincidence.id), found.toSet())
|
||||
}
|
||||
|
||||
/**
|
||||
* `getNostrEventByPublicKeyAndKind` returns a single row from a query ordered newest
|
||||
* first, which is what makes it correct for replaceable events -- the latest metadata
|
||||
* event for an author, not whichever one SQLite happened to reach first.
|
||||
*/
|
||||
@Test
|
||||
fun `the newest event wins for an author and kind`() = runBlocking {
|
||||
storeEvent("1", kind = 0, createdAt = Instant.fromEpochSeconds(1_000), content = "old")
|
||||
storeEvent("2", kind = 0, createdAt = Instant.fromEpochSeconds(2_000), content = "new")
|
||||
|
||||
val found = db.nostrEventDao().getNostrEventByPublicKeyAndKind(alice, 0)
|
||||
|
||||
assertNotNull(found)
|
||||
assertEquals("new", found.content)
|
||||
}
|
||||
|
||||
/**
|
||||
* The two write paths differ and the difference matters: a nostr event is immutable under
|
||||
* its id, so `insert` with IGNORE is the right call when re-receiving an event from a
|
||||
* second relay, while `upsert` overwrites. Using the wrong one silently replaces a stored
|
||||
* event with a re-received copy that may carry different local columns.
|
||||
*/
|
||||
@Test
|
||||
fun `insert keeps the stored event while upsert replaces it`() = runBlocking {
|
||||
val original = storeEvent("1", content = "original")
|
||||
|
||||
db.nostrEventDao().insert(original.copy(content = "from another relay"))
|
||||
assertEquals(
|
||||
"original",
|
||||
db.nostrEventDao().getNostrEventById(original.id)?.content,
|
||||
"insert must ignore a conflicting id rather than overwrite",
|
||||
)
|
||||
|
||||
db.nostrEventDao().upsert(original.copy(content = "replaced"))
|
||||
assertEquals("replaced", db.nostrEventDao().getNostrEventById(original.id)?.content)
|
||||
}
|
||||
|
||||
/**
|
||||
* The paged reads use a strict `createdAt > :since`, which is what makes them safe to call
|
||||
* in a loop with the last row's timestamp as the next cursor. Worth pinning next to the
|
||||
* inclusive bound in `getMarmotGroupEvents`: the two are deliberately different, and a
|
||||
* reader who assumes one convention holds everywhere would introduce either a skipped row
|
||||
* or an endless loop.
|
||||
*/
|
||||
@Test
|
||||
fun `the paged reads treat since as exclusive`() = runBlocking {
|
||||
val onBoundary = storeEvent("1", kind = 1, createdAt = Instant.fromEpochSeconds(1_000))
|
||||
val after = storeEvent("2", kind = 1, createdAt = Instant.fromEpochSeconds(1_001))
|
||||
|
||||
val found = db.nostrEventDao()
|
||||
.getNostrEvents(arrayOf(1), Instant.fromEpochSeconds(1_000), 100)
|
||||
.map { it.id }
|
||||
|
||||
assertEquals(listOf(after.id), found)
|
||||
assertTrue(onBoundary.id !in found, "`since` is exclusive on this query")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package press.mantra.compose.database.dao
|
||||
|
||||
import androidx.room3.Room
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import press.mantra.compose.database.model.Profile
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* NIP-17 rooms have no MLS group, no key packages and no invites -- membership *is* the p-tag
|
||||
* set on each message. Two properties follow from that, and both are load-bearing.
|
||||
*
|
||||
* The room id is [ChatRoom.deriveChatRoomId] over the member set, the same aggregate the
|
||||
* inbound path derives from an arriving gift wrap. That is what makes creation idempotent:
|
||||
* two people starting the same conversation have to land on one room rather than two, or the
|
||||
* same thread exists twice with each side writing into its own copy.
|
||||
*
|
||||
* And `mlsGroupState = null` is not incidental -- `sendChatMessage` reads exactly that to
|
||||
* decide between a group event and gift wraps. A NIP-17 room that acquired MLS state would
|
||||
* have its messages routed down a path no recipient is running.
|
||||
*/
|
||||
class NostrNip17DaoJvmTest {
|
||||
|
||||
private val db: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
|
||||
@AfterTest
|
||||
fun closeDb() = db.close()
|
||||
|
||||
// Real keys: deriveChatRoomId does secp256k1 point work and treats an off-curve value
|
||||
// differently from a valid one, so hex filler would exercise a path users never hit.
|
||||
private val user = KeyPair().pubKey.toHexKey()
|
||||
private val alice = KeyPair().pubKey.toHexKey()
|
||||
private val bob = KeyPair().pubKey.toHexKey()
|
||||
|
||||
private suspend fun seedProfile(publicKey: String) {
|
||||
val nostrEventId = publicKey.take(63) + "f"
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = nostrEventId,
|
||||
pubKey = publicKey,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
)
|
||||
db.profileDao().upsert(
|
||||
Profile(publicKey = publicKey, userName = "member", nostrEventId = nostrEventId)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every member, not just the creator. `Participant.participantPublicKey` is a foreign key
|
||||
* onto Profile, so a room cannot be stood up for someone this device has never seen -- see
|
||||
* `creating a room with an unknown member is refused by the schema` for what that costs.
|
||||
*/
|
||||
private suspend fun seedMembers(vararg publicKeys: String) = publicKeys.forEach { seedProfile(it) }
|
||||
|
||||
private suspend fun participantsOf(chatRoomId: String) =
|
||||
db.participantDao().findParticipantsByChatRoomId(chatRoomId).map { it.participantPublicKey }.toSet()
|
||||
|
||||
@Test
|
||||
fun `a nip17 room is created with its members as participants`() = runBlocking {
|
||||
seedMembers(user, alice, bob)
|
||||
|
||||
val room = assertNotNull(
|
||||
db.nostrNip17Dao().createNip17ChatRoom(
|
||||
userPublicKey = user,
|
||||
participantPublicKeys = listOf(alice, bob),
|
||||
subject = "a thread",
|
||||
),
|
||||
"createNip17ChatRoom returned null",
|
||||
)
|
||||
|
||||
assertEquals(setOf(user, alice, bob), participantsOf(room.chatRoom.id))
|
||||
assertEquals("a thread", room.chatRoom.subject)
|
||||
}
|
||||
|
||||
/**
|
||||
* The author is a member of their own conversation. `sealGiftWrapPayload` walks the
|
||||
* participants to decide who to wrap for, and a room that omitted its creator would send
|
||||
* messages every other member could read and the sender could not.
|
||||
*/
|
||||
@Test
|
||||
fun `the creator is a participant even when not listed`() = runBlocking {
|
||||
seedMembers(user, alice)
|
||||
|
||||
val room = assertNotNull(
|
||||
db.nostrNip17Dao().createNip17ChatRoom(
|
||||
userPublicKey = user,
|
||||
participantPublicKeys = listOf(alice),
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(user in participantsOf(room.chatRoom.id))
|
||||
}
|
||||
|
||||
/** Membership is a set, so naming the creator among the participants is not a second member. */
|
||||
@Test
|
||||
fun `listing the creator among the participants does not duplicate them`() = runBlocking {
|
||||
seedMembers(user, alice)
|
||||
|
||||
val room = assertNotNull(
|
||||
db.nostrNip17Dao().createNip17ChatRoom(
|
||||
userPublicKey = user,
|
||||
participantPublicKeys = listOf(user, alice),
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(setOf(user, alice), participantsOf(room.chatRoom.id))
|
||||
assertEquals(2, db.participantDao().findParticipantsByChatRoomId(room.chatRoom.id).size)
|
||||
}
|
||||
|
||||
/**
|
||||
* What actually enforces this is the `.sorted()` inside `deriveChatRoomId` -- the DAO's
|
||||
* `.toSet()` dedupes but carries an order. Sorting is what lets both ends of a
|
||||
* conversation derive the same id independently: one from the list a user typed, the other
|
||||
* from the p-tags on an arriving gift wrap, which will not be in the same order.
|
||||
*/
|
||||
@Test
|
||||
fun `the room id does not depend on the order members are named`() = runBlocking {
|
||||
seedMembers(user, alice, bob)
|
||||
|
||||
val first = assertNotNull(
|
||||
db.nostrNip17Dao().createNip17ChatRoom(
|
||||
userPublicKey = user,
|
||||
participantPublicKeys = listOf(alice, bob),
|
||||
)
|
||||
)
|
||||
val second = assertNotNull(
|
||||
db.nostrNip17Dao().createNip17ChatRoom(
|
||||
userPublicKey = user,
|
||||
participantPublicKeys = listOf(bob, alice),
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(first.chatRoom.id, second.chatRoom.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotence, which is the point of deriving the id rather than generating one. Creating
|
||||
* the same conversation twice reuses the room instead of standing up a second one that
|
||||
* would split the thread.
|
||||
*/
|
||||
@Test
|
||||
fun `creating the same conversation twice reuses the room`() = runBlocking {
|
||||
seedMembers(user, alice, bob)
|
||||
|
||||
val first = assertNotNull(
|
||||
db.nostrNip17Dao().createNip17ChatRoom(user, listOf(alice, bob), subject = "first")
|
||||
)
|
||||
val second = assertNotNull(
|
||||
db.nostrNip17Dao().createNip17ChatRoom(user, listOf(alice, bob), subject = "ignored")
|
||||
)
|
||||
|
||||
assertEquals(first.chatRoom.id, second.chatRoom.id)
|
||||
assertEquals(
|
||||
"first",
|
||||
second.chatRoom.subject,
|
||||
"the existing room is reused as it stands rather than rewritten",
|
||||
)
|
||||
assertEquals(3, db.participantDao().findParticipantsByChatRoomId(first.chatRoom.id).size)
|
||||
}
|
||||
|
||||
/** A different member set is a different conversation. */
|
||||
@Test
|
||||
fun `a different member set derives a different room`() = runBlocking {
|
||||
seedMembers(user, alice, bob)
|
||||
|
||||
val pair = assertNotNull(db.nostrNip17Dao().createNip17ChatRoom(user, listOf(alice)))
|
||||
val trio = assertNotNull(db.nostrNip17Dao().createNip17ChatRoom(user, listOf(alice, bob)))
|
||||
|
||||
assertTrue(pair.chatRoom.id != trio.chatRoom.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* What marks the room NIP-17. `sendChatMessage` branches on this field to choose between a
|
||||
* kind:445 group event and per-recipient gift wraps, so a non-null value here would route
|
||||
* direct messages down the MLS path.
|
||||
*/
|
||||
@Test
|
||||
fun `a nip17 room carries no mls state`() = runBlocking {
|
||||
seedMembers(user, alice)
|
||||
|
||||
val room = assertNotNull(db.nostrNip17Dao().createNip17ChatRoom(user, listOf(alice)))
|
||||
|
||||
assertNull(room.chatRoom.mlsGroupState, "MLS state is what tells the two room kinds apart")
|
||||
}
|
||||
|
||||
/**
|
||||
* The precondition, and an asymmetry worth knowing about. A member with no Profile row
|
||||
* violates Participant's foreign key, so this raises rather than returning null -- while
|
||||
* `getOrCreateChatRoom`, one method down, answers the same "I have never seen this user"
|
||||
* situation by returning null. A caller that treats the two alike gets an unhandled
|
||||
* exception out of the first one.
|
||||
*/
|
||||
@Test
|
||||
fun `creating a room with an unknown member is refused by the schema`() = runBlocking<Unit> {
|
||||
seedMembers(user)
|
||||
|
||||
assertFailsWith<androidx.sqlite.SQLiteException> {
|
||||
db.nostrNip17Dao().createNip17ChatRoom(
|
||||
userPublicKey = user,
|
||||
participantPublicKeys = listOf(alice),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* getOrCreateChatRoom is the inbound counterpart and takes the id as given, since it comes
|
||||
* off an arriving event rather than from a member list. It returns the room already stored
|
||||
* rather than overwriting it.
|
||||
*/
|
||||
@Test
|
||||
fun `getOrCreate returns the room that already exists`() = runBlocking {
|
||||
seedProfile(user)
|
||||
val chatRoomId = "11".repeat(32)
|
||||
db.chatRoomDao().upsert(
|
||||
ChatRoom(
|
||||
id = chatRoomId,
|
||||
userPublicKey = user,
|
||||
subject = "already here",
|
||||
description = null,
|
||||
mlsGroupState = null,
|
||||
)
|
||||
)
|
||||
|
||||
val found = assertNotNull(
|
||||
db.nostrNip17Dao().getOrCreateChatRoom(
|
||||
chatRoomId = chatRoomId,
|
||||
activeUserPublicKey = user,
|
||||
relayHint = null,
|
||||
defaultSubject = "would be new",
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals("already here", found.chatRoom.subject)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getOrCreate stands up a room the active user has a profile for`() = runBlocking {
|
||||
seedProfile(user)
|
||||
val chatRoomId = "22".repeat(32)
|
||||
|
||||
val created = assertNotNull(
|
||||
db.nostrNip17Dao().getOrCreateChatRoom(
|
||||
chatRoomId = chatRoomId,
|
||||
activeUserPublicKey = user,
|
||||
relayHint = "wss://relay.example",
|
||||
defaultSubject = "new room",
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(chatRoomId, created.chatRoom.id)
|
||||
assertEquals("new room", created.chatRoom.subject)
|
||||
assertTrue(user in participantsOf(chatRoomId))
|
||||
}
|
||||
|
||||
/**
|
||||
* The guard: with no profile for the active user there is nothing to hang a room off, and
|
||||
* the DAO returns null rather than writing a room whose owner it cannot name.
|
||||
*/
|
||||
@Test
|
||||
fun `getOrCreate refuses when the active user has no profile`() = runBlocking {
|
||||
val chatRoomId = "33".repeat(32)
|
||||
|
||||
val created = db.nostrNip17Dao().getOrCreateChatRoom(
|
||||
chatRoomId = chatRoomId,
|
||||
activeUserPublicKey = user,
|
||||
relayHint = null,
|
||||
)
|
||||
|
||||
assertNull(created)
|
||||
assertNull(db.chatRoomDao().findChatRoomById(chatRoomId), "no room should have been written")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package press.mantra.compose.database.query
|
||||
|
||||
import androidx.room3.Room
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import press.mantra.compose.database.model.types.SynchronizationFilter
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* `NostrEventFilterQueryTest` pins the SQL string this builder produces. It cannot pin what
|
||||
* SQLite does with that string, and the gap between the two is where the expensive mistakes
|
||||
* live: SQL that is well-formed but rejected, a bound value whose index is off by one, a LIKE
|
||||
* whose escaping does not survive contact with the stored column, or a bound timestamp in
|
||||
* units the column was never written in. All four produce a query that looks right in a
|
||||
* string assertion.
|
||||
*
|
||||
* That last one is worth spelling out, because it spans two files that nothing forces to
|
||||
* agree. [NostrEventFilterQuery] binds `since`/`until` as `epochSeconds`;
|
||||
* `MantraConverters.instantToTimestamp` writes the `createdAt` column as `epochSeconds`. Change
|
||||
* either one to milliseconds and the filter silently selects nothing, or everything.
|
||||
*
|
||||
* The stakes are the same as for the builder test: negentropy reconciles our set against the
|
||||
* relay's set for the *same* filter, so any row this query gets wrong turns into an event
|
||||
* needlessly re-downloaded or needlessly pushed at a relay that filtered it out on purpose.
|
||||
*/
|
||||
class NostrEventFilterQueryExecutionTest {
|
||||
|
||||
private val db: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
|
||||
@AfterTest
|
||||
fun closeDb() = db.close()
|
||||
|
||||
private val alice = "a".repeat(64)
|
||||
private val bob = "b".repeat(64)
|
||||
private val carol = "c".repeat(64)
|
||||
|
||||
private suspend fun store(
|
||||
id: String,
|
||||
pubKey: String = alice,
|
||||
kind: Int = 1,
|
||||
createdAt: Instant = Instant.fromEpochSeconds(1_000),
|
||||
content: String = "hello",
|
||||
tags: Array<Array<String>> = emptyArray(),
|
||||
): NostrEvent = NostrEvent(
|
||||
id = id.padEnd(64, '0'),
|
||||
pubKey = pubKey,
|
||||
kind = kind,
|
||||
tags = tags,
|
||||
content = content,
|
||||
sig = "0".repeat(128),
|
||||
createdAt = createdAt,
|
||||
).also { db.nostrEventDao().upsert(it) }
|
||||
|
||||
private suspend fun matching(
|
||||
filter: SynchronizationFilter,
|
||||
limit: Int = NostrEventFilterQuery.NO_LIMIT,
|
||||
): List<NostrEvent> = db.nostrEventDao().getNostrEventsMatchingFilter(
|
||||
NostrEventFilterQuery.build(filter, limit)
|
||||
)
|
||||
|
||||
private fun List<NostrEvent>.ids(): List<String> = map { it.id }
|
||||
|
||||
@Test
|
||||
fun `an ids filter selects exactly the listed events`() = runBlocking {
|
||||
val wanted = store("1")
|
||||
store("2")
|
||||
|
||||
val found = matching(SynchronizationFilter(ids = arrayOf(wanted.id)))
|
||||
|
||||
assertEquals(listOf(wanted.id), found.ids())
|
||||
}
|
||||
|
||||
/**
|
||||
* The clause the builder emits for a present-but-empty list is the literal `0`. A string
|
||||
* assertion cannot say whether SQLite accepts that as a boolean expression; this can.
|
||||
*/
|
||||
@Test
|
||||
fun `a present but empty list matches nothing rather than everything`() = runBlocking {
|
||||
store("1")
|
||||
store("2")
|
||||
|
||||
assertTrue(matching(SynchronizationFilter(ids = emptyArray())).isEmpty())
|
||||
assertTrue(matching(SynchronizationFilter(authors = emptyArray())).isEmpty())
|
||||
assertTrue(matching(SynchronizationFilter(kinds = emptyArray())).isEmpty())
|
||||
assertTrue(matching(SynchronizationFilter(tags = mapOf("p" to emptyList()))).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no clauses at all selects every stored event`() = runBlocking {
|
||||
store("1")
|
||||
store("2")
|
||||
|
||||
assertEquals(2, matching(SynchronizationFilter()).size)
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-01 bounds are inclusive on both ends, and an event stamped exactly on the boundary
|
||||
* is the case that tells an inclusive bound from an exclusive one. The queries this
|
||||
* builder replaced used a strict `createdAt > :since`, which put boundary events in the
|
||||
* relay's set and not in ours.
|
||||
*/
|
||||
@Test
|
||||
fun `since and until are inclusive on both ends`() = runBlocking {
|
||||
val before = store("1", createdAt = Instant.fromEpochSeconds(999))
|
||||
val onSince = store("2", createdAt = Instant.fromEpochSeconds(1_000))
|
||||
val between = store("3", createdAt = Instant.fromEpochSeconds(1_500))
|
||||
val onUntil = store("4", createdAt = Instant.fromEpochSeconds(2_000))
|
||||
val after = store("5", createdAt = Instant.fromEpochSeconds(2_001))
|
||||
|
||||
val found = matching(
|
||||
SynchronizationFilter(
|
||||
since = Instant.fromEpochSeconds(1_000),
|
||||
until = Instant.fromEpochSeconds(2_000),
|
||||
)
|
||||
).ids()
|
||||
|
||||
assertTrue(onSince.id in found, "an event stamped exactly on `since` must be included")
|
||||
assertTrue(onUntil.id in found, "an event stamped exactly on `until` must be included")
|
||||
assertTrue(between.id in found)
|
||||
assertTrue(before.id !in found)
|
||||
assertTrue(after.id !in found)
|
||||
}
|
||||
|
||||
/**
|
||||
* The cross-file invariant. The filter binds seconds; the converter writes seconds. If
|
||||
* either side moved to milliseconds this would keep compiling and start selecting the
|
||||
* wrong century.
|
||||
*/
|
||||
@Test
|
||||
fun `the bound timestamps are in the same units the converter writes`() = runBlocking {
|
||||
val at = Instant.fromEpochSeconds(1_700_000_000)
|
||||
val event = store("1", createdAt = at)
|
||||
|
||||
assertEquals(listOf(event.id), matching(SynchronizationFilter(since = at)).ids())
|
||||
assertEquals(listOf(event.id), matching(SynchronizationFilter(until = at)).ids())
|
||||
assertTrue(matching(SynchronizationFilter(since = at.plus(kotlin.time.Duration.parse("1s")))).isEmpty())
|
||||
assertTrue(matching(SynchronizationFilter(until = at.minus(kotlin.time.Duration.parse("1s")))).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tag filter ORs the values of one name and ANDs across names`() = runBlocking {
|
||||
val both = store("1", tags = arrayOf(arrayOf("p", alice), arrayOf("e", "beef".padEnd(64, '0'))))
|
||||
val onlyP = store("2", tags = arrayOf(arrayOf("p", bob)))
|
||||
store("3", tags = arrayOf(arrayOf("e", "beef".padEnd(64, '0'))))
|
||||
|
||||
val orOverValues = matching(
|
||||
SynchronizationFilter(tags = mapOf("p" to listOf(alice, bob)))
|
||||
).ids()
|
||||
assertEquals(setOf(both.id, onlyP.id), orOverValues.toSet())
|
||||
|
||||
val andOverNames = matching(
|
||||
SynchronizationFilter(
|
||||
tags = mapOf("p" to listOf(alice, bob), "e" to listOf("beef".padEnd(64, '0')))
|
||||
)
|
||||
).ids()
|
||||
assertEquals(listOf(both.id), andOverNames)
|
||||
}
|
||||
|
||||
/**
|
||||
* The reason [NostrEventFilterQuery] anchors on the tag name as well as the value. The
|
||||
* substring scan it replaced -- `tags LIKE '%<pubkey>%'` -- matched the same hex sitting
|
||||
* in any tag position, so a `p` filter pulled in every event that merely referenced that
|
||||
* key in an `e` tag.
|
||||
*/
|
||||
@Test
|
||||
fun `a tag value is matched in its own position and not anywhere in the row`() = runBlocking {
|
||||
val taggedAsPerson = store("1", tags = arrayOf(arrayOf("p", carol)))
|
||||
store("2", tags = arrayOf(arrayOf("e", carol)))
|
||||
|
||||
val found = matching(SynchronizationFilter(tags = mapOf("p" to listOf(carol)))).ids()
|
||||
|
||||
assertEquals(listOf(taggedAsPerson.id), found, "the `e` tagged event is not a `p` match")
|
||||
}
|
||||
|
||||
/**
|
||||
* Real tags carry a relay hint and a marker after the value, which is why the pattern
|
||||
* drops the closing bracket rather than matching the whole encoded tag.
|
||||
*/
|
||||
@Test
|
||||
fun `a tag match tolerates trailing tag elements`() = runBlocking {
|
||||
val withHint = store(
|
||||
"1",
|
||||
tags = arrayOf(arrayOf("p", alice, "wss://relay.example", "mention")),
|
||||
)
|
||||
|
||||
val found = matching(SynchronizationFilter(tags = mapOf("p" to listOf(alice)))).ids()
|
||||
|
||||
assertEquals(listOf(withHint.id), found)
|
||||
}
|
||||
|
||||
/** `escapeLike`: a wildcard inside a tag value must be a literal, not a widening match. */
|
||||
@Test
|
||||
fun `a wildcard inside a tag value does not widen the match`() = runBlocking {
|
||||
val literal = store("1", tags = arrayOf(arrayOf("d", "100%")))
|
||||
store("2", tags = arrayOf(arrayOf("d", "100 percent")))
|
||||
|
||||
val found = matching(SynchronizationFilter(tags = mapOf("d" to listOf("100%")))).ids()
|
||||
|
||||
assertEquals(listOf(literal.id), found, "the `%` was treated as a wildcard")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tagsAll requires every value of a name rather than any of them`() = runBlocking {
|
||||
val hasBoth = store("1", tags = arrayOf(arrayOf("p", alice), arrayOf("p", bob)))
|
||||
store("2", tags = arrayOf(arrayOf("p", alice)))
|
||||
|
||||
val all = matching(SynchronizationFilter(tagsAll = mapOf("p" to listOf(alice, bob)))).ids()
|
||||
assertEquals(listOf(hasBoth.id), all)
|
||||
|
||||
val any = matching(SynchronizationFilter(tags = mapOf("p" to listOf(alice, bob)))).ids()
|
||||
assertEquals(2, any.size, "the OR form should still match both")
|
||||
}
|
||||
|
||||
/**
|
||||
* A relay hands back the newest events under a limit. The per-shape queries this replaced
|
||||
* ordered ascending under the same limit and so returned the oldest, which is the opposite
|
||||
* window.
|
||||
*/
|
||||
@Test
|
||||
fun `results are newest first and a limit keeps the newest`() = runBlocking {
|
||||
val oldest = store("1", createdAt = Instant.fromEpochSeconds(1_000))
|
||||
val middle = store("2", createdAt = Instant.fromEpochSeconds(2_000))
|
||||
val newest = store("3", createdAt = Instant.fromEpochSeconds(3_000))
|
||||
|
||||
assertEquals(listOf(newest.id, middle.id, oldest.id), matching(SynchronizationFilter()).ids())
|
||||
assertEquals(listOf(newest.id, middle.id), matching(SynchronizationFilter(), limit = 2).ids())
|
||||
}
|
||||
|
||||
/** The `id DESC` tiebreak, without which a limit over equal timestamps is arbitrary. */
|
||||
@Test
|
||||
fun `events sharing a timestamp are ordered by id descending`() = runBlocking {
|
||||
val sameMoment = Instant.fromEpochSeconds(1_000)
|
||||
val lower = store("1", createdAt = sameMoment)
|
||||
val higher = store("2", createdAt = sameMoment)
|
||||
|
||||
assertEquals(listOf(higher.id, lower.id), matching(SynchronizationFilter()).ids())
|
||||
assertEquals(listOf(higher.id), matching(SynchronizationFilter(), limit = 1).ids())
|
||||
}
|
||||
|
||||
/**
|
||||
* The limit is bound *after* every tag pattern, so its placeholder is the last one in the
|
||||
* statement. Nothing but execution catches a binding index that drifted: the SQL string
|
||||
* would be identical either way.
|
||||
*/
|
||||
@Test
|
||||
fun `binding order holds when a filter combines clauses with a limit`() = runBlocking {
|
||||
val wanted = store(
|
||||
"1",
|
||||
pubKey = alice,
|
||||
kind = 1,
|
||||
createdAt = Instant.fromEpochSeconds(1_500),
|
||||
content = "find me",
|
||||
tags = arrayOf(arrayOf("p", bob)),
|
||||
)
|
||||
store("2", pubKey = alice, kind = 1, createdAt = Instant.fromEpochSeconds(1_500), content = "find me", tags = arrayOf(arrayOf("p", carol)))
|
||||
store("3", pubKey = bob, kind = 1, createdAt = Instant.fromEpochSeconds(1_500), content = "find me", tags = arrayOf(arrayOf("p", bob)))
|
||||
|
||||
val found = matching(
|
||||
SynchronizationFilter(
|
||||
authors = arrayOf(alice),
|
||||
kinds = arrayOf(1),
|
||||
since = Instant.fromEpochSeconds(1_000),
|
||||
until = Instant.fromEpochSeconds(2_000),
|
||||
search = "find me",
|
||||
tags = mapOf("p" to listOf(bob)),
|
||||
),
|
||||
limit = 10,
|
||||
).ids()
|
||||
|
||||
assertEquals(listOf(wanted.id), found)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a search clause matches on content`() = runBlocking {
|
||||
val wanted = store("1", content = "the quick brown fox")
|
||||
store("2", content = "nothing to see")
|
||||
|
||||
assertEquals(listOf(wanted.id), matching(SynchronizationFilter(search = "quick brown")).ids())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user