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:
Kgothatso Ngako
2026-09-06 03:05:51 +02:00
parent ba0c60dd2e
commit 02e70d992a

View File

@@ -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)
}
}