Files
amethyst/cli/tests/marmot/tests-media.sh
T
Claude 3a1947e733 test(marmot): let the disband test converge instead of snapshotting
Test 29 asserted that wn reaches amy's post-disband epoch within one fixed
window. That is stricter than the protocol promises, and it failed on a
full-suite run for a case `group-lifecycle-v1.md` explicitly allows.

MDK rotates its own leaf shortly after joining, so it can commit between the
epoch-agreement gate and amy's disband — and then the two have forked. What the
spec guarantees from there is not "the disband lands first time" but that the
REQUEST survives, is regenerated against whichever branch was selected, and
lands eventually. The old assertion could only pass in the race-free case, and
a busier machine widens the race.

The loop now re-reads AMY's epoch each round, because regeneration advances it,
and drives amy's own sync, which is what carries a pass to settlement and
re-issues a disband that lost. It also adds a check the old version lacked: once
the two agree, amy must still read the group as disbanded. "Epochs agree but the
group is live" is the outcome actually worth catching, and counting epochs alone
never would have.

Evidence this is a race and not a regression from the audit fixes: the two
commits in the failing window carry different `h` tags, so they are commits in
two different groups rather than a fork, and the test passes in isolation
(`epoch 1 -> 2, wn at 2`). A full run with this change is the confirmation and
is not in yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-11 03:11:13 +00:00

715 lines
32 KiB
Bash

# shellcheck shell=bash
#
# tests-media.sh — tests 20-25.
# Focus: the avatar URL component, message edits, deletions, and
# encrypted-media v2 — each in both directions where the reference CLI can
# drive it.
#
# These all need A and B in one group with A as admin, so they share a group
# built once by the first test that needs it.
# Create (or reuse) the group these tests run in: A creates it, so A is the
# admin who may commit component updates, and B joins.
#
# It is its own group rather than GROUP_02 because by this point in the run A
# has left GROUP_02 and been removed from others, and every check here needs
# both parties actually present.
media_group() {
local gid mls_gid
gid=$(load_state GROUP_MEDIA || true)
mls_gid=$(load_state GROUP_MEDIA_MLS || true)
if [[ -n "${gid:-}" && -n "${mls_gid:-}" ]]; then
printf '%s %s\n' "$gid" "$mls_gid"
return 0
fi
local out
out=$(amy_json marmot group create --name "Interop-Media") || return 1
gid=$(printf '%s' "$out" | jq -r '.group_id')
mls_gid=$(printf '%s' "$out" | jq -r '.mls_group_id')
amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || return 1
local b_gid
b_gid=$(wait_for_invite B 60) || return 1
wn_b groups accept "$b_gid" >/dev/null 2>&1 || true
save_state GROUP_MEDIA "$gid"
save_state GROUP_MEDIA_MLS "$mls_gid"
printf '%s %s\n' "$gid" "$mls_gid"
}
# Poll wn's view of the group until [jq filter] matches, or time out.
wn_group_field_becomes() {
local mls_gid="$1" filter="$2" want="$3" timeout="${4:-90}"
local deadline=$(( $(date +%s) + timeout )) got
while [[ $(date +%s) -lt $deadline ]]; do
# wn wraps every --json payload in {"ok":…,"result":…}; try inside the
# envelope first and fall back to a bare payload so a future shape change
# does not silently make this poll always fail.
got=$(wn_b_json groups show "$mls_gid" 2>/dev/null \
| jq -r "(.result | $filter) // ($filter) // empty" 2>/dev/null || true)
[[ "$got" == "$want" ]] && return 0
wn_b sync >/dev/null 2>&1 || true
sleep 3
done
printf 'wn_group_field_becomes: %s was %s, wanted %s\n' "$filter" "${got:-<none>}" "$want" >>"$LOG_FILE"
return 1
}
test_20_avatar_url_amy_to_wn() {
banner "Test 20 — amy commits a URL avatar; wn reads it back"
local id="20 avatar-url amy->wn"
local gid mls_gid
read -r gid mls_gid < <(media_group) || { record_result "$id" fail "could not build the media group"; return; }
if [[ -z "${gid:-}" ]]; then record_result "$id" fail "could not build the media group"; return; fi
# The stored bytes are the NORMALIZED URL, so this deliberately passes a URL
# that is not: the default port and the dot-segment both have to disappear,
# and both sides have to agree on exactly what is left. A decoder rejects
# state whose bytes differ from its own serialization, so a mismatch here is
# a group wn cannot read at all rather than a cosmetic difference.
local raw="https://Example.COM:443/a/./avatars/../pic.png"
local want="https://example.com/a/pic.png"
local out stored
out=$(amy_json marmot group set-avatar-url "$gid" "$raw" --dim "512x512") || {
record_result "$id" fail "amy set-avatar-url failed"; return
}
stored=$(printf '%s' "$out" | jq -r '.avatar_url // empty')
if [[ "$stored" != "$want" ]]; then
record_result "$id" fail "amy stored '$stored', expected the normalized '$want'"; return
fi
if wn_group_field_becomes "$mls_gid" '.group.avatar_url.url // empty' "$want" 120; then
record_result "$id" pass
else
record_result "$id" fail "wn never saw the URL avatar"
fi
}
test_21_avatar_url_wn_to_amy() {
banner "Test 21 — wn commits a URL avatar; amy reads it back"
local id="21 avatar-url wn->amy"
local gid mls_gid
read -r gid mls_gid < <(media_group) || { record_result "$id" fail "could not build the media group"; return; }
if [[ -z "${gid:-}" ]]; then record_result "$id" fail "could not build the media group"; return; fi
# B has to be an admin to commit a component update.
amy_json marmot group promote "$gid" "$B_NPUB" >/dev/null || {
record_result "$id" fail "amy could not promote B"; return
}
sleep 3
wn_b sync >/dev/null 2>&1 || true
local want="https://cdn.example.org/group.png"
if ! wn_b groups set-avatar-url "$mls_gid" --url "$want" >/dev/null 2>&1; then
record_result "$id" fail "wn set-avatar-url failed"; return
fi
local deadline=$(( $(date +%s) + 120 )) got
while [[ $(date +%s) -lt $deadline ]]; do
got=$(amy_json marmot group show "$gid" 2>/dev/null | jq -r '.avatar_url // empty')
[[ "$got" == "$want" ]] && break
sleep 3
done
if [[ "${got:-}" == "$want" ]]; then
record_result "$id" pass
else
record_result "$id" fail "amy saw '${got:-<none>}', expected '$want'"
fi
}
test_22_message_edit_amy_to_wn() {
banner "Test 22 — amy edits a message; wn receives the 1009 and amy overlays it"
local id="22 edit amy->wn"
local gid mls_gid
read -r gid mls_gid < <(media_group) || { record_result "$id" fail "could not build the media group"; return; }
if [[ -z "${gid:-}" ]]; then record_result "$id" fail "could not build the media group"; return; fi
local original="edit-target-frist-post"
local replacement="edit-target-first-post"
local send_json target
send_json=$(amy_json marmot message send "$gid" "$original") || {
record_result "$id" fail "amy send failed"; return
}
target=$(printf '%s' "$send_json" | jq -r '.inner_event_id')
if ! wait_for_message B "$mls_gid" "$original" 90; then
record_result "$id" fail "wn never received the original"; return
fi
if ! amy_json marmot message edit "$gid" "$target" "$replacement" >/dev/null; then
record_result "$id" fail "amy message edit failed"; return
fi
# What is checked on wn's side is that the EDIT EVENT interoperates: a
# kind:1009 carrying exactly one `e` tag naming the target, the replacement
# as its body, authored by A. Whether the reference CLI paints the overlay is
# its rendering choice — MDK's storage deliberately leaves the original row's
# body alone and lets the client compute the chain — so asserting on painted
# text would be testing its TUI, not the protocol.
local deadline=$(( $(date +%s) + 120 )) saw=0
while [[ $(date +%s) -lt $deadline ]]; do
local payload
payload=$(wn_b_json messages list "$mls_gid" --limit 50 2>/dev/null || true)
if [[ -n "$payload" ]] && \
printf '%s' "$payload" | jq_list messages \
| jq -e --arg t "$target" --arg r "$replacement" --arg a "$A_HEX" \
'select(.kind == 1009)
| select((.plaintext // .content // "") == $r)
| select((.pubkey // .author // $a) == $a)
| select([(.tags // [])[] | select(.[0] == "e") | .[1]] == [$t])' \
>/dev/null 2>&1; then
saw=1; break
fi
wn_b sync >/dev/null 2>&1 || true
sleep 3
done
if [[ "$saw" -ne 1 ]]; then
record_result "$id" fail "wn never received a well-formed kind:1009 for the target"; return
fi
# And our own reader must apply it: the target's body reads as the
# replacement and is flagged as edited, with no separate row for the edit.
local body edited
body=$(amy_json marmot message list "$gid" --limit 50 2>/dev/null \
| jq_list messages | jq -r --arg t "$target" 'select(.event_id == $t) | .content' | head -n 1)
edited=$(amy_json marmot message list "$gid" --limit 50 2>/dev/null \
| jq_list messages | jq -r --arg t "$target" 'select(.event_id == $t) | .edited' | head -n 1)
if [[ "$body" == "$replacement" && "$edited" == "true" ]]; then
record_result "$id" pass
else
record_result "$id" fail "amy shows '$body' (edited=$edited) for the edited message"
fi
}
test_23_deletion_amy_to_wn() {
banner "Test 23 — amy deletes a message; wn marks it deleted"
local id="23 deletion amy->wn"
local gid mls_gid
read -r gid mls_gid < <(media_group) || { record_result "$id" fail "could not build the media group"; return; }
if [[ -z "${gid:-}" ]]; then record_result "$id" fail "could not build the media group"; return; fi
local doomed="delete-me-from-amethyst"
local send_json target
send_json=$(amy_json marmot message send "$gid" "$doomed") || {
record_result "$id" fail "amy send failed"; return
}
target=$(printf '%s' "$send_json" | jq -r '.inner_event_id')
if ! wait_for_message B "$mls_gid" "$doomed" 90; then
record_result "$id" fail "wn never received the message to delete"; return
fi
if ! amy_json marmot message delete "$gid" "$target" >/dev/null; then
record_result "$id" fail "amy message delete failed"; return
fi
# wn's materialized timeline carries a `deleted` flag per row — that is the
# user-visible truth, and it is what a kind:5 from another implementation has
# to be able to set. The raw event log keeps both events either way.
local deadline=$(( $(date +%s) + 120 )) gone=0
while [[ $(date +%s) -lt $deadline ]]; do
local payload
payload=$(wn_b_json messages timeline list "$mls_gid" --limit 50 2>/dev/null || true)
if [[ -n "$payload" ]] && \
printf '%s' "$payload" | jq_list messages \
| jq -e --arg t "$target" \
'select((.message_id // .id // .event_id) == $t) | select(.deleted == true)' \
>/dev/null 2>&1; then
gone=1; break
fi
wn_b sync >/dev/null 2>&1 || true
sleep 3
done
if [[ "$gone" -eq 1 ]]; then
record_result "$id" pass
else
record_result "$id" fail "wn never marked the message deleted"
fi
}
# --- encrypted media v2 (0x800b) --------------------------------------------
# Both directions upload ciphertext to the harness's loopback Blossom store and
# fetch the other side's back. The store never holds a key: the file key comes
# from each group's own MLS exporter, so a successful download that hashes back
# to the original bytes is proof both implementations derived the same one.
media_policy_committed() {
local gid="$1"
if [[ -n "$(load_state MEDIA_POLICY_SET || true)" ]]; then return 0; fi
amy_json marmot media set-policy "$gid" "$BLOSSOM_URL/" >/dev/null || return 1
save_state MEDIA_POLICY_SET 1
sleep 3
wn_b sync >/dev/null 2>&1 || true
return 0
}
test_24_media_v2_amy_to_wn() {
banner "Test 24 — amy sends an encrypted attachment; wn decrypts it"
local id="24 media-v2 amy->wn"
if [[ -z "${BLOSSOM_PID:-}" ]]; then record_result "$id" skip "no blossom blob store"; return; fi
local gid mls_gid
read -r gid mls_gid < <(media_group) || { record_result "$id" fail "could not build the media group"; return; }
if [[ -z "${gid:-}" ]]; then record_result "$id" fail "could not build the media group"; return; fi
if ! media_policy_committed "$gid"; then
record_result "$id" fail "amy could not commit the media policy"; return
fi
local src="$STATE_DIR/media-from-amy.bin"
head -c 4096 /dev/urandom >"$src" 2>/dev/null || printf 'attachment-bytes-from-amethyst' >"$src"
local want_hash
want_hash=$(sha256sum "$src" | cut -d' ' -f1)
local send_json
send_json=$(amy_json marmot media send "$gid" "$src" --caption "from amethyst" --mime "application/octet-stream") || {
record_result "$id" fail "amy media send failed"; return
}
printf 'media24 send=%s\n' "$send_json" >>"$LOG_FILE"
# wn recovers the plaintext by hash. Its `media download` takes the PLAINTEXT
# hash, which is what it also uses to key its own reference index — so
# finding it there at all already proves the imeta tag parsed.
local out="$STATE_DIR/media-to-wn.bin"
local deadline=$(( $(date +%s) + 150 )) ok=1
while [[ $(date +%s) -lt $deadline ]]; do
if wn_b media download "$mls_gid" "$want_hash" --output "$out" >/dev/null 2>&1; then ok=0; break; fi
wn_b sync >/dev/null 2>&1 || true
sleep 5
done
if [[ "$ok" -ne 0 ]]; then
record_result "$id" fail "wn could not download the attachment"; return
fi
if [[ "$(sha256sum "$out" | cut -d' ' -f1)" == "$want_hash" ]]; then
record_result "$id" pass
else
record_result "$id" fail "wn decrypted different bytes than amy sent"
fi
}
test_25_media_v2_wn_to_amy() {
banner "Test 25 — wn sends an encrypted attachment; amy decrypts it"
local id="25 media-v2 wn->amy"
if [[ -z "${BLOSSOM_PID:-}" ]]; then record_result "$id" skip "no blossom blob store"; return; fi
local gid mls_gid
read -r gid mls_gid < <(media_group) || { record_result "$id" fail "could not build the media group"; return; }
if [[ -z "${gid:-}" ]]; then record_result "$id" fail "could not build the media group"; return; fi
if ! media_policy_committed "$gid"; then
record_result "$id" fail "amy could not commit the media policy"; return
fi
local src="$STATE_DIR/media-from-wn.bin"
head -c 4096 /dev/urandom >"$src" 2>/dev/null || printf 'attachment-bytes-from-whitenoise' >"$src"
local want_hash
want_hash=$(sha256sum "$src" | cut -d' ' -f1)
if ! wn_b media upload "$mls_gid" "$src" --send --message "from whitenoise" \
--server "$BLOSSOM_URL/" >/dev/null 2>&1; then
record_result "$id" fail "wn media upload failed"; return
fi
# Find the kind:9 wn just sent, by its caption, and pull the attachment out
# of its imeta tag.
local deadline=$(( $(date +%s) + 150 )) event_id=""
while [[ $(date +%s) -lt $deadline ]]; do
event_id=$(amy_json marmot message list "$gid" --limit 50 2>/dev/null \
| jq_list messages \
| jq -r 'select((.content // "") == "from whitenoise") | .event_id' | head -n 1)
[[ -n "$event_id" && "$event_id" != "null" ]] && break
sleep 5
done
if [[ -z "$event_id" || "$event_id" == "null" ]]; then
record_result "$id" fail "amy never received wn's media message"; return
fi
local out="$STATE_DIR/media-to-amy.bin"
if ! amy_json marmot media get "$gid" "$event_id" --out "$out" >/dev/null; then
record_result "$id" fail "amy media get failed"; return
fi
if [[ "$(sha256sum "$out" | cut -d' ' -f1)" == "$want_hash" ]]; then
record_result "$id" pass
else
record_result "$id" fail "amy decrypted different bytes than wn sent"
fi
}
# --- 26: disappearing messages ------------------------------------------------
# `marmot.group.message-retention.v1` (0x8005) has the nastiest encoding in the
# component set: eight big-endian bytes with NO length prefix, unlike almost
# every other Marmot field, and MIP-01 spelled it differently. Nothing else
# proves MDK accepts a GroupContext that REQUIRES it with our bytes — and if it
# does not, the failure is not cosmetic: wn cannot read the group at all.
#
# One-directional on purpose. `wn` has no command that sets retention
# (`wn groups` is list/create/show/add-members/remove-members/members/admins/
# relays/leave/rename/set-avatar-url), so the reverse direction is untestable
# here. The encode side is the one that can be wrong anyway. What amy DOES with
# the expiry once it holds one is unit-tested in `MarmotRetentionTest`; this is
# purely "does the other implementation accept and read what we wrote".
test_26_retention_amy_to_wn() {
banner "Test 26 — amy creates a group with disappearing messages; wn reads the policy"
local id="26 retention amy->wn"
local want=3600
local out gid mls_gid
out=$(amy_json marmot group create --name "Interop-Retention" --disappearing-secs "$want") || {
record_result "$id" fail "amy group create --disappearing-secs failed"; return
}
gid=$(printf '%s' "$out" | jq -r '.group_id')
mls_gid=$(printf '%s' "$out" | jq -r '.mls_group_id')
if [[ -z "$gid" || "$gid" == "null" ]]; then
record_result "$id" fail "amy reported no group id"; return
fi
amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || {
record_result "$id" fail "amy could not invite wn"; return
}
# The Welcome is the real assertion: a group requiring a component wn cannot
# decode is a group wn refuses to join.
local b_gid
b_gid=$(wait_for_invite B 60) || {
record_result "$id" fail "wn never received a Welcome for a group requiring 0x8005"; return
}
wn_b groups accept "$b_gid" >/dev/null 2>&1 || true
# wn's CLI `group_json` does not surface the retention value — it is on the
# uniffi group struct the apps consume, not this surface — so the assertion
# is acceptance rather than read-back. That is still the encoding test: the
# group REQUIRES 0x8005, and a required component whose bytes wn cannot
# decode makes the group unreadable, so `groups show` returning it at all
# means our eight big-endian bytes parsed.
if ! wn_group_field_becomes "$mls_gid" '.group.group_id // empty' "$mls_gid" 120; then
record_result "$id" fail "wn never surfaced a group that requires 0x8005"; return
fi
# And the group still works: a required component that decodes but breaks
# messaging would pass the check above and still be useless.
wn_b messages send "$mls_gid" "retention round trip" >/dev/null 2>&1 || {
record_result "$id" fail "wn could not send into the retention group"; return
}
if amy_json marmot await message "$gid" --match "retention round trip" --timeout 90 >/dev/null; then
record_result "$id" pass
else
record_result "$id" fail "amy never received wn's message in the retention group"
fi
}
# --- 27: a deletion the other way ---------------------------------------------
# Test 23 proves MDK applies OUR kind:5. This is the direction that was never
# covered, and it is the worse failure of the two: a message its sender believes
# is gone that stays on screen here.
#
# The authorization rule is the whole test. A kind:5 is authorized by ACCOUNT,
# so wn retracting its OWN message must land, and the forged cross-author case
# — which no CLI can send — is pinned in `MarmotEditsAndSystemRowsTest`.
test_27_deletion_wn_to_amy() {
banner "Test 27 — wn deletes its own message; amy marks it deleted"
local id="27 deletion wn->amy"
local gid mls_gid
read -r gid mls_gid < <(media_group) || { record_result "$id" fail "could not build the media group"; return; }
if [[ -z "${gid:-}" ]]; then record_result "$id" fail "could not build the media group"; return; fi
local doomed="delete-me-from-whitenoise"
if ! wn_b messages send "$mls_gid" "$doomed" >/dev/null 2>&1; then
record_result "$id" fail "wn send failed"; return
fi
if ! amy_json marmot await message "$gid" --match "$doomed" --timeout 90 >/dev/null; then
record_result "$id" fail "amy never received the message to delete"; return
fi
# Both sides key the message by the SAME inner event id, so amy's view of it
# is what we hand back to wn's deleter.
local target
target=$(amy_json marmot message list "$gid" --limit 50 2>/dev/null \
| jq_list messages \
| jq -r --arg c "$doomed" 'select((.content // "") == $c) | .event_id' | head -n 1)
if [[ -z "$target" || "$target" == "null" ]]; then
record_result "$id" fail "amy has no event id for wn's message"; return
fi
if ! wn_b messages delete "$mls_gid" "$target" >/dev/null 2>&1; then
record_result "$id" fail "wn messages delete failed"; return
fi
# The row stays, blanked and flagged: "retracted" and "never arrived" are
# different states to a reader, and only one of them is worth telling them
# about.
local deadline=$(( $(date +%s) + 120 )) gone=0 body="unset"
while [[ $(date +%s) -lt $deadline ]]; do
local row
row=$(amy_json marmot message list "$gid" --limit 50 2>/dev/null \
| jq_list messages | jq -c --arg t "$target" 'select(.event_id == $t)' | head -n 1)
if [[ -n "$row" ]] && printf '%s' "$row" | jq -e 'select(.deleted == true)' >/dev/null 2>&1; then
gone=1
body=$(printf '%s' "$row" | jq -r '.content')
break
fi
sleep 3
done
if [[ "$gone" -ne 1 ]]; then
record_result "$id" fail "amy never marked wn's message deleted"; return
fi
if [[ -n "$body" ]]; then
record_result "$id" fail "amy flagged the message deleted but still shows '$body'"; return
fi
record_result "$id" pass
}
# --- 28: retention, applied to what wn sends ----------------------------------
# Test 26 proves MDK ACCEPTS a group that requires `0x8005` with our bytes. This
# is the read side, and it is the half the epoch-pinning rule lives in: each
# message keeps the retention of the epoch that DELIVERED it, so a later change
# must not shorten, extend or restore an expiry that already exists.
#
# Driving it from wn is the point. Our own sends pin at persist time from state
# this client just wrote; an inbound message arrives under an epoch the group
# may already have moved past, which is exactly where the fallback used to be
# wrong. (`wn` itself has no retention setter — `wn groups` is list/create/show/
# add-members/remove-members/members/admins/relays/leave/rename/set-avatar-url —
# so amy owns the setting and wn owns the sending.)
test_28_retention_wn_to_amy() {
banner "Test 28 — amy re-times a group; wn's messages pin the epoch that delivered them"
local id="28 retention wn->amy"
local short=60 long=86400
local out gid mls_gid
out=$(amy_json marmot group create --name "Interop-Retention-Inbound" --disappearing-secs "$short") || {
record_result "$id" fail "amy group create --disappearing-secs failed"; return
}
gid=$(printf '%s' "$out" | jq -r '.group_id')
mls_gid=$(printf '%s' "$out" | jq -r '.mls_group_id')
if [[ -z "$gid" || "$gid" == "null" ]]; then
record_result "$id" fail "amy reported no group id"; return
fi
amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || {
record_result "$id" fail "amy could not invite wn"; return
}
local b_gid
b_gid=$(wait_for_invite B 60) || {
record_result "$id" fail "wn never received the Welcome"; return
}
wn_b groups accept "$b_gid" >/dev/null 2>&1 || true
local early="retention-inbound-early"
wn_b messages send "$mls_gid" "$early" >/dev/null 2>&1 || {
record_result "$id" fail "wn could not send under the first policy"; return
}
if ! amy_json marmot await message "$gid" --match "$early" --timeout 90 >/dev/null; then
record_result "$id" fail "amy never received wn's first message"; return
fi
# Now move the policy. The commit opens a new epoch, and only messages
# delivered by that epoch take the new duration.
if ! amy_json marmot group set-retention "$gid" "$long" >/dev/null; then
record_result "$id" fail "amy set-retention failed"; return
fi
sleep 3
wn_b sync >/dev/null 2>&1 || true
local late="retention-inbound-late"
local sent=0 attempt
for attempt in 1 2 3 4 5; do
if wn_b messages send "$mls_gid" "$late" >/dev/null 2>&1; then sent=1; break; fi
wn_b sync >/dev/null 2>&1 || true
sleep 5
done
if [[ "$sent" -ne 1 ]]; then
record_result "$id" fail "wn could not send after the retention change"; return
fi
if ! amy_json marmot await message "$gid" --match "$late" --timeout 120 >/dev/null; then
record_result "$id" fail "amy never received wn's second message"; return
fi
# `expires_at` is the value amy PINNED, not a recomputation: created_at plus
# the duration that applied at the delivering epoch.
local rows early_created early_expiry late_created late_expiry
rows=$(amy_json marmot message list "$gid" --limit 50 2>/dev/null | jq_list messages)
early_created=$(printf '%s' "$rows" | jq -r --arg c "$early" 'select((.content // "") == $c) | .created_at' | head -n 1)
early_expiry=$(printf '%s' "$rows" | jq -r --arg c "$early" 'select((.content // "") == $c) | .expires_at' | head -n 1)
late_created=$(printf '%s' "$rows" | jq -r --arg c "$late" 'select((.content // "") == $c) | .created_at' | head -n 1)
late_expiry=$(printf '%s' "$rows" | jq -r --arg c "$late" 'select((.content // "") == $c) | .expires_at' | head -n 1)
printf 'retention28 early=%s/%s late=%s/%s\n' \
"${early_created:-?}" "${early_expiry:-?}" "${late_created:-?}" "${late_expiry:-?}" >>"$LOG_FILE"
if [[ -z "$early_expiry" || "$early_expiry" == "null" || -z "$late_expiry" || "$late_expiry" == "null" ]]; then
record_result "$id" fail "amy pinned no expiry for one of wn's messages"; return
fi
if [[ "$early_expiry" -ne $(( early_created + short )) ]]; then
record_result "$id" fail "the first message expires at $early_expiry, wanted $(( early_created + short ))"; return
fi
if [[ "$late_expiry" -ne $(( late_created + long )) ]]; then
record_result "$id" fail "the second message expires at $late_expiry, wanted $(( late_created + long ))"; return
fi
record_result "$id" pass
}
# --- 29: disband ---------------------------------------------------------------
# The terminal state, and the one with no way back: there is no un-disband
# commit, no later branch supersedes it, and a replacement conversation is a new
# MLS group with a new id. Two implementations disagreeing about whether a group
# ended is unrecoverable by construction, which is why it is worth a test even
# though it can only run one way.
#
# `group-lifecycle-v1.md` fixes the whole Commit shape — the lifecycle update,
# an admin-policy replacement naming only the committer, and a Remove for every
# other leaf — and MDK validates all of it before applying anything. A Commit
# carrying only the lifecycle update, which is what we used to send, is rejected
# as an unsupported lifecycle transition, so wn ACCEPTING this one is the
# assertion.
#
# What that acceptance looks like through `wn` is narrower than you would hope,
# and the narrowing is not our side being coy:
#
# - the group does not disappear. The spec has a disbanded client keep a
# read-only authenticated tombstone, and MDK keeps listing it.
# - the app-level projection freezes. `groups members` and `groups admins`
# keep reporting the last state in which wn held a leaf, because this Commit
# removes that leaf — so neither moves, however the Commit is handled.
# - `wn` does not print the state that would say it outright. MDK's
# `AppGroupMlsState` carries `lifecycle_state` and `disbanding_enabled`, and
# its uniffi surface hands both to the apps, but the CLI's
# `group_mls_state_json` emits only group_id/epoch/member_count/
# required_app_components.
#
# That leaves the MLS epoch, and it is enough: the disband is the only Commit
# published in the window, both sides are made to agree on the epoch before it,
# and a wn that refused it stays where it was.
#
# One direction only: MDK exposes disband on its uniffi surface (the apps call
# `disband_group`) but `wn groups` has no verb for it, so wn cannot originate
# one here.
test_29_disband_amy_to_wn() {
banner "Test 29 — amy disbands a group; wn accepts the terminal commit"
local id="29 disband amy->wn"
# Its own group, and the LAST thing that happens to it: disband is absorbing,
# so nothing else can be tested in a group afterwards.
local out gid mls_gid
out=$(amy_json marmot group create --name "Interop-Disband") || {
record_result "$id" fail "amy group create failed"; return
}
gid=$(printf '%s' "$out" | jq -r '.group_id')
mls_gid=$(printf '%s' "$out" | jq -r '.mls_group_id')
if [[ -z "$gid" || "$gid" == "null" ]]; then
record_result "$id" fail "amy reported no group id"; return
fi
amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || {
record_result "$id" fail "amy could not invite wn"; return
}
local b_gid
b_gid=$(wait_for_invite B 60) || {
record_result "$id" fail "wn never received the Welcome"; return
}
wn_b groups accept "$b_gid" >/dev/null 2>&1 || true
# A message first, so wn is demonstrably live in the group before the end —
# otherwise a quiet wn afterwards could just mean it never joined.
local alive="before-the-end"
amy_json marmot message send "$gid" "$alive" >/dev/null || {
record_result "$id" fail "amy could not send into the group"; return
}
if ! wait_for_message B "$mls_gid" "$alive" 90; then
record_result "$id" fail "wn never joined the group properly"; return
fi
# Agree on the epoch before committing anything terminal. This is both the
# baseline the assertion below reads against and what a real client does — it
# reads the group before acting on it — and it matters more here than
# anywhere else: an ordinary commit off a stale epoch is survivable because
# convergence settles it, and this one is not, since a disbanded client stops
# processing group traffic by design and can never learn it lost a branch.
local wn_epoch amy_epoch before_epoch=""
local settle=$(( $(date +%s) + 120 ))
while [[ $(date +%s) -lt $settle ]]; do
wn_epoch=$(wn_b_json groups show "$mls_gid" 2>/dev/null | jq -r '.result.mls.epoch // empty')
amy_epoch=$(amy_json marmot group show "$gid" 2>/dev/null | jq -r '.epoch // empty')
if [[ -n "$wn_epoch" && "$wn_epoch" == "$amy_epoch" ]]; then before_epoch="$amy_epoch"; break; fi
wn_b sync >/dev/null 2>&1 || true
sleep 5
done
if [[ -z "$before_epoch" ]]; then
record_result "$id" fail "amy (epoch ${amy_epoch:-?}) and wn (epoch ${wn_epoch:-?}) never agreed before the disband"
return
fi
# Retry a disband the relay never acknowledged. That is not a protocol
# failure — the commit stays a queued publish obligation and the group stays
# live, exactly as `disbandGroup` reports — and a real client tries again. The
# loopback relay drops a connection often enough under a full-suite run to be
# worth spelling out rather than reading as a conformance failure.
local disbanded=0 attempt
for attempt in 1 2 3; do
if amy_json marmot group disband "$gid" --yes >/dev/null; then disbanded=1; break; fi
sleep 10
done
if [[ "$disbanded" -ne 1 ]]; then
record_result "$id" fail "amy group disband failed"; return
fi
# Terminal here, and the tree is down to the committing leaf — the Remove
# half of the shape, which the reference client cannot show us but our own
# state can.
local after after_epoch members
after=$(amy_json marmot group show "$gid" 2>/dev/null || true)
after_epoch=$(printf '%s' "$after" | jq -r '.epoch // empty')
members=$(printf '%s' "$after" | jq -r '.members | length')
if [[ "$(printf '%s' "$after" | jq -r '.disbanded // false')" != "true" ]]; then
record_result "$id" fail "amy does not read its own group as disbanded"; return
fi
if [[ "$members" != "1" ]]; then
record_result "$id" fail "amy kept $members leaves after the disband; the shape requires only the committer"; return
fi
if [[ -z "$after_epoch" || "$after_epoch" == "$before_epoch" ]]; then
record_result "$id" fail "amy's epoch did not advance past $before_epoch"; return
fi
# Converge, don't snapshot. MDK rotates its own leaf shortly after joining,
# so it can commit between the epoch gate above and the disband below — and
# then the two have forked. The protocol's answer is not "the disband lands
# first time": it is that the REQUEST survives, is regenerated against
# whichever branch was selected, and lands eventually. Asserting the
# race-free happy path made this test fail on a busy machine for a case the
# spec explicitly allows, so the loop re-reads amy's epoch each round —
# regeneration moves it — and drives amy's own sync, which is what carries a
# pass to settlement and re-issues a disband that lost.
local deadline=$(( $(date +%s) + 240 )) accepted=0 saw="" amy_now="$after_epoch"
while [[ $(date +%s) -lt $deadline ]]; do
amy_now=$(amy_json marmot group show "$gid" 2>/dev/null | jq -r '.epoch // empty')
saw=$(wn_b_json groups show "$mls_gid" 2>/dev/null | jq -r '.result.mls.epoch // empty')
if [[ -n "$saw" && -n "$amy_now" && "$saw" == "$amy_now" ]]; then accepted=1; break; fi
wn_b sync >/dev/null 2>&1 || true
sleep 5
done
printf 'disband29 epoch %s -> %s (amy now %s), wn at %s\n' \
"$before_epoch" "$after_epoch" "${amy_now:-?}" "${saw:-<none>}" >>"$LOG_FILE"
if [[ "$accepted" -ne 1 ]]; then
record_result "$id" fail "wn stayed at epoch ${saw:-<none>} while amy is at ${amy_now:-?} — the disband never converged"
return
fi
# Converged — and amy must still read the group as ended. A disband that
# lost its branch and was never regenerated would agree on an epoch here
# while leaving the group live, which is the failure worth catching.
if [[ "$(amy_json marmot group show "$gid" 2>/dev/null | jq -r '.disbanded // false')" != "true" ]]; then
record_result "$id" fail "amy and wn agree on epoch ${saw} but the group is not disbanded"
return
fi
record_result "$id" pass
}