From 5194190f5ecdd4d8d4143056bf773cfc9caffa11 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 22:20:01 +0000 Subject: [PATCH] test(marmot): cover MarmotManager leave + rejoin commons-layer round-trip The quartz-layer companion (testLeaveAndRejoin_SameGroupIdEndToEnd in MlsGroupManagerTest) only exercises the MLS engine. This one goes one layer up and drives the full commons-layer pipeline end to end: - NIP-59 gift wrap / unseal of the Welcome (kind:1059 -> kind:13 -> kind:444) via MarmotManager.ingest, - ChaCha20-Poly1305 outer + MLS PrivateMessage decryption of the group event (kind:445) via the same ingest entrypoint, - persisted inner-event log via MarmotMessageStore (asserts log is wiped on leave and does not resurrect pre-leave messages on rejoin), - subscription bookkeeping on MarmotSubscriptionManager (asserts subscribe on join, unsubscribe on leave, re-subscribe on rejoin), - KeyPackage bundle lifecycle via KeyPackageRotationManager + KeyPackageBundleStore (two distinct KPs generated for the two joins). Same admin-kick workaround as the quartz-layer test: the standalone- proposal ingestion path in MarmotInboundProcessor still returns "Standalone proposals not yet supported", so the test has Alice commit the Remove directly and documents that inline. Self-contained in-memory stores live alongside the test so we don't need to cross-depend on quartz's test utilities. Runs under commons androidHostTest (same source set that already pulls in secp256k1 JVM bindings for real crypto). https://claude.ai/code/session_01Unm6uLHGLj9UcBY7hWfJVW --- .../marmot/MarmotManagerLeaveRejoinTest.kt | 290 ++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 commons/src/androidHostTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerLeaveRejoinTest.kt diff --git a/commons/src/androidHostTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerLeaveRejoinTest.kt b/commons/src/androidHostTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerLeaveRejoinTest.kt new file mode 100644 index 000000000..6025c9484 --- /dev/null +++ b/commons/src/androidHostTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerLeaveRejoinTest.kt @@ -0,0 +1,290 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.marmot + +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore +import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * End-to-end leave + rejoin round-trip at the [MarmotManager] layer. + * + * One level above [com.vitorpamplona.quartz.marmot.mls.MlsGroupManagerTest.testLeaveAndRejoin_SameGroupIdEndToEnd] + * in quartz, which only exercises the MLS engine. This one drives the full + * commons-layer pipeline: + * + * - NIP-59 gift wrap / unwrap of the Welcome (kind:1059 → kind:13 → kind:444), + * - ChaCha20-Poly1305 outer layer + MLS PrivateMessage decryption of group + * events (kind:445) via `MarmotManager.ingest`, + * - persisted inner-event log via [MarmotMessageStore], + * - subscription bookkeeping in [com.vitorpamplona.quartz.marmot.MarmotSubscriptionManager], + * - KeyPackage bundle lifecycle via [KeyPackageBundleStore] + + * [com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager]. + * + * Scenarios covered in one flow: + * 1. Alice creates the group with a [MarmotGroupData] extension + * (required so the receiver's `processWelcome` can derive the + * nostrGroupId — admin list left empty so MIP-03 gates don't fire). + * 2. Bob publishes a KeyPackage, Alice `addMember`s it, Bob `ingest`s the + * resulting gift wrap and joins. + * 3. Round-trip: Alice sends a kind:9 via `buildTextMessage`, Bob ingests + * the resulting kind:445, the plaintext lands in his [MarmotMessageStore]. + * 4. `bob.leaveGroup(...)` wipes Bob's MLS state, persisted messages and + * subscription for that group. + * 5. Alice evicts Bob's leaf with an admin-kick Remove commit. (Standalone- + * proposal ingestion at [com.vitorpamplona.quartz.marmot.MarmotInboundProcessor] + * is a separate TODO — this test pins down the cleanup + rejoin story + * around it.) + * 6. Bob publishes a fresh KeyPackage, Alice `addMember`s it, Bob + * `ingest`s the second gift wrap and rejoins the same nostrGroupId. + * 7. Round-trip after rejoin proves the shared tree / keying material + * are in sync and that `MarmotManager.ingest` correctly routes a + * kind:445 authored at the new epoch. + */ +class MarmotManagerLeaveRejoinTest { + private val nostrGroupId = "a".repeat(64) + private val relay = + RelayUrlNormalizer.normalizeOrNull("wss://example.invalid/") + ?: error("test relay must normalize") + + @Test + fun testLeaveAndRejoin_CommonsLayer() { + runBlocking { + val alice = buildManager() + val bob = buildManager() + + // Step 1: Alice creates the group with a NostrGroupData extension. + val metadata = + MarmotGroupData( + nostrGroupId = nostrGroupId, + name = "leave-rejoin-commons", + relays = listOf(relay.url), + ) + alice.manager.createGroup(nostrGroupId, metadata) + assertTrue(alice.manager.isMember(nostrGroupId)) + + // Step 2: Bob publishes a KP, Alice adds him, Bob ingests the wrap. + val bobKp1 = bob.manager.generateKeyPackageEvent(listOf(relay)) + val (commitEvent1, delivery1) = + alice.manager.addMember(nostrGroupId, bobKp1, listOf(relay)) + assertNotNull(delivery1, "Alice's first add must produce a gift-wrapped Welcome") + + val firstJoin = bob.manager.ingest(delivery1.giftWrapEvent) + assertIs( + firstJoin, + "Bob must join via the gift-wrapped Welcome", + ) + assertEquals(nostrGroupId, firstJoin.nostrGroupId) + assertTrue(bob.manager.isMember(nostrGroupId)) + assertTrue( + bob.manager.subscriptionManager.isSubscribed(nostrGroupId), + "Joining must register a subscription for the group", + ) + + // The commit that added Bob also arrives at Alice (echo) — ingest + // is idempotent: her own pipeline marks the id processed before + // publish, so a replay routes to Ignored. + assertIs(alice.manager.ingest(commitEvent1.signedEvent)) + + // Step 3: Alice sends a kind:9 inner event. Bob ingests the kind:445. + val helloBefore = "hello before leave" + val helloBundle = alice.manager.buildTextMessage(nostrGroupId, helloBefore) + val helloResult = bob.manager.ingest(helloBundle.outbound.signedEvent) + val helloMessage = + assertIs(helloResult, "Bob should decrypt Alice's kind:9") + assertEquals(nostrGroupId, helloMessage.inner.groupId) + + val bobMessages1 = bob.manager.loadStoredMessages(nostrGroupId) + assertTrue( + bobMessages1.any { it.contains(helloBefore) }, + "Bob's persisted log should contain Alice's pre-leave message; was $bobMessages1", + ) + + // Step 4: Bob leaves. SelfRemove bytes are returned; local state wiped. + val leaveOutbound = bob.manager.leaveGroup(nostrGroupId) + assertTrue(leaveOutbound.signedEvent.content.isNotEmpty()) + assertFalse( + bob.manager.isMember(nostrGroupId), + "Bob must no longer hold the MLS group after leaveGroup", + ) + assertNull( + bob.mlsStore.load(nostrGroupId), + "Bob's MLS state must be deleted from the store", + ) + assertEquals( + emptyList(), + bob.manager.loadStoredMessages(nostrGroupId), + "Bob's persisted inner-event log must be wiped on leave", + ) + assertFalse( + bob.manager.subscriptionManager.isSubscribed(nostrGroupId), + "Bob's subscription for the group must be cleared on leave", + ) + + // Step 5: Alice evicts Bob's leaf (admin-kick stands in for the + // still-unimplemented standalone-proposal ingestion pipeline). + // Bob's leaf was assigned during addMember; look it up against + // Alice's MLS group view to avoid hard-coding a leaf index. + val bobLeaf = + alice.manager.groupManager + .getGroup(nostrGroupId) + ?.memberIdentityHex(1) + ?.let { 1 } + ?: error("Bob must still occupy leaf 1 on Alice's side") + alice.manager.removeMember(nostrGroupId, bobLeaf) + assertEquals(1, alice.manager.memberCount(nostrGroupId)) + + // Step 6: Bob rejoins with a fresh KP under the SAME nostrGroupId. + val bobKp2 = bob.manager.generateKeyPackageEvent(listOf(relay)) + val (_, delivery2) = + alice.manager.addMember(nostrGroupId, bobKp2, listOf(relay)) + assertNotNull(delivery2, "Alice's re-add must produce a second gift-wrapped Welcome") + + val secondJoin = bob.manager.ingest(delivery2.giftWrapEvent) + assertIs( + secondJoin, + "Bob must rejoin via the second gift wrap", + ) + assertEquals(nostrGroupId, secondJoin.nostrGroupId) + assertTrue(bob.manager.isMember(nostrGroupId)) + assertTrue( + bob.manager.subscriptionManager.isSubscribed(nostrGroupId), + "Rejoining must re-subscribe Bob to the group", + ) + assertNotNull( + bob.mlsStore.load(nostrGroupId), + "Bob's store must hold fresh MLS state for the rejoined group", + ) + + // Step 7: Round-trip after rejoin — proves the new epoch works + // end-to-end through commons, not just that the Welcome deserialized. + val helloAfter = "hello after rejoin" + val afterBundle = alice.manager.buildTextMessage(nostrGroupId, helloAfter) + val afterResult = bob.manager.ingest(afterBundle.outbound.signedEvent) + val afterMessage = + assertIs( + afterResult, + "Bob should decrypt Alice's post-rejoin kind:9", + ) + assertEquals(nostrGroupId, afterMessage.inner.groupId) + + val bobMessages2 = bob.manager.loadStoredMessages(nostrGroupId) + assertTrue( + bobMessages2.any { it.contains(helloAfter) }, + "Bob's fresh log should contain the post-rejoin message; was $bobMessages2", + ) + assertFalse( + bobMessages2.any { it.contains(helloBefore) }, + "Pre-leave messages MUST NOT survive the rejoin — forward secrecy sanity", + ) + } + } + + // --- Test fixtures ----------------------------------------------------- + + private data class Fixture( + val manager: MarmotManager, + val mlsStore: InMemoryMlsGroupStateStore, + val kpStore: InMemoryKeyPackageBundleStore, + val messageStore: InMemoryMarmotMessageStore, + ) + + private fun buildManager(): Fixture { + val signer = NostrSignerInternal(KeyPair()) + val mls = InMemoryMlsGroupStateStore() + val kp = InMemoryKeyPackageBundleStore() + val msg = InMemoryMarmotMessageStore() + return Fixture(MarmotManager(signer, mls, msg, kp), mls, kp, msg) + } +} + +private class InMemoryMlsGroupStateStore : MlsGroupStateStore { + private val states = mutableMapOf() + private val retained = mutableMapOf>() + + override suspend fun save( + nostrGroupId: String, + state: ByteArray, + ) { + states[nostrGroupId] = state + } + + override suspend fun load(nostrGroupId: String): ByteArray? = states[nostrGroupId] + + override suspend fun delete(nostrGroupId: String) { + states.remove(nostrGroupId) + retained.remove(nostrGroupId) + } + + override suspend fun listGroups(): List = states.keys.toList() + + override suspend fun saveRetainedEpochs( + nostrGroupId: String, + retainedSecrets: List, + ) { + retained[nostrGroupId] = retainedSecrets + } + + override suspend fun loadRetainedEpochs(nostrGroupId: String): List = retained[nostrGroupId] ?: emptyList() +} + +private class InMemoryKeyPackageBundleStore : KeyPackageBundleStore { + private var snapshot: ByteArray? = null + + override suspend fun save(snapshot: ByteArray) { + this.snapshot = snapshot + } + + override suspend fun load(): ByteArray? = snapshot + + override suspend fun delete() { + snapshot = null + } +} + +private class InMemoryMarmotMessageStore : MarmotMessageStore { + private val messages = mutableMapOf>() + + override suspend fun appendMessage( + nostrGroupId: String, + innerEventJson: String, + ) { + messages.getOrPut(nostrGroupId) { mutableListOf() }.add(innerEventJson) + } + + override suspend fun loadMessages(nostrGroupId: String): List = messages[nostrGroupId]?.toList() ?: emptyList() + + override suspend fun delete(nostrGroupId: String) { + messages.remove(nostrGroupId) + } +}