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:
Claude
2026-04-23 22:05:12 +00:00
parent f08ad845f1
commit 73becef038
@@ -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,
)
}
}
}