Files
amethyst/cli/tests/marmot/tests-media.sh
T
Claude 37cd798e99 feat(marmot): pin retention to the delivering epoch, and set it from amy
Three related pieces.

**The pinning was approximate.** Expiry was pinned from the group's CURRENT
retention at persist time, which is right for a message delivered under the
current epoch and wrong for one delivered under an older one — a kind:445 held
back as a retained candidate, or replayed after a restart, is decrypted under
an epoch the group has since moved past. Pinning that to today's setting is
precisely what the component forbids. The store now keeps a small epoch →
retention history, written wherever the epoch may have advanced, and the pin
reads the delivering epoch's value. The fallback to the current value is not a
shrug: a group whose setting never changed has one value at every epoch, which
is the overwhelmingly common case. MDK carries `source_epoch` on its rows for
the same reason.

**`amy` can set it.** `marmot group create --disappearing-secs N`, on both
profiles: component `0x8005` for a current-profile group, and the legacy blob
for `--legacy`, where asking for it bumps the version to 3 because v1/v2
deliberately omit the field to stay byte-compatible with MDK's older parser.

**Interop test 26.** Nothing proved MDK accepts a GroupContext that REQUIRES
`0x8005` with our bytes, and retention has the nastiest encoding in the set:
eight big-endian bytes with no length prefix, unlike almost every other Marmot
field, and MIP-01 spelled it differently. amy creates such a group, wn joins
it, and messaging round-trips.

It asserts acceptance rather than read-back, because wn's CLI `group_json`
does not surface the retention value — that field is on the uniffi struct the
apps consume, not this surface. Acceptance is still the encoding test: a
required component whose bytes wn cannot decode leaves the group unreadable,
so `groups show` returning it at all means the eight bytes parsed. The
direction is one-way for the same reason as push — `wn groups` has no command
that sets retention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-10 02:16:25 +00:00

407 lines
17 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
}