mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat(marmot): disband a group, edit its avatar link, and replay seven more vectors
Disband (`marmot.group.lifecycle.v1`, 0x800c) -------------------------------------------- We enforced the Disbanded state but nothing on this side could enter it, so the enforcement was only reachable from a peer's commit. `disbandGroup` writes the component in a Commit every member replays. It refuses what is structurally impossible — a non-admin, a legacy MIP-01 group with no carrier for a lifecycle state, a second disband — and, unlike every other metadata setter, refuses to report success when no relay acknowledged the commit: 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 announced as ended. The obligation stays queued either way. On Android it is an admin-only action in the group header behind its own confirmation, worded for what it is: for everyone, and not reopenable. Avatar link (`marmot.group.avatar-url.v1`, 0x8007) -------------------------------------------------- `setGroupAvatarUrl` existed but only `amy` could reach it — the renderer already preferred a URL avatar over the Blossom image, and no Android screen could set one. Edit Group Info now carries the field; it commits separately from the profile so saving a rename does not rewrite the avatar state, and clearing it falls the group back to the uploaded image. Seven more scenario vectors --------------------------- New steps: `update_group_data`, `remove_members`, and the delivery-fault family — `omit_message`, `duplicate_message`, `reorder_messages`, `withhold_message`, `release_withheld`. Selectors are matched key by key and an unknown key is refused, because a silently widened selector injects a different fault from the scripted one while still reporting under the vector's name. `group_profile` outcomes are checked too. Replaying now: group-data-update, deferred-tick-catchup, incremental-growth, drop-queued, queue-faults, delayed-past-epoch-app-message, readd-after-eviction. 18 vector tests, all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
This commit is contained in:
@@ -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<NormalizedRelayUrl>,
|
||||
) {
|
||||
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<NormalizedRelayUrl>,
|
||||
) {
|
||||
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.
|
||||
|
||||
+18
@@ -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()
|
||||
}
|
||||
|
||||
+35
-1
@@ -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<SelectedMedia?>(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(
|
||||
|
||||
+89
-1
@@ -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<GroupMemberInfo?>(null) }
|
||||
var memberToPromote by remember { mutableStateOf<GroupMemberInfo?>(null) }
|
||||
var memberToDemote by remember { mutableStateOf<GroupMemberInfo?>(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,
|
||||
|
||||
@@ -2696,6 +2696,8 @@
|
||||
<string name="marmot_failed_to_update">Failed to update: %1$s</string>
|
||||
<string name="marmot_failed_to_create_group">Failed to create group: %1$s</string>
|
||||
<string name="marmot_failed_to_leave_group">Failed to leave group: %1$s</string>
|
||||
<string name="marmot_group_disbanded_toast">Group disbanded</string>
|
||||
<string name="marmot_failed_to_disband">Could not disband the group: %1$s</string>
|
||||
<string name="marmot_adding_user">Adding %1$s…</string>
|
||||
<string name="marmot_failed_to_add_user">Failed to add %1$s: %2$s</string>
|
||||
<string name="marmot_unknown_error">unknown error</string>
|
||||
|
||||
@@ -2638,6 +2638,12 @@
|
||||
<string name="marmot_retention_1w">1 week</string>
|
||||
<string name="marmot_retention_active">Messages disappear after %1$s</string>
|
||||
<string name="marmot_retention_footer">Messages are deleted from every member\'s device after this long. It cannot be changed later.</string>
|
||||
<string name="marmot_disband_group">Disband group</string>
|
||||
<string name="marmot_disband_group_confirm">Disband "%1$s" for everyone? The conversation ends for every member and cannot be reopened — a new group would have to be created.</string>
|
||||
<string name="marmot_disband_group_action">Disband</string>
|
||||
<string name="marmot_avatar_url">Avatar link</string>
|
||||
<string name="marmot_avatar_url_placeholder">https://example.com/avatar.png</string>
|
||||
<string name="marmot_avatar_url_footer">An https link every member can load. Leave it empty to use the uploaded image instead.</string>
|
||||
<string name="marmot_group_default_name">Marmot Group</string>
|
||||
<string name="marmot_group_fallback_name">Group %1$s…</string>
|
||||
<string name="marmot_user_fallback_name">%1$s…</string>
|
||||
|
||||
+64
@@ -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<NormalizedRelayUrl> = 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.
|
||||
*
|
||||
|
||||
+158
@@ -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<IllegalStateException> {
|
||||
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<IllegalStateException> { 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<IllegalStateException> { 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<IllegalStateException> { 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<IllegalStateException> { 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
|
||||
}
|
||||
}
|
||||
+183
-10
@@ -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<Pair<String, Event>>()
|
||||
/** Queued outbound events, drained by `deliver_all`. */
|
||||
private val inFlight = mutableListOf<Queued>()
|
||||
|
||||
/** Messages pulled out of the queue by `withhold_message`, by label. */
|
||||
private val withheld = mutableMapOf<String, MutableList<Queued>>()
|
||||
|
||||
/**
|
||||
* 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<Int> {
|
||||
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<Int>()
|
||||
val reordered = mutableListOf<Queued>()
|
||||
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<HexKey> =
|
||||
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"
|
||||
}
|
||||
|
||||
+38
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -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<Step> =
|
||||
(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<String> = 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<String>? = 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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": []}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user