fix(groups): refuse a welcome for a group another profile on this device holds, and say so in the room
Phase 6 of docs/curated-to-mantra-profiles.md, the fourth decision's follow-up, written natively before the several-profiles line reaches users. ChatRoom is keyed by the group id alone, and the MLS state, the DKG sessions and the FROST shares all hang off the room; before several profiles on a device the second local member could not exist, and after it a user can invite their own second profile into their own collective. What the DAO did with that Welcome was already a refusal, by accident: the welcome branch of indexNostrEvent checks for an existing room before it writes one, so the joiner's state never overwrote the holder's -- but the branch could not tell a redelivered Welcome for our own room from a Welcome for a room another profile holds, logged "Chat Room already exists" at warning level for both, and told nobody. The invitee never joined, the inviter's device believed the invite had landed, and the failure had the exact shape of "the other device never got it". The branch now distinguishes the two by the room's userPublicKey. The same profile's redelivery stays the quiet no-op it was. Another profile's Welcome is refused with a warning naming both keys, and a membership line is written into the room that exists -- the holder's, because it is the only room on the device the group has and the holder is the one who can act on it: leave, or tell the inviter which profile to invite instead. The line is TYPE_WELCOME_REFUSED_OTHER_PROFILE, in MEMBERSHIP_TYPES so the transcript draws it as a notice rather than a bubble, with the error tint and a PersonOff icon since the person and not the invite is what it is about; its content is a whole sentence naming the inviter, the invitee and the holder, resolved at write time the way the other membership lines are. The invitee's key package bundle is left unconsumed, as the branch always left it; a second Welcome for the same package arrives here again and writes a second line. The test is two devices for real: an MLS group on the inviter's database, a key package whose private keys the invitee's database holds -- the fixture now returns the bundle beside the public package, and marmotKeyPackageFor is a projection of it -- and the Welcome the invite queued, sealed the way sealGiftWrapPayload seals one and stored through storeNostrEvent under the invitee's key. Three cases: the group held by another profile is refused, its row and MLS state untouched, the line in the room naming all three and the bundle unconsumed; the same profile welcomed twice writes nothing; and with no profile in the way the Welcome is a join, which is what shows the fixture is real. jvmTest 868 -> 871, both compilers clean, every audit budget met. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ import press.mantra.compose.exceptions.MarmotMissingKeyPackageBundleException
|
||||
import press.mantra.compose.exceptions.MarmotMissingNostrGroupDataExtension
|
||||
import press.mantra.compose.exceptions.MarmotNotMemberOfChatGroupException
|
||||
import press.mantra.compose.exceptions.MarmotWelcomeEventMissingKeyPackageEventIdException
|
||||
import press.mantra.compose.extensions.shortened
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.managers.ChillDkgRitualManager
|
||||
import press.mantra.compose.nostr.Relays
|
||||
@@ -53,6 +54,7 @@ import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle
|
||||
import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage
|
||||
import com.vitorpamplona.quartz.marmot.mls.tree.Credential
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
@@ -915,6 +917,23 @@ abstract class NostrDao(
|
||||
// content = "Coming soon.", // TODO: Figure out what to do here...
|
||||
// )
|
||||
// )
|
||||
} else if (localChatRoom.chatRoom.userPublicKey != activeKeyPair.pubKey.toHex()) {
|
||||
// Another profile on this device already holds this
|
||||
// group. ChatRoom is keyed by the group id alone, and
|
||||
// every table that hangs off it -- the MLS state, the
|
||||
// DKG sessions, the FROST shares -- is keyed by the
|
||||
// room, so letting a second local member in would
|
||||
// overwrite the first's state with the joiner's and
|
||||
// break both. Refuse, and say so where the group is:
|
||||
// in the room, under the profile that holds it. See
|
||||
// docs/curated-to-mantra-profiles.md, the fourth
|
||||
// decision.
|
||||
refuseWelcomeForOtherProfile(
|
||||
localChatRoom = localChatRoom,
|
||||
inviteePublicKey = activeKeyPair.pubKey.toHex(),
|
||||
inviterPublicKey = decryptedGiftWrapPayload.publicKey,
|
||||
welcomePayloadId = decryptedGiftWrapPayload.id,
|
||||
)
|
||||
} else {
|
||||
logger.w("Chat Room already exists for $nostrGroupId")
|
||||
}
|
||||
@@ -1652,6 +1671,53 @@ abstract class NostrDao(
|
||||
* room needs. Creating a group locally sends nothing to anyone, which is why the
|
||||
* first arrival for a group is just as likely to be a key ceremony as a message.
|
||||
*/
|
||||
/**
|
||||
* A Welcome for a group this device already holds under another of its profiles,
|
||||
* refused, and the refusal written into the room as a membership line.
|
||||
*
|
||||
* The line goes into the room that exists -- the other profile's -- because that is
|
||||
* the only place on this device the group has, and the profile that holds it is the
|
||||
* one who can do something about it: leave, or tell the inviter which profile to
|
||||
* invite instead. The invitee's key package bundle is left unconsumed, as the
|
||||
* "already exists" branch has always left it; nothing here changes what a second
|
||||
* Welcome for the same package would do, which is arrive here again and write a
|
||||
* second line.
|
||||
*
|
||||
* Content is a whole sentence with both names written in, the way the other
|
||||
* membership lines are, so nothing prefixes an author to it: this is nobody's words.
|
||||
*/
|
||||
private suspend fun refuseWelcomeForOtherProfile(
|
||||
localChatRoom: LocalChatRoom,
|
||||
inviteePublicKey: HexKey,
|
||||
inviterPublicKey: HexKey,
|
||||
welcomePayloadId: HexKey,
|
||||
) {
|
||||
val holder = localChatRoom.chatRoom.userPublicKey
|
||||
logger.w(
|
||||
"Refusing Welcome $welcomePayloadId for ${localChatRoom.chatRoom.id}: this device already holds the group " +
|
||||
"as $holder, and $inviteePublicKey is another profile on it"
|
||||
)
|
||||
|
||||
suspend fun nameOf(publicKey: HexKey): String =
|
||||
database.profileDao().getProfileByPublicKey(publicKey)?.humanReadableNameOrPubkey()
|
||||
?: publicKey.shortened()
|
||||
|
||||
database.chatMessageDao().upsert(
|
||||
ChatMessage(
|
||||
content = "${nameOf(inviterPublicKey)} invited ${nameOf(inviteePublicKey)}, another profile on this device, " +
|
||||
"to this group. The invitation was not accepted: this device already holds the group as " +
|
||||
"${nameOf(holder)}, and one device cannot hold a group twice.",
|
||||
messageType = ChatMessage.TYPE_WELCOME_REFUSED_OTHER_PROFILE,
|
||||
chatRoomId = localChatRoom.chatRoom.id,
|
||||
senderPublicKey = holder,
|
||||
isUserMessage = true,
|
||||
giftWrapPayloadId = welcomePayloadId,
|
||||
marmotGroupEventId = null,
|
||||
marmotInnerEventId = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getOrCreateNip17ChatRoom(
|
||||
decryptedGiftWrapPayload: GiftWrapPayload,
|
||||
activeKeyPair: KeyPair,
|
||||
|
||||
@@ -437,6 +437,20 @@ data class ChatMessage(
|
||||
const val TYPE_MEMBER_INVITE_SENT = "memberInviteSent"
|
||||
const val TYPE_MEMBER_INVITE_FAILED = "memberInviteFailed"
|
||||
|
||||
/**
|
||||
* A Welcome that arrived for another profile on this device, for a group this
|
||||
* device already holds, and was refused.
|
||||
*
|
||||
* Written by the invitee's side, into the room the other profile holds, since
|
||||
* that is the only room on the device the group has. ChatRoom is keyed by the
|
||||
* group id alone and everything that hangs off it -- MLS state, ceremonies,
|
||||
* shares -- by the room, so a second local member would overwrite the first.
|
||||
* The inviter's device sees nothing of this and believes the invite landed,
|
||||
* which is why the line names both profiles: the one who was invited and the
|
||||
* one who holds the group. See docs/curated-to-mantra-profiles.md.
|
||||
*/
|
||||
const val TYPE_WELCOME_REFUSED_OTHER_PROFILE = "welcomeRefusedOtherProfile"
|
||||
|
||||
/**
|
||||
* Every membership line, for the one check the transcript dispatches on.
|
||||
*
|
||||
@@ -447,6 +461,7 @@ data class ChatMessage(
|
||||
TYPE_MEMBER_INVITED,
|
||||
TYPE_MEMBER_INVITE_SENT,
|
||||
TYPE_MEMBER_INVITE_FAILED,
|
||||
TYPE_WELCOME_REFUSED_OTHER_PROFILE,
|
||||
)
|
||||
|
||||
const val TYPE_UNDECRYPTABLE_OUTER_LAYER = "undecryptableOuterLayer"
|
||||
|
||||
@@ -39,6 +39,7 @@ import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.PanTool
|
||||
import androidx.compose.material.icons.filled.PersonAdd
|
||||
import androidx.compose.material.icons.filled.PersonOff
|
||||
import androidx.compose.material.icons.filled.Upload
|
||||
import androidx.compose.material.icons.filled.WorkspacePremium
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
@@ -815,6 +816,9 @@ private fun RitualNotice(
|
||||
ChatMessage.TYPE_MEMBER_INVITED -> Icons.Default.PersonAdd
|
||||
ChatMessage.TYPE_MEMBER_INVITE_SENT -> Icons.Default.Upload
|
||||
ChatMessage.TYPE_MEMBER_INVITE_FAILED -> Icons.Default.ErrorOutline
|
||||
// Somebody on this device was kept out of a group it already holds; the
|
||||
// person, not the invite, is what the line is about.
|
||||
ChatMessage.TYPE_WELCOME_REFUSED_OTHER_PROFILE -> Icons.Default.PersonOff
|
||||
|
||||
else -> Icons.Default.PanTool
|
||||
}
|
||||
@@ -835,7 +839,8 @@ private fun RitualNotice(
|
||||
val tint = when {
|
||||
chatMessage.messageType == ChatMessage.TYPE_DKG_FAILED ||
|
||||
chatMessage.messageType == ChatMessage.TYPE_FROST_FAILED ||
|
||||
chatMessage.messageType == ChatMessage.TYPE_MEMBER_INVITE_FAILED ->
|
||||
chatMessage.messageType == ChatMessage.TYPE_MEMBER_INVITE_FAILED ||
|
||||
chatMessage.messageType == ChatMessage.TYPE_WELCOME_REFUSED_OTHER_PROFILE ->
|
||||
MaterialTheme.colorScheme.error
|
||||
isRequest -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
@@ -14,6 +14,8 @@ 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
|
||||
import press.mantra.compose.database.model.MarmotKeyPackageBundle
|
||||
import press.mantra.compose.extensions.toHex
|
||||
|
||||
/**
|
||||
* A real MLS key package for [publicKey], built the same way
|
||||
@@ -21,7 +23,19 @@ import press.mantra.compose.database.model.MarmotKeyPackage
|
||||
* 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 {
|
||||
internal fun marmotKeyPackageFor(publicKey: HexKey): MarmotKeyPackage = marmotKeyPackageBundleFor(publicKey).keyPackage
|
||||
|
||||
/**
|
||||
* The key package and the private keys behind it, as the invitee's own device would hold
|
||||
* them: [keyPackage] is what the inviter reads off a relay, [bundle] is the row the
|
||||
* invitee's `processWelcome` opens the Welcome with. [id] is the kind 443 event id the
|
||||
* Welcome names in its `e` tag, and both rows carry it.
|
||||
*/
|
||||
internal class MarmotKeyPackagePair(val keyPackage: MarmotKeyPackage, val bundle: MarmotKeyPackageBundle) {
|
||||
val id: HexKey get() = keyPackage.id
|
||||
}
|
||||
|
||||
internal fun marmotKeyPackageBundleFor(publicKey: HexKey, id: HexKey = publicKey): MarmotKeyPackagePair {
|
||||
val initKp = X25519.generateKeyPair()
|
||||
val encKp = X25519.generateKeyPair()
|
||||
val sigKp = Ed25519.generateKeyPair()
|
||||
@@ -65,9 +79,22 @@ internal fun marmotKeyPackageFor(publicKey: HexKey): MarmotKeyPackage {
|
||||
),
|
||||
)
|
||||
|
||||
return MarmotKeyPackage(
|
||||
id = publicKey,
|
||||
publicKey = publicKey,
|
||||
tlsEncodedMarmotKeyPackage = keyPackage.toTlsBytes(),
|
||||
val tls = keyPackage.toTlsBytes()
|
||||
return MarmotKeyPackagePair(
|
||||
keyPackage = MarmotKeyPackage(
|
||||
id = id,
|
||||
publicKey = publicKey,
|
||||
tlsEncodedMarmotKeyPackage = tls,
|
||||
),
|
||||
// The same three private keys the app stores raw, hex-encoded -- see
|
||||
// DatabaseMarmotRepository.publishMarmotKeyPackageBundle.
|
||||
bundle = MarmotKeyPackageBundle(
|
||||
id = id,
|
||||
publicKey = publicKey,
|
||||
tlsEncodedMarmotKeyPackage = tls,
|
||||
ncryptsecInitPrivateKey = initKp.privateKey.toHex(),
|
||||
ncryptsecEncryptionPrivateKey = encKp.privateKey.toHex(),
|
||||
ncryptsecSignaturePrivateKey = sigKp.privateKey.toHex(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
package press.mantra.compose.database.dao
|
||||
|
||||
import androidx.room3.Room
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
||||
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
|
||||
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
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.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 kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Two of a device's own profiles in one group, which docs/multiple-profiles.md made
|
||||
* possible and docs/curated-to-mantra-profiles.md's fourth decision refuses.
|
||||
*
|
||||
* `ChatRoom` is keyed by the group id alone. Before several profiles on a device the
|
||||
* second local member could not exist; after it, a user can invite their own second
|
||||
* profile into their own collective, and a Welcome processed for it would file the
|
||||
* joiner's MLS state over the holder's. The DAO already declined to overwrite -- "Chat
|
||||
* Room already exists", at warning level, and nothing else -- so the invitee never
|
||||
* joined and nobody was told. This pins the refusal and the line that now reports it.
|
||||
*
|
||||
* The setup is real: an MLS group on the inviter's database, a key package whose
|
||||
* private keys the invitee's database holds, and a Welcome sealed the way
|
||||
* `DatabaseChatRepository.sealGiftWrapPayload` seals one, delivered through
|
||||
* `storeNostrEvent` under the invitee's key.
|
||||
*/
|
||||
class WelcomeForOtherProfileJvmTest {
|
||||
|
||||
/** The inviter's device. */
|
||||
private val inviterDb: MantraDatabase = getRoomDatabase(Room.inMemoryDatabaseBuilder<MantraDatabase>())
|
||||
|
||||
/** The device that holds two profiles: `holder`, already in the group, and `invitee`. */
|
||||
private val deviceDb: MantraDatabase = getRoomDatabase(Room.inMemoryDatabaseBuilder<MantraDatabase>())
|
||||
|
||||
@AfterTest
|
||||
fun closeDbs() {
|
||||
inviterDb.close()
|
||||
deviceDb.close()
|
||||
}
|
||||
|
||||
private val relay = Relays.DefaultDMRelayList.first().url
|
||||
private val inviter = KeyPair()
|
||||
private val invitee = KeyPair()
|
||||
private val holder = KeyPair()
|
||||
private val roomId = "b".repeat(64)
|
||||
private val keyPackageEventId = "d".repeat(64)
|
||||
|
||||
private suspend fun MantraDatabase.seedProfile(publicKey: HexKey, name: String, nostrEventId: String) {
|
||||
nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = nostrEventId,
|
||||
pubKey = publicKey,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
)
|
||||
profileDao().upsert(Profile(publicKey = publicKey, userName = name, nostrEventId = nostrEventId))
|
||||
}
|
||||
|
||||
/** A room with real MLS state on the inviter's device, the inviter its only member. */
|
||||
private suspend fun seedInviterRoom(): LocalChatRoom {
|
||||
val inviterHex = inviter.pubKey.toHexKey()
|
||||
inviterDb.seedProfile(inviterHex, "inviter", "1".repeat(64))
|
||||
inviterDb.seedProfile(invitee.pubKey.toHexKey(), "invitee", "2".repeat(64))
|
||||
val mlsGroup = MlsGroup.create(
|
||||
identity = inviterHex.hexToByteArray(),
|
||||
initialExtensions = listOf(
|
||||
MarmotGroupData.bootstrap(
|
||||
nostrGroupId = roomId,
|
||||
creatorPubKey = inviterHex,
|
||||
outboxRelays = listOf(relay),
|
||||
).toExtension()
|
||||
),
|
||||
)
|
||||
val chatRoom = ChatRoom(
|
||||
id = roomId,
|
||||
userPublicKey = inviterHex,
|
||||
subject = "the collective",
|
||||
description = null,
|
||||
mlsGroupState = mlsGroup.saveState().encodeTls().toHex(),
|
||||
)
|
||||
inviterDb.chatRoomDao().upsert(chatRoom)
|
||||
return LocalChatRoom(chatRoom = chatRoom)
|
||||
}
|
||||
|
||||
/**
|
||||
* The inviter adds the invitee and the Welcome reaches the queue; this seals it for
|
||||
* the invitee the way the notary would, and hands back the wrap as a relay would.
|
||||
*/
|
||||
private suspend fun welcomeWrapForInvitee(): NostrEvent {
|
||||
// The room first: MarmotKeyPackage.publicKey is a foreign key onto Profile, and
|
||||
// the room's seeding is what gives the invitee one on the inviter's device.
|
||||
val localChatRoom = seedInviterRoom()
|
||||
val pair = marmotKeyPackageBundleFor(invitee.pubKey.toHexKey(), id = keyPackageEventId)
|
||||
inviterDb.marmotKeyPackageDao().upsert(pair.keyPackage)
|
||||
deviceDb.marmotKeyPackageBundleDao().upsert(pair.bundle)
|
||||
|
||||
inviterDb.marmotOutboundDao().inviteMemberToChatRoom(
|
||||
localChatRoom = localChatRoom,
|
||||
peerPublicKey = invitee.pubKey.toHexKey(),
|
||||
peerKeyPackage = pair.keyPackage,
|
||||
)
|
||||
val payload = inviterDb.giftWrapPayloadDao()
|
||||
.getByChatRoomAndKinds(roomId, listOf(WelcomeEvent.KIND))
|
||||
.single()
|
||||
|
||||
val signer = NostrSignerSync(inviter)
|
||||
val rumor = RumorAssembler.assembleRumor(
|
||||
pubKey = signer.pubKey,
|
||||
ev = EventTemplate(
|
||||
createdAt = payload.createdAt.epochSeconds,
|
||||
kind = payload.kind,
|
||||
tags = payload.tags,
|
||||
content = payload.content,
|
||||
),
|
||||
)
|
||||
val seal = signer.signNormal<SealedRumorEvent>(
|
||||
createdAt = 1_700_000_000,
|
||||
kind = SealedRumorEvent.KIND,
|
||||
tags = emptyArray(),
|
||||
content = signer.nip44Encrypt(plaintext = rumor.toJson(), toPublicKey = invitee.pubKey.toHexKey()),
|
||||
)
|
||||
val wrap = GiftWrapEvent.create(
|
||||
event = seal,
|
||||
recipientPubKey = invitee.pubKey.toHexKey(),
|
||||
createdAt = 1_700_000_100,
|
||||
)
|
||||
return NostrEvent(
|
||||
id = wrap.id,
|
||||
pubKey = wrap.pubKey,
|
||||
kind = wrap.kind,
|
||||
tags = wrap.tags,
|
||||
content = wrap.content,
|
||||
sig = wrap.sig,
|
||||
createdAt = Instant.fromEpochSeconds(wrap.createdAt),
|
||||
)
|
||||
}
|
||||
|
||||
/** The group, already on the device under [owner], with state that is recognisably not the joiner's. */
|
||||
private suspend fun seedDeviceRoom(owner: KeyPair): ChatRoom {
|
||||
val ownerHex = owner.pubKey.toHexKey()
|
||||
deviceDb.seedProfile(ownerHex, "holder", "3".repeat(64))
|
||||
val chatRoom = ChatRoom(
|
||||
id = roomId,
|
||||
userPublicKey = ownerHex,
|
||||
subject = "the collective",
|
||||
description = null,
|
||||
mlsGroupState = "0123456789abcdef",
|
||||
)
|
||||
deviceDb.chatRoomDao().upsert(chatRoom)
|
||||
return chatRoom
|
||||
}
|
||||
|
||||
private suspend fun deliverToInvitee(wrap: NostrEvent) = deviceDb.nostrDao().storeNostrEvent(
|
||||
nostrEvent = wrap,
|
||||
relayURL = relay,
|
||||
synchronizationRelayURLs = listOf(relay),
|
||||
level = 0,
|
||||
activeKeyPair = invitee,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a welcome for a group another profile on the device holds is refused and said so`() = runBlocking {
|
||||
val before = seedDeviceRoom(holder)
|
||||
deviceDb.seedProfile(invitee.pubKey.toHexKey(), "second self", "4".repeat(64))
|
||||
// The holder is in the group with the inviter, so this device knows them by name.
|
||||
deviceDb.seedProfile(inviter.pubKey.toHexKey(), "inviter", "5".repeat(64))
|
||||
|
||||
deliverToInvitee(welcomeWrapForInvitee())
|
||||
|
||||
val room = assertNotNull(deviceDb.chatRoomDao().findChatRoomById(roomId)).chatRoom
|
||||
assertEquals(before.userPublicKey, room.userPublicKey, "the room changed hands")
|
||||
assertEquals(before.mlsGroupState, room.mlsGroupState, "the holder's MLS state was overwritten")
|
||||
|
||||
val line = deviceDb.chatMessageDao().getChatMessagesByChatRoomId(roomId).map { it.chatMessage }
|
||||
.singleOrNull { it.messageType == ChatMessage.TYPE_WELCOME_REFUSED_OTHER_PROFILE }
|
||||
assertNotNull(line, "the refusal left no line in the room")
|
||||
assertTrue(line.content.contains("second self"), "the line does not name the invitee: ${line.content}")
|
||||
assertTrue(line.content.contains("holder"), "the line does not name who holds the group: ${line.content}")
|
||||
assertTrue(line.content.contains("inviter"), "the line does not name the inviter: ${line.content}")
|
||||
assertTrue(line.messageType in ChatMessage.MEMBERSHIP_TYPES, "the line would render as a bubble")
|
||||
|
||||
val bundle = assertNotNull(deviceDb.marmotKeyPackageBundleDao().getMarmotKeyPackageBundleById(keyPackageEventId))
|
||||
assertFalse(bundle.consumed, "a refused welcome must not consume the key package")
|
||||
}
|
||||
|
||||
/** The branch the refusal was carved out of: the same profile, welcomed twice, is still a quiet no-op. */
|
||||
@Test
|
||||
fun `a welcome for a group the same profile already holds writes nothing`() = runBlocking {
|
||||
seedDeviceRoom(invitee)
|
||||
|
||||
deliverToInvitee(welcomeWrapForInvitee())
|
||||
|
||||
assertEquals(
|
||||
emptyList(),
|
||||
deviceDb.chatMessageDao().getChatMessagesByChatRoomId(roomId).map { it.chatMessage.messageType },
|
||||
"a redelivered welcome for our own room is not a refusal",
|
||||
)
|
||||
}
|
||||
|
||||
/** And with nothing in the way, the Welcome is a join, which is what tells the fixture is real. */
|
||||
@Test
|
||||
fun `the same welcome joins when no profile on the device holds the group`() = runBlocking {
|
||||
deviceDb.seedProfile(invitee.pubKey.toHexKey(), "second self", "4".repeat(64))
|
||||
|
||||
deliverToInvitee(welcomeWrapForInvitee())
|
||||
|
||||
val room = assertNotNull(deviceDb.chatRoomDao().findChatRoomById(roomId), "the invitee did not join").chatRoom
|
||||
assertEquals(invitee.pubKey.toHexKey(), room.userPublicKey)
|
||||
assertNotNull(room.mlsGroupState, "the joiner's MLS state was not filed")
|
||||
assertTrue(
|
||||
assertNotNull(deviceDb.marmotKeyPackageBundleDao().getMarmotKeyPackageBundleById(keyPackageEventId)).consumed,
|
||||
"a join consumes the key package",
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user