test: cover the MarmotOutboundDao membership guards
Both entry points that change a group's membership start 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 throw exists to "say so instead of silently doing nothing and letting the caller report success", which is a claim about behaviour and therefore something a test can hold to. A guard that returned quietly would still compile, still look like it worked, and leave a room whose members believe someone was invited who was not. Covered: inviteMemberToChatRoom and addMembersToChatRoom each raise MarmotMissingChatGroupException against a room whose mlsGroupState is null, which is exactly the shape a gift-wrap-restored room has. Covered separately, because ordering is the substance of it: a refused invite leaves no Participant row behind. The guard has to run before that write, not after. sealGiftWrapPayload walks a room's participants to decide who to wrap a Welcome for, so a participant persisted by a failed invite would make the room look like it has a member no MLS group knows about -- and the next Welcome would be sealed for them. Covered last: the empty-batch guard returns before the MLS state is looked at, so addMembersToChatRoom with no peers must *not* throw on the same stateless room the other two tests reject. Adding nobody is not a failure to add somebody, and pinning that keeps the two guards from being collapsed into one. Deliberately not covered, and the test file says so rather than implying the DAO is done: everything past the guard -- the MLS commit, the Welcome, the epoch advance and persisting it back to the room -- needs a real peer key package, which means an MLS fixture this change does not build. That gap includes the batching rationale on addMembersToChatRoom, which is the more interesting property of the two: one commit and one Welcome so no member ever has to process a commit for an epoch they were not yet in, since MarmotInboundManager refuses future-epoch messages outright with no queue and no replay. Worth covering once there is a fixture to build a key package with. The MarmotKeyPackage these tests pass carries an empty byte array, which is honest: no test here reaches the MLS layer, so the bytes only have to exist. A test that got past the guard could not use it. 4 tests. composeApp jvmTest is 258 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
|||||||
|
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.exceptions.MarmotMissingChatGroupException
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* These cover the paths that need no MLS material. Everything past the guard -- the commit, the
|
||||||
|
* Welcome, the epoch advance and its persistence -- needs a real peer key package to exercise,
|
||||||
|
* which means an MLS fixture this test file deliberately does not build. Those paths are worth
|
||||||
|
* covering and are not covered here.
|
||||||
|
*/
|
||||||
|
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 any of these tests reach the MLS layer, so the
|
||||||
|
* bytes only have to exist. A test that got past the guard would need a real key package.
|
||||||
|
*/
|
||||||
|
private fun keyPackage() = MarmotKeyPackage(
|
||||||
|
id = "d".repeat(64),
|
||||||
|
publicKey = peer,
|
||||||
|
tlsEncodedMarmotKeyPackage = ByteArray(0),
|
||||||
|
)
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user