diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt index 2b93a44319..5d3a9bdea0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt @@ -20,7 +20,9 @@ */ package com.vitorpamplona.amethyst.model +import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1 import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1 +import com.vitorpamplona.quartz.marmot.appComponents.MarmotWebUrl import com.vitorpamplona.quartz.marmot.appComponents.MessageRetentionV1 import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher @@ -553,6 +555,54 @@ class AccountMarmotActions( manager.syncMetadataTo(nostrGroupId, chatroom) } + /** + * Disband a Marmot MLS group (`marmot.group.lifecycle.v1`, `0x800c`). + * + * Irreversible and absorbing: every member's copy terminalizes when they + * apply the commit, and there is no commit that walks it back. The caller + * MUST have confirmed with a human first — this layer only refuses what is + * structurally impossible (a non-admin, a legacy group, a second disband), + * which is not the same as asking. + * + * Deliberately NOT silent on failure the way the other actions here are: a + * disband that did not happen must not look like one that did, so the + * exception propagates to the caller's error path. + */ + suspend fun disbandMarmotGroup( + nostrGroupId: HexKey, + groupRelays: Set, + ) { + val manager = account.marmotManager ?: return + if (!account.isWriteable()) return + + manager.disbandGroup(nostrGroupId, groupRelays.toList()) + val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId) + manager.syncMetadataTo(nostrGroupId, chatroom) + } + + /** + * Set or clear the group's plain-`https` avatar link + * (`marmot.group.avatar-url.v1`, `0x8007`). + * + * The lightweight avatar carrier: no Blossom upload, no key material, just + * a URL every Marmot client can render. A blank [url] clears it, which + * falls the group back to its encrypted Blossom image if it has one — the + * two carriers coexist and this one wins while it is set. + */ + suspend fun setMarmotGroupAvatarUrl( + nostrGroupId: HexKey, + url: String, + groupRelays: Set, + ) { + val manager = account.marmotManager ?: return + if (!account.isWriteable()) return + + val avatar = url.trim().takeIf { it.isNotEmpty() }?.let { GroupAvatarUrlV1(MarmotWebUrl.normalize(it, label = "avatar URL")) } + manager.setGroupAvatarUrl(nostrGroupId, avatar, groupRelays.toList()) + val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId) + manager.syncMetadataTo(nostrGroupId, chatroom) + } + /** * Grant admin privileges to [targetPubKey] in a Marmot MLS group by * appending them to `admin_pubkeys` via a GroupContextExtensions commit. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 32eb0d23a4..238a61bf4e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -2521,6 +2521,24 @@ class AccountViewModel( account.marmot.leaveMarmotGroup(nostrGroupId, relays) } + /** + * Disband the group for everyone. Irreversible — the caller is responsible + * for confirming with the user before this is reached. + */ + suspend fun disbandMarmotGroup(nostrGroupId: String) { + val relays = account.marmot.marmotGroupRelays(nostrGroupId) + account.marmot.disbandMarmotGroup(nostrGroupId, relays) + } + + /** Set (or, with a blank string, clear) the group's plain-https avatar link. */ + suspend fun setMarmotGroupAvatarUrl( + nostrGroupId: String, + url: String, + ) { + val relays = account.marmot.marmotGroupRelays(nostrGroupId) + account.marmot.setMarmotGroupAvatarUrl(nostrGroupId, url, relays) + } + suspend fun resetMarmotState() { account.marmot.resetMarmotState() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/EditGroupInfoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/EditGroupInfoScreen.kt index c8c26db6fd..40eb0300e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/EditGroupInfoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/EditGroupInfoScreen.kt @@ -43,6 +43,9 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.marmot_avatar_url +import com.vitorpamplona.amethyst.commons.resources.marmot_avatar_url_footer +import com.vitorpamplona.amethyst.commons.resources.marmot_avatar_url_placeholder import com.vitorpamplona.amethyst.commons.resources.marmot_edit_info_footer import com.vitorpamplona.amethyst.commons.resources.marmot_group_description_placeholder import com.vitorpamplona.amethyst.commons.resources.marmot_group_name @@ -71,9 +74,11 @@ fun EditGroupInfoScreen( val currentName by chatroom.displayName.collectAsStateWithLifecycle() val currentDescription by chatroom.description.collectAsStateWithLifecycle() val currentImage by chatroom.image.collectAsStateWithLifecycle() + val currentAvatarUrl by chatroom.avatarUrl.collectAsStateWithLifecycle() var name by remember(currentName) { mutableStateOf(currentName ?: "") } var description by remember(currentDescription) { mutableStateOf(currentDescription ?: "") } + var avatarUrl by remember(currentAvatarUrl) { mutableStateOf(currentAvatarUrl?.url.orEmpty()) } var pickedIcon by remember { mutableStateOf(null) } var removeIcon by remember { mutableStateOf(false) } var isSaving by remember { mutableStateOf(false) } @@ -81,7 +86,12 @@ fun EditGroupInfoScreen( val context = LocalContext.current val iconChanged = pickedIcon != null || removeIcon - val hasChanges = name != (currentName ?: "") || description != (currentDescription ?: "") || iconChanged + val avatarUrlChanged = avatarUrl.trim() != currentAvatarUrl?.url.orEmpty() + val hasChanges = + name != (currentName ?: "") || + description != (currentDescription ?: "") || + iconChanged || + avatarUrlChanged Scaffold( topBar = { @@ -104,6 +114,13 @@ fun EditGroupInfoScreen( description = description.trim(), icon = iconChange, ) + // A separate component (`0x8007`) and therefore a + // separate commit — only made when it actually + // changed, so saving a rename does not also + // rewrite the avatar state. + if (avatarUrlChanged) { + accountViewModel.setMarmotGroupAvatarUrl(nostrGroupId, avatarUrl.trim()) + } launch(Dispatchers.Main) { Toast .makeText(context, stringRes(context, R.string.marmot_group_info_updated), Toast.LENGTH_SHORT) @@ -179,6 +196,23 @@ fun EditGroupInfoScreen( enabled = !isSaving, ) + Spacer(modifier = Modifier.height(16.dp)) + + // The plain-https avatar carrier. It wins over the uploaded + // Blossom image while it is set, and clearing it falls the group + // back to that image — so the two fields are not alternatives to + // choose between, they stack. + OutlinedTextField( + value = avatarUrl, + onValueChange = { avatarUrl = it }, + label = { Text(stringRes(Res.string.marmot_avatar_url)) }, + placeholder = { Text(stringRes(Res.string.marmot_avatar_url_placeholder)) }, + supportingText = { Text(stringRes(Res.string.marmot_avatar_url_footer)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isSaving, + ) + Spacer(modifier = Modifier.height(8.dp)) Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt index 40dfcb0bcf..69ce70df00 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt @@ -75,6 +75,9 @@ import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.marmot_add_member import com.vitorpamplona.amethyst.commons.resources.marmot_add_member_placeholder import com.vitorpamplona.amethyst.commons.resources.marmot_add_to_group +import com.vitorpamplona.amethyst.commons.resources.marmot_disband_group +import com.vitorpamplona.amethyst.commons.resources.marmot_disband_group_action +import com.vitorpamplona.amethyst.commons.resources.marmot_disband_group_confirm import com.vitorpamplona.amethyst.commons.resources.marmot_edit_group_info import com.vitorpamplona.amethyst.commons.resources.marmot_grant import com.vitorpamplona.amethyst.commons.resources.marmot_grant_admin_confirm @@ -142,7 +145,9 @@ fun MarmotGroupInfoScreen( val relayActivity by chatroom.relayActivity.collectAsStateWithLifecycle() val members by chatroom.members.collectAsStateWithLifecycle() var showLeaveDialog by remember { mutableStateOf(false) } + var showDisbandDialog by remember { mutableStateOf(false) } var isLeaving by remember { mutableStateOf(false) } + var isDisbanding by remember { mutableStateOf(false) } var memberToRemove by remember { mutableStateOf(null) } var memberToPromote by remember { mutableStateOf(null) } var memberToDemote by remember { mutableStateOf(null) } @@ -182,9 +187,25 @@ fun MarmotGroupInfoScreen( contentDescription = stringRes(Res.string.marmot_edit_group_info), ) } + // Disband ends the conversation for EVERYONE, so only an + // admin sees it and it sits behind its own confirmation. + // Peers reject a non-admin's lifecycle commit anyway; not + // offering it is what keeps a member from trying. + if (myPubkey in adminPubkeys) { + IconButton( + onClick = { showDisbandDialog = true }, + enabled = !isLeaving && !isDisbanding, + ) { + Icon( + symbol = MaterialSymbols.DeleteForever, + contentDescription = stringRes(Res.string.marmot_disband_group), + tint = MaterialTheme.colorScheme.error, + ) + } + } IconButton( onClick = { showLeaveDialog = true }, - enabled = !isLeaving, + enabled = !isLeaving && !isDisbanding, ) { Icon( symbol = MaterialSymbols.AutoMirrored.ExitToApp, @@ -388,6 +409,41 @@ fun MarmotGroupInfoScreen( ) } + if (showDisbandDialog) { + DisbandGroupDialog( + groupName = displayName ?: stringRes(Res.string.marmot_this_group), + onConfirm = { + showDisbandDialog = false + isDisbanding = true + scope.launch(Dispatchers.IO) { + try { + accountViewModel.disbandMarmotGroup(nostrGroupId) + launch(Dispatchers.Main) { + Toast + .makeText(context, stringRes(context, R.string.marmot_group_disbanded_toast), Toast.LENGTH_SHORT) + .show() + } + nav.nav(Route.Message) + } catch (e: Exception) { + // A disband that reached no relay leaves the group + // live, so the screen must stay usable rather than + // navigate away on a change that did not happen. + isDisbanding = false + launch(Dispatchers.Main) { + Toast + .makeText( + context, + stringRes(context, R.string.marmot_failed_to_disband, e.message), + Toast.LENGTH_LONG, + ).show() + } + } + } + }, + onDismiss = { showDisbandDialog = false }, + ) + } + memberToRemove?.let { member -> ConfirmRemoveMemberDialog( memberPubkey = member.pubkey, @@ -650,6 +706,38 @@ fun LeaveGroupDialog( ) } +/** + * Confirmation for the one group action that cannot be undone. + * + * Disband is absorbing: every member's copy terminalizes and no commit walks it + * back, so the wording says "for everyone" and "cannot be reopened" rather than + * the usual "are you sure". + */ +@Composable +fun DisbandGroupDialog( + groupName: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(Res.string.marmot_disband_group)) }, + text = { + Text(stringRes(Res.string.marmot_disband_group_confirm, groupName)) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringRes(Res.string.marmot_disband_group_action), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringRes(R.string.cancel)) + } + }, + ) +} + @Composable private fun ConfirmRemoveMemberDialog( memberPubkey: HexKey, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 31c37ef9e9..dab6461e71 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2696,6 +2696,8 @@ Failed to update: %1$s Failed to create group: %1$s Failed to leave group: %1$s + Group disbanded + Could not disband the group: %1$s Adding %1$s… Failed to add %1$s: %2$s unknown error diff --git a/commons/src/commonMain/composeResources/values/strings.xml b/commons/src/commonMain/composeResources/values/strings.xml index 57ed45e69d..9884a9c103 100644 --- a/commons/src/commonMain/composeResources/values/strings.xml +++ b/commons/src/commonMain/composeResources/values/strings.xml @@ -2638,6 +2638,12 @@ 1 week Messages disappear after %1$s Messages are deleted from every member\'s device after this long. It cannot be changed later. + Disband group + Disband "%1$s" for everyone? The conversation ends for every member and cannot be reopened — a new group would have to be created. + Disband + Avatar link + https://example.com/avatar.png + An https link every member can load. Leave it empty to use the uploaded image instead. Marmot Group Group %1$s… %1$s… diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index 89aad9ab1d..c03e326b48 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaReferenceV2 import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaV2 import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1 import com.vitorpamplona.quartz.marmot.appComponents.GroupBlossomImageV1 +import com.vitorpamplona.quartz.marmot.appComponents.GroupLifecycleV1 import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1 import com.vitorpamplona.quartz.marmot.appComponents.MarmotGroupState import com.vitorpamplona.quartz.marmot.appComponents.MessageRetentionV1 @@ -1591,6 +1592,69 @@ class MarmotManager( }.event } + /** + * Disband the group: write `marmot.group.lifecycle.v1` (`0x800c`) as + * `disbanded` in a Commit every member replays. + * + * Disband is ABSORBING and irreversible. There is no un-disband commit and + * no later branch that supersedes it — a replacement conversation is a new + * MLS group with a new id. So this is deliberately the only writer of that + * component, it refuses to run twice, and the caller is expected to have + * confirmed with a human first. + * + * Only an admin may do it. The check is local *as well as* remote: peers + * reject a non-admin's lifecycle change anyway, but a non-admin who got + * this far would burn an epoch and desync themselves for a commit nobody + * applies, which is a worse failure than an exception. + * + * Current profile only — MIP-01's `0xF2EE` blob has no lifecycle field, so + * a legacy group genuinely cannot express "disbanded" and this refuses + * rather than writing the state somewhere no peer reads. + * + * The commit takes the normal publish-before-apply path, so a disband that + * no relay acknowledged does not terminalize the group locally either — + * exactly the outcome we want, since a locally-disbanded group nobody else + * heard about would be unreachable state. + */ + suspend fun disbandGroup( + nostrGroupId: HexKey, + relays: List = groupRelays(nostrGroupId), + ): OutboundGroupEvent { + val view = groupView(nostrGroupId) ?: throw IllegalStateException("Not a member of group $nostrGroupId") + check(view.isCurrentProfile) { + "Group $nostrGroupId is a legacy MIP-01 group and has no carrier for a lifecycle state" + } + check(signer.pubKey in view.adminPubkeys) { + "Only an admin of group $nostrGroupId can disband it" + } + check(groupManager.getGroup(nostrGroupId)?.currentGroupState()?.isDisbanded != true) { + "Group $nostrGroupId is already disbanded" + } + // requireOutboundAllowed also refuses an Unrecoverable group, which is + // the point: disbanding from state we do not trust would publish a + // terminal commit off a fork. + requireOutboundAllowed(nostrGroupId, "disband the group") + val publication = + commitAndPublish(nostrGroupId, relays) { + groupManager.stageAppDataUpdate( + nostrGroupId, + GroupLifecycleV1.COMPONENT_ID, + GroupLifecycleV1.DISBANDED.encode(), + ) + } + // Every other setter is content to leave an unacknowledged commit as a + // retryable obligation and say nothing, because a later retry lands the + // same state. This one cannot: the caller is about to tell a human the + // conversation is over, and a group that is still live for everyone + // else must not be reported as ended. The obligation IS still queued — + // the message says so — but the answer to "did it happen" is no. + check(publication.confirmed) { + "Disband of group $nostrGroupId reached no relay; it stays queued as a pending " + + "publish and the group is still live until one acknowledges it" + } + return publication.event + } + /** * Set or clear the group avatar, writing to whichever carrier the group uses. * diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDisbandTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDisbandTest.kt new file mode 100644 index 0000000000..75311c3305 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDisbandTest.kt @@ -0,0 +1,158 @@ +/* + * 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.appComponents.GroupProfileV1 +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * Disbanding a group — `marmot.group.lifecycle.v1`, component `0x800c`. + * + * We already REFUSED work in a disbanded group; nothing could put a group into + * that state from this side, so the enforcement was only ever reachable from a + * peer's commit. These cover the initiator half. + * + * Disband is absorbing: there is no un-disband, no later branch supersedes it, + * and a replacement conversation is a new MLS group. So the guards matter more + * than the happy path — a mistaken disband cannot be undone, and one published + * off unpublished or untrusted state would terminalize the group for everyone + * on a commit its author never confirmed. + */ +class MarmotDisbandTest { + private val nostrGroupId = "d".repeat(64) + + private class Fixture( + publisher: MarmotPublisher = ACCEPTING_RELAY, + ) { + val signer = NostrSignerInternal(KeyPair()) + val manager = + MarmotManager( + signer, + SnapshotStateStore(), + SnapshotMessageStore(), + SnapshotBundleStore(), + publisher = publisher, + ) + } + + private suspend fun Fixture.createCurrentProfile() = + manager.createCurrentProfileGroup( + nostrGroupId = nostrGroupId, + relays = listOf("wss://relay.invalid"), + profile = GroupProfileV1("doomed", ""), + ) + + @Test + fun `an admin disbands the group and everything after is refused`() = + runBlocking { + val f = Fixture() + f.createCurrentProfile() + + f.manager.disbandGroup(nostrGroupId) + + assertTrue(f.manager.groupState(nostrGroupId)?.isDisbanded == true) + assertEquals(GroupLifecycleState.DISBANDED, f.manager.lifecycle(nostrGroupId)) + + // The whole point of the state: outbound work stops. + assertFailsWith { + f.manager.buildTextMessage(nostrGroupId, "anyone still here?") + } + Unit + } + + @Test + fun `disband is absorbing and cannot be issued twice`() = + runBlocking { + val f = Fixture() + f.createCurrentProfile() + f.manager.disbandGroup(nostrGroupId) + + val thrown = assertFailsWith { f.manager.disbandGroup(nostrGroupId) } + assertTrue(thrown.message.orEmpty().contains("already disbanded")) + } + + @Test + fun `a non-admin member cannot disband`() = + runBlocking { + // Two clients: the creator is the admin, the invitee is not. Peers + // would reject a lifecycle change from a non-admin anyway; refusing + // locally is what stops the invitee burning an epoch on a commit + // nobody will apply. + val alice = Fixture() + val bob = Fixture() + alice.createCurrentProfile() + + val kp = bob.manager.generateKeyPackageEvent(relays = emptyList()) + val (_, welcome) = alice.manager.addMember(nostrGroupId, kp, emptyList()) + bob.manager.ingest(welcome!!.giftWrapEvent) + + val thrown = assertFailsWith { bob.manager.disbandGroup(nostrGroupId) } + assertTrue(thrown.message.orEmpty().contains("Only an admin")) + } + + @Test + fun `a legacy group has no carrier for a lifecycle state`() = + runBlocking { + val f = Fixture() + f.manager.createGroup( + nostrGroupId, + MarmotGroupData( + nostrGroupId = nostrGroupId, + name = "legacy", + relays = listOf("wss://relay.invalid"), + ), + ) + + val thrown = assertFailsWith { f.manager.disbandGroup(nostrGroupId) } + assertTrue(thrown.message.orEmpty().contains("legacy")) + } + + @Test + fun `a disband no relay accepted does not terminalize the group locally`() = + runBlocking { + // Publish-before-apply, on the one commit that cannot be walked + // back: a group disbanded here but nowhere else would be dead for + // us and alive for everyone. The obligation stays queued, the local + // group stays live, and the caller is told it did NOT happen — + // otherwise the UI would announce an ending that never occurred. + val f = Fixture(publisher = MarmotPublisher { _, _ -> false }) + f.createCurrentProfile() + + val thrown = assertFailsWith { f.manager.disbandGroup(nostrGroupId) } + assertTrue(thrown.message.orEmpty().contains("reached no relay")) + + assertTrue(f.manager.groupState(nostrGroupId)?.isDisbanded != true) + assertTrue(f.manager.lifecycle(nostrGroupId) != GroupLifecycleState.DISBANDED) + + // Still a working group: nothing about a failed disband may leak + // into the states that stop outbound work. + f.manager.buildTextMessage(nostrGroupId, "still here") + Unit + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/scenario/MarmotScenarioRunner.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/scenario/MarmotScenarioRunner.kt index 8547762bdd..0957889afb 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/scenario/MarmotScenarioRunner.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/scenario/MarmotScenarioRunner.kt @@ -70,8 +70,23 @@ class MarmotScenarioRunner( ) { private val clients = vector.clients.associateWith { VectorClient(it) } - /** Queued outbound events: (sender, event). Drained by `deliver_all`. */ - private val inFlight = mutableListOf>() + /** Queued outbound events, drained by `deliver_all`. */ + private val inFlight = mutableListOf() + + /** Messages pulled out of the queue by `withhold_message`, by label. */ + private val withheld = mutableMapOf>() + + /** + * One event sitting in the delivery queue, with the facts a fault selector + * matches on: who sent it, whether it is an application message or a + * commit, and — for a commit — the publication label the vector gave it. + */ + private class Queued( + val sender: String, + val event: Event, + val messageClass: String, + val publication: String?, + ) /** * The group label the current step runs against. @@ -130,6 +145,9 @@ class MarmotScenarioRunner( } } + /** The publication label the NEXT publish by this client carries, if any. */ + private fun peekLabel(client: String): String? = publishOutcomes[client]?.firstOrNull()?.first?.takeIf { it.isNotEmpty() } + private fun nextOutcome(client: String): Boolean { val queue = publishOutcomes[client] ?: return true if (queue.isEmpty()) return true @@ -150,8 +168,9 @@ class MarmotScenarioRunner( SnapshotBundleStore(), publisher = MarmotPublisher { event, _ -> + val label = peekLabel(client.name) val accepted = nextOutcome(client.name) - if (accepted) inFlight.add(client.name to event) + if (accepted) inFlight.add(Queued(client.name, event, COMMIT_CLASS, label)) accepted }, ) @@ -171,6 +190,13 @@ class MarmotScenarioRunner( "in_group" -> inGroup(step) "clear_events" -> clearEvents(step) "assert" -> assertPredicate(step) + "update_group_data" -> updateGroupData(step) + "remove_members" -> removeMembers(step) + "omit_message" -> omitMessage(step) + "duplicate_message" -> duplicateMessage(step) + "reorder_messages" -> reorderMessages(step) + "withhold_message" -> withholdMessage(step) + "release_withheld" -> releaseWithheld(step) // The publication's outcome was consumed when it was made; the step // itself carries no further state change. "acknowledge_outbound" -> Unit @@ -218,6 +244,11 @@ class MarmotScenarioRunner( */ private fun assertPredicate(step: ScenarioVector.Step) { val assertion = step.obj("assertion") ?: error("assert without an assertion") + // `exactly` is the only mode in this set. A different one would mean a + // different comparison (at-least, at-most), so refuse rather than + // silently applying equality to it. + val mode = assertion.string("mode") ?: "exactly" + if (mode != "exactly") throw UnsupportedScenarioStep("assert/mode=$mode") val predicate = assertion.step("predicate") ?: error("assertion without a predicate") when (predicate.type) { "payload_count" -> { @@ -294,6 +325,131 @@ class MarmotScenarioRunner( } } + /** + * Rename the group — the vector's `update_group_data`. + * + * Only `name` ever appears in these vectors, and the current profile keeps + * it in `marmot.group.profile.v1` (`0x8001`), so this is a profile commit + * that preserves the description rather than a blanket metadata replace. + */ + private suspend fun updateGroupData(step: ScenarioVector.Step) { + val who = client(step.string("client") ?: error("update_group_data without a client")) + val groupId = who.groups[currentGroup] ?: error("${who.name} renamed a group it is not in") + val name = step.string("name").orEmpty() + val description = + who.manager + .groupView(groupId) + ?.description + .orEmpty() + who.manager.setGroupProfile(groupId, name, description, emptyList()) + } + + /** + * Evict members by name. The vector names people; MLS removes leaves, so + * the pubkey is resolved to the leaf index the group actually holds. + */ + private suspend fun removeMembers(step: ScenarioVector.Step) { + val remover = client(step.string("remover") ?: error("remove_members without a remover")) + val groupId = remover.groups[currentGroup] ?: error("${remover.name} evicted from a group it is not in") + val targets = step.strings("members").map { client(it).signer.pubKey }.toSet() + val leaves = + remover.manager + .memberPubkeys(groupId) + .filter { it.pubkey in targets } + .map { it.leafIndex } + check(leaves.size == targets.size) { + "remove_members names ${targets.size} members but only ${leaves.size} are in the group" + } + // One Remove per commit. Every vector in this set evicts exactly one + // member per step, and the step names ONE publication — so a + // multi-member step would publish N commits against one + // acknowledgement and silently mis-align every outcome after it. + // Refuse instead, the same way an unimplemented step is refused. + check(leaves.size == 1) { + "remove_members evicts ${leaves.size} members in one step; this runner commits one " + + "Remove at a time and the vector's single acknowledgement would not line up" + } + remover.manager.removeMember(groupId, leaves.single(), emptyList()) + } + + /** + * Does this queued event match a fault selector? + * + * Every key is a conjunct and an unknown key is refused rather than + * ignored — a selector we silently widen would inject a different fault + * from the one the vector scripted, and still report on the vector's name. + */ + private fun matches( + queued: Queued, + selector: ScenarioVector.Step, + ): Boolean { + selector.keys().forEach { key -> + when (key) { + "sender" -> if (selector.string("sender") != queued.sender) return false + "class" -> if (selector.string("class") != queued.messageClass) return false + "publication" -> if (selector.string("publication") != queued.publication) return false + // Handled by the caller: it picks which of the matches to act on. + "occurrence" -> Unit + else -> throw UnsupportedScenarioStep("selector/$key") + } + } + return true + } + + /** Indices in [inFlight] the selector names, honouring `occurrence`. */ + private fun select(selector: ScenarioVector.Step): List { + val all = inFlight.indices.filter { matches(inFlight[it], selector) } + val occurrence = selector.int("occurrence") ?: return all + return listOfNotNull(all.getOrNull(occurrence)) + } + + private fun selectorOf(step: ScenarioVector.Step) = step.obj("selector") ?: error("${step.type} without a selector") + + /** Drop a queued message entirely — it never reaches anyone. */ + private fun omitMessage(step: ScenarioVector.Step) { + val hit = select(selectorOf(step)) + check(hit.isNotEmpty()) { "omit_message matched nothing in a queue of ${inFlight.size}" } + hit.sortedDescending().forEach { inFlight.removeAt(it) } + } + + /** Deliver a queued message twice. The receiver must not act on it twice. */ + private fun duplicateMessage(step: ScenarioVector.Step) { + val hit = select(selectorOf(step)) + check(hit.isNotEmpty()) { "duplicate_message matched nothing in a queue of ${inFlight.size}" } + hit.sortedDescending().forEach { inFlight.add(it + 1, inFlight[it]) } + } + + /** Deliver the queue in the order the vector names, not the order it was sent. */ + private fun reorderMessages(step: ScenarioVector.Step) { + val order = step.steps("order") + val taken = mutableSetOf() + val reordered = mutableListOf() + order.forEach { selector -> + val index = select(selector).firstOrNull { it !in taken } + checkNotNull(index) { "reorder_messages names a message that is not queued" } + taken.add(index) + reordered.add(inFlight[index]) + } + inFlight.indices.filter { it !in taken }.forEach { reordered.add(inFlight[it]) } + inFlight.clear() + inFlight.addAll(reordered) + } + + /** Hold a message back under a label; `release_withheld` puts it back. */ + private fun withholdMessage(step: ScenarioVector.Step) { + val label = step.string("label") ?: error("withhold_message without a label") + val hit = select(selectorOf(step)) + check(hit.isNotEmpty()) { "withhold_message matched nothing in a queue of ${inFlight.size}" } + val held = withheld.getOrPut(label) { mutableListOf() } + hit.sortedDescending().forEach { held.add(0, inFlight.removeAt(it)) } + } + + private fun releaseWithheld(step: ScenarioVector.Step) { + val label = step.string("label") ?: error("release_withheld without a label") + val held = withheld.remove(label) ?: error("release_withheld names an unknown label '$label'") + inFlight.addAll(held) + } + private suspend fun sendAppMessage(step: ScenarioVector.Step) { val sender = client(step.string("sender") ?: error("send_app_message without a sender")) val groupId = sender.groups[currentGroup] ?: error("${sender.name} sent before joining a group") @@ -303,17 +459,17 @@ class MarmotScenarioRunner( // it itself. Leaving that out meant every `send_app_message` built an // event nobody ever delivered. val bundle = sender.manager.buildTextMessage(groupId, payload, persistOwn = false) - inFlight.add(sender.name to bundle.outbound.signedEvent) + inFlight.add(Queued(sender.name, bundle.outbound.signedEvent, APPLICATION_CLASS, null)) } private fun deliverAll() { val batch = inFlight.toList() inFlight.clear() - batch.forEach { (senderName, event) -> + batch.forEach { queued -> // Broadcast to everyone else, including clients who are not in the // sending group. Their engine refusing that traffic is precisely // what multigroup isolation asserts. - clients.values.filter { it.name != senderName }.forEach { it.inbox.add(event) } + clients.values.filter { it.name != queued.sender }.forEach { it.inbox.add(queued.event) } } } @@ -348,14 +504,23 @@ class MarmotScenarioRunner( } } + /** + * The membership this client currently sees, or empty when it can no + * longer see the group at all — a client that was just evicted has no + * roster to read, and the vectors reach that state on purpose. + */ private fun membersOf( client: VectorClient, groupId: HexKey, ): Set = - client.manager - .memberPubkeys(groupId) - .map { it.pubkey } - .toSet() + try { + client.manager + .memberPubkeys(groupId) + .map { it.pubkey } + .toSet() + } catch (_: Exception) { + emptySet() + } /** Compare every client's end state against the vector's expected trace. */ private fun verify() { @@ -404,6 +569,10 @@ class MarmotScenarioRunner( val got = client.manager.groupView(groupId)?.name if (got != want) failures.add("${expected.client}[$label] group name '$got', expected '$want'") } + expected.groupDescription?.let { want -> + val got = client.manager.groupView(groupId)?.description + if (got != want) failures.add("${expected.client}[$label] group description '$got', expected '$want'") + } } if (expected.receivedPayloads.isNotEmpty()) { val got = client.received.sorted() @@ -427,6 +596,10 @@ class MarmotScenarioRunner( private companion object { const val CHAT_KIND = 9 + /** The two message classes a fault selector distinguishes. */ + const val APPLICATION_CLASS = "application" + const val COMMIT_CLASS = "commit" + /** The label for a vector that never says `in_group` — most of them. */ const val DEFAULT_GROUP = "default" } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/scenario/MarmotScenarioVectorTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/scenario/MarmotScenarioVectorTest.kt index a7941c9279..fbd0b5ed09 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/scenario/MarmotScenarioVectorTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/scenario/MarmotScenarioVectorTest.kt @@ -86,6 +86,37 @@ class MarmotScenarioVectorTest { @Test fun conversation() = replay("conversation.v1.json") + /** A rename lands on every member as a commit, not as a hint. */ + @Test + fun groupDataUpdate() = replay("group-data-update.v1.json") + + /** A client that missed several rounds catches up on one tick. */ + @Test + fun deferredTickCatchup() = replay("deferred-tick-catchup.v1.json") + + /** Members added at each step see only what came after them. */ + @Test + fun incrementalGrowth() = replay("incremental-growth.v1.json") + + /** + * The delivery-fault family: a dropped message, and a queue that duplicates + * and reorders before it delivers. What arrives twice must be acted on + * once, and out-of-order arrival must not change the end state. + */ + @Test + fun dropQueued() = replay("drop-queued.v1.json") + + @Test + fun queueFaults() = replay("queue-faults.v1.json") + + /** An application message from an epoch the group has already left. */ + @Test + fun delayedPastEpochAppMessage() = replay("delayed-past-epoch-app-message.v1.json") + + /** Evicted and invited back: the second membership is not the first. */ + @Test + fun readdAfterEviction() = replay("readd-after-eviction.v1.json") + /** * `convergence-committer-selected` concludes with a `convergence_decision` * — which tip the client picked, under which rule, and whether the witness @@ -151,6 +182,13 @@ class MarmotScenarioVectorTest { "three-client-message-exchange.v1.json", "conversation.v1.json", "convergence-committer-selected.v1.json", + "group-data-update.v1.json", + "deferred-tick-catchup.v1.json", + "incremental-growth.v1.json", + "drop-queued.v1.json", + "queue-faults.v1.json", + "delayed-past-epoch-app-message.v1.json", + "readd-after-eviction.v1.json", ) } } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/scenario/ScenarioVector.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/scenario/ScenarioVector.kt index 64a888efe3..35615f2b9a 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/scenario/ScenarioVector.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/scenario/ScenarioVector.kt @@ -76,6 +76,19 @@ class ScenarioVector( /** A nested object read as a [Step] so the same accessors work on it. */ fun obj(key: String): Step? = (raw[key] as? JsonObject)?.let { Step(key, it) } + + /** A nested array of objects, each read as a [Step]. */ + fun steps(key: String): List = + (raw[key] as? JsonArray) + ?.mapNotNull { element -> + (element as? JsonObject)?.let { Step((it["type"] as? JsonPrimitive)?.content.orEmpty(), it) } + }.orEmpty() + + /** + * The keys this object carries. A fault selector is checked key by key + * so an unknown one can be refused rather than quietly widening it. + */ + fun keys(): Set = raw.keys } /** What one client's state must look like when the script says to look. */ @@ -91,6 +104,8 @@ class ScenarioVector( * list, and an empty list is itself an assertion. */ val addedMembers: List? = null, + /** The group description the vector states, when it states one. */ + val groupDescription: String? = null, ) /** @@ -160,6 +175,21 @@ class ScenarioVector( ) } + // A profile assertion is per-client state like any other, + // just stated separately because it is about the group's + // metadata rather than its membership. + "group_profile" -> + stated.add( + Observation( + client = obj.getValue("client").jsonPrimitive.content, + epoch = null, + memberCount = null, + groupName = (obj["name"] as? JsonPrimitive)?.takeIf { it.isString }?.content, + receivedPayloads = emptyList(), + groupDescription = (obj["description"] as? JsonPrimitive)?.takeIf { it.isString }?.content, + ), + ) + "pending_resolution" -> resolutions.add( PendingResolution( diff --git a/commons/src/jvmTest/resources/marmot/vectors/README.md b/commons/src/jvmTest/resources/marmot/vectors/README.md index 085b92f273..d012cdd524 100644 --- a/commons/src/jvmTest/resources/marmot/vectors/README.md +++ b/commons/src/jvmTest/resources/marmot/vectors/README.md @@ -11,14 +11,37 @@ fixtures we already consume from `quartz/src/commonTest/resources/marmot/ conformance/`. The rest are **scenario scripts**: a client roster, a step list, and an expected trace. -These nine are the subset whose steps `MarmotScenarioRunner` implements — -`create_group`, `invite_members`, `send_app_message`, `deliver_all`, `tick`, -`acknowledge_outbound`, `observe`, `in_group`, `assert`, `clear_events`. The -other nineteen need fault injection (withhold/release, partition, duplicate, -reorder, restart) or group-data and admin-policy steps; the runner refuses +These sixteen are the subset whose steps `MarmotScenarioRunner` implements: +`create_group`, `invite_members`, `remove_members`, `send_app_message`, +`update_group_data`, `deliver_all`, `tick`, `acknowledge_outbound`, `observe`, +`in_group`, `assert`, `clear_events`, and the queue faults `omit_message`, +`duplicate_message`, `reorder_messages`, `withhold_message`, +`release_withheld`. The rest need `restart_client`, `set_partition`, `leave`, +admin-policy steps, or the `convergence_decision` outcome; the runner refuses them by name rather than skipping quietly, so adding a step type is what widens the set. +## Two expectation shapes + +A vector states what must be true in one of two ways, and BOTH have to be +read: + +- `expected_trace.observations` — the older shape (`publish-fail`, + `three-client-message-exchange`). +- `expected_outcomes` — a list of typed entries: `client_state`, + `clients_converged`, `group_profile`, `pending_resolution`, + `no_pending_work`, and `convergence_decision`. Everything else here uses + this one. + +Reading only the first is not a partial check, it is no check: a vector whose +expectations all live in the other shape replays its steps and reports green +having compared nothing. `MarmotScenarioVectorTest.everyVectorStatesSomethingToCheck` +exists to make that failure loud rather than invisible. + +An outcome type the runner cannot evaluate raises `UnsupportedScenarioOutcome` +and the vector is refused — `convergence-committer-selected` is refused today +for exactly that reason. + ## Refreshing ```bash diff --git a/commons/src/jvmTest/resources/marmot/vectors/deferred-tick-catchup.v1.json b/commons/src/jvmTest/resources/marmot/vectors/deferred-tick-catchup.v1.json new file mode 100644 index 0000000000..2a31e2c68a --- /dev/null +++ b/commons/src/jvmTest/resources/marmot/vectors/deferred-tick-catchup.v1.json @@ -0,0 +1,272 @@ +{ + "scenario_name": "deferred-tick-catchup/v1", + "vector_version": "1", + "conformance_version": "0.9.19", + "seed": null, + "scenario": { + "name": "deferred-tick-catchup/v1", + "spec_version": "2", + "clients": [ + "alice", + "bob", + "carol", + "dave" + ], + "steps": [ + { + "type": "create_group", + "creator": "alice", + "name": "reconnect", + "invitees": [ + "bob", + "carol", + "dave" + ], + "required_features": [], + "pending": "create", + "initial_admins": [ + "alice" + ] + }, + { + "type": "acknowledge_outbound", + "client": "alice", + "publication": "create", + "outcome": "accepted" + }, + { + "type": "deliver_all" + }, + { + "type": "tick", + "clients": [ + "bob", + "carol", + "dave" + ] + }, + { + "type": "clear_events", + "clients": [ + "alice", + "bob", + "carol", + "dave" + ] + }, + { + "type": "send_app_message", + "sender": "alice", + "payload": "reconnect:warm:alice" + }, + { + "type": "send_app_message", + "sender": "bob", + "payload": "reconnect:warm:bob" + }, + { + "type": "send_app_message", + "sender": "carol", + "payload": "reconnect:warm:carol" + }, + { + "type": "send_app_message", + "sender": "dave", + "payload": "reconnect:warm:dave" + }, + { + "type": "deliver_all" + }, + { + "type": "tick", + "clients": [ + "alice", + "bob", + "carol", + "dave" + ] + }, + { + "type": "send_app_message", + "sender": "alice", + "payload": "reconnect:away:alice" + }, + { + "type": "send_app_message", + "sender": "bob", + "payload": "reconnect:away:bob" + }, + { + "type": "send_app_message", + "sender": "carol", + "payload": "reconnect:away:carol" + }, + { + "type": "deliver_all" + }, + { + "type": "tick", + "clients": [ + "alice", + "bob", + "carol" + ] + }, + { + "type": "update_group_data", + "client": "alice", + "name": "reconnect-renamed", + "pending": "rename" + }, + { + "type": "acknowledge_outbound", + "client": "alice", + "publication": "rename", + "outcome": "accepted" + }, + { + "type": "deliver_all" + }, + { + "type": "tick", + "clients": [ + "bob", + "carol" + ] + }, + { + "type": "send_app_message", + "sender": "alice", + "payload": "reconnect:back:alice" + }, + { + "type": "send_app_message", + "sender": "bob", + "payload": "reconnect:back:bob" + }, + { + "type": "send_app_message", + "sender": "carol", + "payload": "reconnect:back:carol" + }, + { + "type": "deliver_all" + }, + { + "type": "tick", + "clients": [ + "alice", + "bob", + "carol" + ] + }, + { + "type": "tick", + "clients": [ + "dave" + ] + }, + { + "type": "observe_exact", + "clients": [ + "alice", + "bob", + "carol", + "dave" + ] + } + ] + }, + "expected_outcomes": [ + { + "type": "pending_resolution", + "step_index": 1, + "client": "alice", + "pending": "create", + "resolution": "confirmed" + }, + { + "type": "pending_resolution", + "step_index": 17, + "client": "alice", + "pending": "rename", + "resolution": "confirmed" + }, + { + "type": "client_state", + "client": "dave", + "epoch": 2, + "member_count": 4, + "received_payloads": [ + "reconnect:warm:alice", + "reconnect:warm:bob", + "reconnect:warm:carol", + "reconnect:away:alice", + "reconnect:away:bob", + "reconnect:away:carol", + "reconnect:back:alice", + "reconnect:back:bob", + "reconnect:back:carol" + ] + }, + { + "type": "client_state", + "client": "alice", + "epoch": 2, + "member_count": 4, + "received_payloads": [ + "reconnect:warm:bob", + "reconnect:warm:carol", + "reconnect:warm:dave", + "reconnect:away:bob", + "reconnect:away:carol", + "reconnect:back:bob", + "reconnect:back:carol" + ] + }, + { + "type": "group_profile", + "client": "alice", + "name": "reconnect-renamed", + "description": "" + }, + { + "type": "group_profile", + "client": "bob", + "name": "reconnect-renamed", + "description": "" + }, + { + "type": "group_profile", + "client": "carol", + "name": "reconnect-renamed", + "description": "" + }, + { + "type": "group_profile", + "client": "dave", + "name": "reconnect-renamed", + "description": "" + }, + { + "type": "clients_converged", + "clients": [ + "alice", + "bob", + "carol", + "dave" + ], + "epoch": 2, + "member_count": 4 + }, + { + "type": "no_pending_work", + "clients": [ + "alice", + "bob", + "carol", + "dave" + ] + } + ] +} diff --git a/commons/src/jvmTest/resources/marmot/vectors/delayed-past-epoch-app-message.v1.json b/commons/src/jvmTest/resources/marmot/vectors/delayed-past-epoch-app-message.v1.json new file mode 100644 index 0000000000..e7cf71580e --- /dev/null +++ b/commons/src/jvmTest/resources/marmot/vectors/delayed-past-epoch-app-message.v1.json @@ -0,0 +1,132 @@ +{ + "scenario_name": "delayed-past-epoch-app-message/v1", + "vector_version": "1", + "conformance_version": "0.9.19", + "seed": null, + "scenario": { + "name": "delayed-past-epoch-app-message/v1", + "spec_version": "2", + "clients": [ + "alice", + "bob", + "carol", + "david" + ], + "steps": [ + { + "type": "create_group", + "creator": "alice", + "name": "delayed-past-epoch-app", + "invitees": [ + "bob", + "carol" + ], + "required_features": [], + "pending": "create" + }, + { + "type": "acknowledge_outbound", + "client": "alice", + "publication": "create", + "outcome": "accepted" + }, + { + "type": "deliver_all" + }, + { + "type": "tick", + "clients": [ + "bob", + "carol" + ] + }, + { + "type": "clear_events", + "clients": [ + "alice", + "bob", + "carol", + "david" + ] + }, + { + "type": "send_app_message", + "sender": "bob", + "payload": "epoch-one-delayed" + }, + { + "type": "withhold_message", + "selector": { "sender": "bob", "class": "application" }, + "label": "old-app" + }, + { + "type": "invite_members", + "inviter": "alice", + "invitees": [ + "david" + ], + "pending": "invite-david" + }, + { + "type": "acknowledge_outbound", + "client": "alice", + "publication": "invite-david", + "outcome": "accepted" + }, + { + "type": "deliver_all" + }, + { + "type": "tick", + "clients": [ + "carol", + "david" + ] + }, + { + "type": "release_withheld", + "label": "old-app" + }, + { + "type": "deliver_all" + }, + { + "type": "tick", + "clients": [ + "carol" + ] + }, + { + "type": "observe", + "clients": [ + "carol" + ] + } + ] + }, + "expected_outcomes": [ + { + "type": "pending_resolution", + "step_index": 1, + "client": "alice", + "pending": "create", + "resolution": "confirmed" + }, + { + "type": "pending_resolution", + "step_index": 8, + "client": "alice", + "pending": "invite-david", + "resolution": "confirmed" + }, + { + "type": "client_state", + "client": "carol", + "epoch": 2, + "member_count": 4, + "received_payloads": [ + "epoch-one-delayed" + ] + } + ] +} diff --git a/commons/src/jvmTest/resources/marmot/vectors/drop-queued.v1.json b/commons/src/jvmTest/resources/marmot/vectors/drop-queued.v1.json new file mode 100644 index 0000000000..11cdd1f730 --- /dev/null +++ b/commons/src/jvmTest/resources/marmot/vectors/drop-queued.v1.json @@ -0,0 +1,95 @@ +{ + "scenario_name": "drop-queued/v1", + "vector_version": "1", + "conformance_version": "0.9.19", + "seed": null, + "scenario": { + "name": "drop-queued/v1", + "spec_version": "2", + "clients": [ + "alice", + "bob" + ], + "steps": [ + { + "type": "create_group", + "creator": "alice", + "name": "drop", + "invitees": [ + "bob" + ], + "required_features": [], + "pending": "create" + }, + { + "type": "acknowledge_outbound", + "client": "alice", + "publication": "create", + "outcome": "accepted" + }, + { + "type": "deliver_all" + }, + { + "type": "tick", + "clients": [ + "bob" + ] + }, + { + "type": "clear_events", + "clients": [ + "alice", + "bob" + ] + }, + { + "type": "send_app_message", + "sender": "bob", + "payload": "bob:dropped" + }, + { + "type": "omit_message", + "selector": { "sender": "bob", "class": "application" } + }, + { + "type": "send_app_message", + "sender": "bob", + "payload": "bob:delivered" + }, + { + "type": "deliver_all" + }, + { + "type": "tick", + "clients": [ + "alice" + ] + }, + { + "type": "observe", + "clients": [ + "alice" + ] + } + ] + }, + "expected_outcomes": [ + { + "type": "pending_resolution", + "step_index": 1, + "client": "alice", + "pending": "create", + "resolution": "confirmed" + }, + { + "type": "client_state", + "client": "alice", + "epoch": 1, + "member_count": 2, + "received_payloads": [ + "bob:delivered" + ] + } + ] +} diff --git a/commons/src/jvmTest/resources/marmot/vectors/group-data-update.v1.json b/commons/src/jvmTest/resources/marmot/vectors/group-data-update.v1.json new file mode 100644 index 0000000000..f57d236a3b --- /dev/null +++ b/commons/src/jvmTest/resources/marmot/vectors/group-data-update.v1.json @@ -0,0 +1,127 @@ +{ + "scenario_name": "group-data-update/v1", + "vector_version": "1", + "conformance_version": "0.9.19", + "seed": null, + "scenario": { + "name": "group-data-update/v1", + "spec_version": "2", + "clients": [ + "alice", + "bob" + ], + "steps": [ + { + "type": "create_group", + "creator": "alice", + "name": "before", + "invitees": [ + "bob" + ], + "required_features": [], + "pending": "create" + }, + { + "type": "acknowledge_outbound", + "client": "alice", + "publication": "create", + "outcome": "accepted" + }, + { + "type": "deliver_all" + }, + { + "type": "tick", + "clients": [ + "bob" + ] + }, + { + "type": "clear_events", + "clients": [ + "alice", + "bob" + ] + }, + { + "type": "update_group_data", + "client": "alice", + "name": "after", + "pending": "rename" + }, + { + "type": "acknowledge_outbound", + "client": "alice", + "publication": "rename", + "outcome": "accepted" + }, + { + "type": "deliver_all" + }, + { + "type": "tick", + "clients": [ + "bob" + ] + }, + { + "type": "observe", + "clients": [ + "alice", + "bob" + ] + } + ] + }, + "expected_outcomes": [ + { + "type": "pending_resolution", + "step_index": 1, + "client": "alice", + "pending": "create", + "resolution": "confirmed" + }, + { + "type": "pending_resolution", + "step_index": 6, + "client": "alice", + "pending": "rename", + "resolution": "confirmed" + }, + { + "type": "client_state", + "client": "alice", + "epoch": 2, + "member_count": 2, + "received_payloads": [] + }, + { + "type": "client_state", + "client": "bob", + "epoch": 2, + "member_count": 2, + "received_payloads": [] + }, + { + "type": "group_profile", + "client": "alice", + "name": "after", + "description": "" + }, + { + "type": "group_profile", + "client": "bob", + "name": "after", + "description": "" + }, + { + "type": "clients_converged", + "clients": [ + "alice", + "bob" + ], + "epoch": 2, + "member_count": 2 + } + ] +} diff --git a/commons/src/jvmTest/resources/marmot/vectors/incremental-growth.v1.json b/commons/src/jvmTest/resources/marmot/vectors/incremental-growth.v1.json new file mode 100644 index 0000000000..e52636c3f8 --- /dev/null +++ b/commons/src/jvmTest/resources/marmot/vectors/incremental-growth.v1.json @@ -0,0 +1,376 @@ +{ + "scenario_name": "incremental-growth/v1", + "vector_version": "1", + "conformance_version": "0.9.19", + "seed": null, + "scenario": { + "name": "incremental-growth/v1", + "spec_version": "2", + "clients": [ + "alice", + "bob", + "carol", + "dave" + ], + "steps": [ + { + "type": "create_group", + "creator": "alice", + "name": "growth-2", + "invitees": [ + "bob" + ], + "required_features": [], + "pending": "create" + }, + { + "type": "acknowledge_outbound", + "client": "alice", + "publication": "create", + "outcome": "accepted" + }, + { + "type": "deliver_all" + }, + { + "type": "tick", + "clients": [ + "bob" + ] + }, + { + "type": "clear_events", + "clients": [ + "alice", + "bob", + "carol", + "dave" + ] + }, + { + "type": "send_app_message", + "sender": "alice", + "payload": "growth:p0:alice" + }, + { + "type": "send_app_message", + "sender": "bob", + "payload": "growth:p0:bob" + }, + { + "type": "deliver_all" + }, + { + "type": "tick", + "clients": [ + "alice", + "bob" + ] + }, + { + "type": "invite_members", + "inviter": "alice", + "invitees": [ + "carol" + ], + "pending": "invite-carol" + }, + { + "type": "acknowledge_outbound", + "client": "alice", + "publication": "invite-carol", + "outcome": "accepted" + }, + { + "type": "deliver_all" + }, + { + "type": "tick", + "clients": [ + "bob", + "carol" + ] + }, + { + "type": "update_group_data", + "client": "alice", + "name": "growth-3", + "pending": "rename-3" + }, + { + "type": "acknowledge_outbound", + "client": "alice", + "publication": "rename-3", + "outcome": "accepted" + }, + { + "type": "deliver_all" + }, + { + "type": "tick", + "clients": [ + "bob", + "carol" + ] + }, + { + "type": "send_app_message", + "sender": "alice", + "payload": "growth:p1:alice" + }, + { + "type": "send_app_message", + "sender": "bob", + "payload": "growth:p1:bob" + }, + { + "type": "send_app_message", + "sender": "carol", + "payload": "growth:p1:carol" + }, + { + "type": "deliver_all" + }, + { + "type": "tick", + "clients": [ + "alice", + "bob", + "carol" + ] + }, + { + "type": "invite_members", + "inviter": "alice", + "invitees": [ + "dave" + ], + "pending": "invite-dave" + }, + { + "type": "acknowledge_outbound", + "client": "alice", + "publication": "invite-dave", + "outcome": "accepted" + }, + { + "type": "deliver_all" + }, + { + "type": "tick", + "clients": [ + "bob", + "carol", + "dave" + ] + }, + { + "type": "update_group_data", + "client": "alice", + "name": "growth-4", + "pending": "rename-4" + }, + { + "type": "acknowledge_outbound", + "client": "alice", + "publication": "rename-4", + "outcome": "accepted" + }, + { + "type": "deliver_all" + }, + { + "type": "tick", + "clients": [ + "bob", + "carol", + "dave" + ] + }, + { + "type": "send_app_message", + "sender": "alice", + "payload": "growth:p2:alice" + }, + { + "type": "send_app_message", + "sender": "bob", + "payload": "growth:p2:bob" + }, + { + "type": "send_app_message", + "sender": "carol", + "payload": "growth:p2:carol" + }, + { + "type": "send_app_message", + "sender": "dave", + "payload": "growth:p2:dave" + }, + { + "type": "deliver_all" + }, + { + "type": "tick", + "clients": [ + "alice", + "bob", + "carol", + "dave" + ] + }, + { + "type": "assert", + "assertion": { + "mode": "exactly", + "predicate": { + "type": "payload_count", + "client": "carol", + "payload": "growth:p0:alice", + "count": 0 + } + } + }, + { + "type": "assert", + "assertion": { + "mode": "exactly", + "predicate": { + "type": "payload_count", + "client": "dave", + "payload": "growth:p0:alice", + "count": 0 + } + } + }, + { + "type": "assert", + "assertion": { + "mode": "exactly", + "predicate": { + "type": "payload_count", + "client": "dave", + "payload": "growth:p1:alice", + "count": 0 + } + } + }, + { + "type": "observe_exact", + "clients": [ + "alice", + "bob", + "carol", + "dave" + ] + } + ] + }, + "expected_outcomes": [ + { + "type": "pending_resolution", + "step_index": 1, + "client": "alice", + "pending": "create", + "resolution": "confirmed" + }, + { + "type": "pending_resolution", + "step_index": 10, + "client": "alice", + "pending": "invite-carol", + "resolution": "confirmed" + }, + { + "type": "pending_resolution", + "step_index": 14, + "client": "alice", + "pending": "rename-3", + "resolution": "confirmed" + }, + { + "type": "pending_resolution", + "step_index": 23, + "client": "alice", + "pending": "invite-dave", + "resolution": "confirmed" + }, + { + "type": "pending_resolution", + "step_index": 27, + "client": "alice", + "pending": "rename-4", + "resolution": "confirmed" + }, + { + "type": "client_state", + "client": "alice", + "epoch": 5, + "member_count": 4, + "received_payloads": [ + "growth:p0:bob", + "growth:p1:bob", + "growth:p1:carol", + "growth:p2:bob", + "growth:p2:carol", + "growth:p2:dave" + ] + }, + { + "type": "client_state", + "client": "bob", + "epoch": 5, + "member_count": 4, + "received_payloads": [ + "growth:p0:alice", + "growth:p1:alice", + "growth:p1:carol", + "growth:p2:alice", + "growth:p2:carol", + "growth:p2:dave" + ] + }, + { + "type": "client_state", + "client": "carol", + "epoch": 5, + "member_count": 4, + "received_payloads": [ + "growth:p1:alice", + "growth:p1:bob", + "growth:p2:alice", + "growth:p2:bob", + "growth:p2:dave" + ] + }, + { + "type": "client_state", + "client": "dave", + "epoch": 5, + "member_count": 4, + "received_payloads": [ + "growth:p2:alice", + "growth:p2:bob", + "growth:p2:carol" + ] + }, + { + "type": "clients_converged", + "clients": [ + "alice", + "bob", + "carol", + "dave" + ], + "epoch": 5, + "member_count": 4 + }, + { + "type": "no_pending_work", + "clients": [ + "alice", + "bob" + ] + } + ] +} diff --git a/commons/src/jvmTest/resources/marmot/vectors/queue-faults.v1.json b/commons/src/jvmTest/resources/marmot/vectors/queue-faults.v1.json new file mode 100644 index 0000000000..5159db8940 --- /dev/null +++ b/commons/src/jvmTest/resources/marmot/vectors/queue-faults.v1.json @@ -0,0 +1,125 @@ +{ + "scenario_name": "queue-faults/v1", + "vector_version": "1", + "conformance_version": "0.9.19", + "seed": null, + "scenario": { + "name": "queue-faults/v1", + "spec_version": "2", + "clients": [ + "alice", + "bob", + "carol" + ], + "steps": [ + { + "type": "create_group", + "creator": "alice", + "name": "faults", + "invitees": [ + "bob", + "carol" + ], + "required_features": [], + "pending": "create" + }, + { + "type": "acknowledge_outbound", + "client": "alice", + "publication": "create", + "outcome": "accepted" + }, + { + "type": "deliver_all" + }, + { + "type": "tick", + "clients": [ + "bob", + "carol" + ] + }, + { + "type": "clear_events", + "clients": [ + "alice", + "bob", + "carol" + ] + }, + { + "type": "send_app_message", + "sender": "bob", + "payload": "bob:first" + }, + { + "type": "send_app_message", + "sender": "carol", + "payload": "carol:second" + }, + { + "type": "duplicate_message", + "selector": { "sender": "bob", "class": "application" } + }, + { + "type": "withhold_message", + "selector": { "sender": "bob", "class": "application", "occurrence": 1 }, + "label": "delayed-copy" + }, + { + "type": "reorder_messages", + "order": [ + { "sender": "carol", "class": "application" }, + { "sender": "bob", "class": "application" } + ] + }, + { + "type": "deliver_all" + }, + { + "type": "tick", + "clients": [ + "alice" + ] + }, + { + "type": "release_withheld", + "label": "delayed-copy" + }, + { + "type": "deliver_all" + }, + { + "type": "tick", + "clients": [ + "alice" + ] + }, + { + "type": "observe", + "clients": [ + "alice" + ] + } + ] + }, + "expected_outcomes": [ + { + "type": "pending_resolution", + "step_index": 1, + "client": "alice", + "pending": "create", + "resolution": "confirmed" + }, + { + "type": "client_state", + "client": "alice", + "epoch": 1, + "member_count": 3, + "received_payloads": [ + "carol:second", + "bob:first" + ] + } + ] +} diff --git a/commons/src/jvmTest/resources/marmot/vectors/readd-after-eviction.v1.json b/commons/src/jvmTest/resources/marmot/vectors/readd-after-eviction.v1.json new file mode 100644 index 0000000000..840d5104b4 --- /dev/null +++ b/commons/src/jvmTest/resources/marmot/vectors/readd-after-eviction.v1.json @@ -0,0 +1,37 @@ +{ + "scenario_name": "readd-after-eviction/v1", + "vector_version": "1", + "conformance_version": "0.9.19", + "seed": null, + "scenario": { + "name": "readd-after-eviction/v1", + "spec_version": "2", + "clients": ["alice", "bob", "carol"], + "steps": [ + {"type": "create_group", "creator": "alice", "name": "readd", "invitees": ["bob", "carol"], "required_features": [], "pending": "create"}, + {"type": "acknowledge_outbound", "client": "alice", "publication": "create", "outcome": "accepted"}, + {"type": "deliver_all"}, + {"type": "tick", "clients": ["bob", "carol"]}, + {"type": "clear_events", "clients": ["alice", "bob", "carol"]}, + {"type": "remove_members", "remover": "alice", "members": ["carol"], "pending": "remove-carol"}, + {"type": "acknowledge_outbound", "client": "alice", "publication": "remove-carol", "outcome": "accepted"}, + {"type": "deliver_all"}, + {"type": "tick", "clients": ["bob", "carol"]}, + {"type": "invite_members", "inviter": "alice", "invitees": ["carol"], "pending": "readd-carol"}, + {"type": "acknowledge_outbound", "client": "alice", "publication": "readd-carol", "outcome": "accepted"}, + {"type": "deliver_all"}, + {"type": "tick", "clients": ["bob", "carol"]}, + {"type": "send_app_message", "sender": "carol", "payload": "carol:back"}, + {"type": "deliver_all"}, + {"type": "tick", "clients": ["alice", "bob"]}, + {"type": "observe", "clients": ["alice", "bob", "carol"]} + ] + }, + "expected_outcomes": [ + {"type": "pending_resolution", "step_index": 6, "client": "alice", "pending": "remove-carol", "resolution": "confirmed"}, + {"type": "pending_resolution", "step_index": 10, "client": "alice", "pending": "readd-carol", "resolution": "confirmed"}, + {"type": "client_state", "client": "alice", "epoch": 3, "member_count": 3, "received_payloads": ["carol:back"]}, + {"type": "client_state", "client": "bob", "epoch": 3, "member_count": 3, "received_payloads": ["carol:back"]}, + {"type": "client_state", "client": "carol", "epoch": 3, "member_count": 3, "received_payloads": []} + ] +}