test(marmot): cover MlsGroupManager leave + rejoin round-trip
The existing testReaddAfterRemove_RejoinerCanEncryptAndDecrypt in MlsGroupLifecycleTest exercises the MLS layer (two raw MlsGroup instances, admin kicks peer, fresh KP, rejoin). Nothing covered the same flow one level up at the MlsGroupManager layer, where the persistent state store, retained-epoch wipe, and Welcome routing for the same nostrGroupId actually live. Adds testLeaveAndRejoin_SameGroupIdEndToEnd: two MlsGroupManager instances (Alice + Bob) with separate InMemoryGroupStateStores go through the full cycle — Alice creates with a MarmotGroupData extension, Bob joins via Welcome, bidirectional message round-trip, Bob calls manager.leaveGroup (asserts store + retained epochs both wiped), Alice removes Bob's leaf, Bob's second KeyPackage is admin- added, Bob processes the second Welcome under the same nostrGroupId, and bidirectional round-trip works again at the new epoch. Closes the "MarmotManager.leaveGroup + rejoin" and "persistent-store leave + fresh Welcome for same nostrGroupId" gaps from the review. Documents inline why Alice drives the eviction commit directly: the standalone-proposal ingestion pipeline (MarmotInboundProcessor) is a separate, still-unimplemented concern. https://claude.ai/code/session_01Unm6uLHGLj9UcBY7hWfJVW
This commit is contained in:
+150
@@ -20,15 +20,19 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.marmot.mls
|
||||
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
||||
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
|
||||
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager
|
||||
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.fail
|
||||
|
||||
/**
|
||||
* In-memory implementation of [MlsGroupStateStore] for testing.
|
||||
@@ -220,4 +224,150 @@ class MlsGroupManagerTest {
|
||||
assertTrue(manager2.isMember(groupId2))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End-to-end amethyst ↔ amethyst leave + rejoin round-trip at the
|
||||
* [MlsGroupManager] layer. Covers the gaps left by
|
||||
* [MlsGroupLifecycleTest.testReaddAfterRemove_RejoinerCanEncryptAndDecrypt]
|
||||
* (which operates a layer below on raw [MlsGroup] instances):
|
||||
*
|
||||
* 1. Bob's [leaveGroup] must actually wipe his persisted state —
|
||||
* `isMember` flips to false and the store no longer holds his group.
|
||||
* 2. Alice (admin) applying a Remove commit for the same leaf keeps
|
||||
* her own group consistent afterwards.
|
||||
* 3. Bob can rejoin the SAME `nostrGroupId` with a fresh KeyPackage;
|
||||
* the leave-side state cleanup must not leave any stale residue
|
||||
* that would corrupt the new Welcome.
|
||||
* 4. Post-rejoin, bidirectional MLS encryption works in both
|
||||
* directions — so the new epoch's tree, keying material and
|
||||
* exporter secrets are all in lockstep across both managers.
|
||||
*/
|
||||
@Test
|
||||
fun testLeaveAndRejoin_SameGroupIdEndToEnd() {
|
||||
runBlocking {
|
||||
val aliceStore = InMemoryGroupStateStore()
|
||||
val alice = MlsGroupManager(aliceStore)
|
||||
val bobStore = InMemoryGroupStateStore()
|
||||
val bob = MlsGroupManager(bobStore)
|
||||
|
||||
// Seed with a NostrGroupData extension so Bob's processWelcome
|
||||
// can derive the nostrGroupId from the Welcome's GroupContext.
|
||||
// adminPubkeys is intentionally empty — the admin-depletion
|
||||
// guard (MlsGroup.enforceNoAdminDepletion) and MIP-03
|
||||
// committer-authority check both short-circuit when no admins
|
||||
// are configured, so Alice can drive all commits and Bob can
|
||||
// SelfRemove without tripping the admin gate.
|
||||
val groupData =
|
||||
MarmotGroupData(
|
||||
nostrGroupId = groupId,
|
||||
name = "leave-rejoin",
|
||||
)
|
||||
alice.createGroup(
|
||||
groupId,
|
||||
"alice".encodeToByteArray(),
|
||||
initialExtensions = listOf(groupData.toExtension()),
|
||||
)
|
||||
assertTrue(alice.isMember(groupId))
|
||||
|
||||
// Bob publishes his first KeyPackage via a throwaway scratch
|
||||
// group (mirrors how a standalone KP is generated in
|
||||
// production before a Welcome has ever been seen).
|
||||
val bobBundle1 =
|
||||
MlsGroup
|
||||
.create("bob".encodeToByteArray())
|
||||
.createKeyPackage("bob".encodeToByteArray(), ByteArray(0))
|
||||
val firstAdd = alice.addMember(groupId, bobBundle1.keyPackage.toTlsBytes())
|
||||
val firstWelcome = firstAdd.welcomeBytes ?: fail("Alice's first add must produce a Welcome")
|
||||
|
||||
bob.processWelcome(firstWelcome, bobBundle1, hintNostrGroupId = groupId)
|
||||
assertTrue(bob.isMember(groupId))
|
||||
assertEquals(2, alice.getGroup(groupId)!!.memberCount)
|
||||
assertEquals(2, bob.getGroup(groupId)!!.memberCount)
|
||||
|
||||
// Baseline round-trip both directions.
|
||||
val hello = "hello before leave".encodeToByteArray()
|
||||
assertContentEquals(
|
||||
hello,
|
||||
bob.getGroup(groupId)!!.decrypt(alice.getGroup(groupId)!!.encrypt(hello)).content,
|
||||
)
|
||||
val pong = "pong before leave".encodeToByteArray()
|
||||
assertContentEquals(
|
||||
pong,
|
||||
alice.getGroup(groupId)!!.decrypt(bob.getGroup(groupId)!!.encrypt(pong)).content,
|
||||
)
|
||||
|
||||
// --- Leave: Bob's unilateral local teardown. -----------------
|
||||
// MlsGroupManager.leaveGroup returns the SelfRemove proposal
|
||||
// bytes (Alice would normally ingest these via the inbound
|
||||
// pipeline and fold them into a commit). It ALSO deletes Bob's
|
||||
// group from his store — the return contract is "your state
|
||||
// is gone now, the bytes are your farewell". Verify both.
|
||||
val (selfRemoveBytes, _) = bob.leaveGroup(groupId)
|
||||
assertTrue(selfRemoveBytes.isNotEmpty(), "SelfRemove bytes must be returned")
|
||||
assertFalse(bob.isMember(groupId), "Bob must no longer hold the group")
|
||||
assertNull(bobStore.load(groupId), "Bob's disk state for the group must be gone")
|
||||
assertEquals(
|
||||
emptyList(),
|
||||
bobStore.loadRetainedEpochs(groupId),
|
||||
"Bob's retained-epoch secrets must be wiped too",
|
||||
)
|
||||
|
||||
// --- Admin commit that actually evicts Bob's leaf. ----------
|
||||
// The standalone-proposal ingestion pipeline is a separate
|
||||
// concern (MarmotInboundProcessor.processPublicMessage still
|
||||
// returns "Standalone proposals not yet supported" for the
|
||||
// PROPOSAL content type). Here we verify the equivalent
|
||||
// admin-kick flow: Alice removes Bob's leaf directly, which
|
||||
// is what an admin auto-commit would do after picking up the
|
||||
// proposal.
|
||||
val bobLeafIndex = bob.getGroup(groupId)?.leafIndex ?: 1
|
||||
alice.removeMember(groupId, bobLeafIndex)
|
||||
assertEquals(
|
||||
1,
|
||||
alice.getGroup(groupId)!!.memberCount,
|
||||
"Alice must be solo after evicting Bob",
|
||||
)
|
||||
|
||||
// --- Rejoin: fresh KeyPackage + fresh Welcome, SAME groupId. -
|
||||
val bobBundle2 =
|
||||
MlsGroup
|
||||
.create("bob".encodeToByteArray())
|
||||
.createKeyPackage("bob".encodeToByteArray(), ByteArray(0))
|
||||
val secondAdd = alice.addMember(groupId, bobBundle2.keyPackage.toTlsBytes())
|
||||
val secondWelcome =
|
||||
secondAdd.welcomeBytes ?: fail("Alice's re-add must produce a Welcome")
|
||||
|
||||
bob.processWelcome(secondWelcome, bobBundle2, hintNostrGroupId = groupId)
|
||||
assertTrue(
|
||||
bob.isMember(groupId),
|
||||
"Bob must be a member again under the same nostrGroupId",
|
||||
)
|
||||
assertEquals(2, bob.getGroup(groupId)!!.memberCount)
|
||||
assertNotNull(
|
||||
bobStore.load(groupId),
|
||||
"Bob's store must hold fresh state for the same nostrGroupId",
|
||||
)
|
||||
|
||||
// Epoch must be aligned.
|
||||
assertEquals(
|
||||
alice.getGroup(groupId)!!.epoch,
|
||||
bob.getGroup(groupId)!!.epoch,
|
||||
"Rejoiner must sit at Alice's current epoch",
|
||||
)
|
||||
|
||||
// Bidirectional round-trip after rejoin — proves the shared
|
||||
// tree, keying material and exporter secrets are all back in
|
||||
// sync, not just that the Welcome deserialized cleanly.
|
||||
val afterHello = "hello after rejoin".encodeToByteArray()
|
||||
assertContentEquals(
|
||||
afterHello,
|
||||
bob.getGroup(groupId)!!.decrypt(alice.getGroup(groupId)!!.encrypt(afterHello)).content,
|
||||
)
|
||||
val afterPong = "pong after rejoin".encodeToByteArray()
|
||||
assertContentEquals(
|
||||
afterPong,
|
||||
alice.getGroup(groupId)!!.decrypt(bob.getGroup(groupId)!!.encrypt(afterPong)).content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user