From 73becef0388fccc84981dcfa8fc4be62877be24c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 22:05:12 +0000 Subject: [PATCH] test(marmot): cover MlsGroupManager leave + rejoin round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../quartz/marmot/mls/MlsGroupManagerTest.kt | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupManagerTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupManagerTest.kt index 47c76842b..159febb7d 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupManagerTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupManagerTest.kt @@ -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, + ) + } + } }