Files
amethyst/cli/tests/marmot/tests-media.sh
T
Vitor PamplonaandClaude Opus 5.5 9464879a80 test(marmot): harness checks that could pass without testing anything
- Removal checks read "not in the member list" off a pipeline where a failed wn query
  (dead daemon, ok:false) also produced no match. Tests 06 and 34 now require wn's own
  positive signal (self_membership "removed"); the leave and avatar checks use wn_lists,
  which tells "answered, not listed" from "no answer".
- setup.sh fell through to the old amy binary when all four builds failed; it now fails.
- Test 27's "tombstone published" check compared a count to false, so it never fired;
  it requires published > 0.
- The short wnd socket dir was random per run: dirs piled up in /tmp and start_daemon
  could not find a wnd a killed run left behind. It is now derived from the path (0700,
  reused only when ours).

Headless run: 32 passed, 0 failed, 2 skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:48:02 -04:00

728 lines
33 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
# Keep wn's answer: "the command ran" and "the tombstone reached a relay" are
# different, and only the second one can reach amy.
local del
del=$(wn_b --json messages delete "$mls_gid" "$target" 2>>"$LOG_FILE") || {
record_result "$id" fail "wn messages delete failed"; return
}
printf 'wn messages delete %s -> %s\n' "$target" "$del" >>"$LOG_FILE"
if ! printf '%s' "$del" | jq -e '(.result.published // 0) > 0' >/dev/null 2>&1; then
record_result "$id" fail "wn did not publish the delete tombstone: $del"; 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
# Tell a lost tombstone from a split group: if the two sides sit on different
# epochs, the delete was sent where amy cannot follow.
local wn_epoch amy_epoch
wn_epoch=$(wn_b_json groups show "$mls_gid" 2>/dev/null | jq -r '.result.mls.epoch // "?"')
amy_epoch=$(amy_json marmot group show "$gid" 2>/dev/null | jq -r '.epoch // "?"')
record_result "$id" fail "amy never marked wn's message deleted (wn epoch $wn_epoch, amy epoch $amy_epoch)"; 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
wn_b_json groups show "$mls_gid" >"$STATE_DIR/disband29-wn-view.json" 2>&1 || true
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
}