Files
amethyst/cli/tests/marmot/tests-mdk014.sh
Claude 3dc91ee695 fix: audit findings in group emoji reactions, edits, polls, Tor routing and amy
Marmot groups
- A custom-emoji reaction is the same reaction by shortcode: every one
  carries its own encrypted copy under its own URL, so chips merged
  nothing, a second tap reacted again instead of retracting, and the sheet
  never showed the emoji as yours. Chips group by shortcode, and the
  toggle, the duplicate guard and the highlight match by it.
- The picture of a reaction comes from the account's own packs. Tapping a
  peer's chip used to download the URL in their reaction: their
  ciphertext, or any URL a peer wrote. When the picture cannot be
  attached the reaction goes out as a bare :shortcode:, with no emoji tag
  pointing members at a public URL.
- Messages with custom emoji can be edited, and an edit carries its emoji
  the way a message does.
- A signed kind 1018 does not vote in a group poll: members vote inside
  the group, unsigned, and the poll id alone was enough to vote from a
  public relay.
- Emoji over the tag limit are dropped before any download or upload.

Android Tor
- "Continue without Tor" drops the SOCKS port and the relay client falls
  back to a direct one, but the dial gate still waited for Tor, so DM and
  untrusted relays never connected. The bypass now lets them dial.
- The waiting-for-Tor hint treats Tor off (a choice, or that bypass) as
  not holding relays back.

UI
- The poll sheet pads the keyboard and nav bar outside its scroll, with
  imePaddingSafe; suggestions follow the focused field; Tab moves to the
  next field instead of typing a tab MDK refuses; an option emptied
  mid-list can be removed; the ready check is derived state.
- Change vote can be cancelled; a quoted poll is a preview, not a ballot.
- Emoji tags of an edit replacing an edit no longer keep the old ones.
- A failed group-avatar plan still discards the compressed file.
- Every chat row skipped the Marmot reaction producer and the imeta
  parse when it has neither.

amy
- Epoch admins, media keys and verdicts ignore a record a crash cut short
  and replace it on the next write, instead of reading it as whole.
- Canonical error codes (forbidden, not_found).
- Harness: test 36 asserts the deletion positively; test 17 runs on
  bash 3.2 without a Blossom server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AnG7pkzazc6jfhNkCkcX11
2026-10-24 16:25:47 +00:00

191 lines
7.9 KiB
Bash

# shellcheck shell=bash
#
# tests-mdk014.sh — tests 35-37.
# Focus: what the MDK 0.11-0.14 cycle and the matching spec changes made
# interop-visible: an attachment keeps the media key of the epoch that carried
# it, and an admin deletes another member's chat message with a kind 4891 that
# the other side honours.
#
# All of them reuse the media group from tests-media.sh (A admin, B member).
test_35_media_from_an_earlier_epoch_wn_to_amy() {
banner "Test 35 — amy opens a wn attachment sent before later commits"
local id="35 media earlier epoch 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-earlier-epoch.bin"
head -c 4096 /dev/urandom >"$src" 2>/dev/null || printf 'attachment-bytes-earlier-epoch' >"$src"
local want_hash
want_hash=$(sha256sum "$src" | cut -d' ' -f1)
if ! wn_b media upload "$mls_gid" "$src" --send --message "35 earlier epoch" \
--server "$BLOSSOM_URL/" >/dev/null 2>&1; then
record_result "$id" fail "wn media upload failed"; return
fi
if ! amy_json marmot await message "$gid" --match "35 earlier epoch" --timeout 150 >/dev/null; then
record_result "$id" fail "amy never received wn's media message"; return
fi
local event_id
event_id=$(amy_json marmot message list "$gid" --limit 50 2>/dev/null \
| jq_list messages \
| jq -r 'select((.content // "") == "35 earlier epoch") | .event_id' | head -n 1)
# Move the group on: each rename is a commit, and the attachment's key is
# the one of the epoch BEFORE them. Opening it under the current epoch's key
# was the bug.
local i
for i in 1 2; do
amy_json marmot group rename "$gid" "Interop-Media-$i" >/dev/null || {
record_result "$id" fail "amy rename $i failed"; return
}
done
local out="$STATE_DIR/media-earlier-epoch.out"
if ! amy_json marmot media get "$gid" "$event_id" --out "$out" >/dev/null; then
record_result "$id" fail "amy could not open an attachment from an earlier epoch"; 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
}
test_36_admin_deletion_amy_to_wn() {
banner "Test 36 — amy, as admin, deletes wn's message (kind 4891); wn hides it"
local id="36 admin 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="36-moderate-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 wn's message"; return
fi
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" ]]; then
record_result "$id" fail "amy has no event id for wn's message"; return
fi
# wn must list the row before the delete, or its absence afterwards proves nothing.
local row_of='select((.message_id // .id // .event_id) == $t)'
local deadline=$(( $(date +%s) + 90 )) seen=0
while [[ $(date +%s) -lt $deadline ]]; do
if wn_b_json messages timeline list "$mls_gid" --limit 50 2>/dev/null | jq_list messages \
| jq -e --arg t "$target" "$row_of" >/dev/null 2>&1; then
seen=1; break
fi
wn_b sync >/dev/null 2>&1 || true
sleep 3
done
if [[ "$seen" -ne 1 ]]; then
record_result "$id" fail "wn's timeline never listed the message to delete"; return
fi
local del
del=$(amy_json marmot message delete "$gid" "$target") || {
record_result "$id" fail "amy admin delete failed"; return
}
if [[ "$(printf '%s' "$del" | jq -r '.kind')" != "4891" ]]; then
record_result "$id" fail "amy did not send a kind 4891: $del"; return
fi
# Applied means flagged deleted, or dropped from a timeline that still lists messages. An empty
# or failed listing is neither: keep polling.
deadline=$(( $(date +%s) + 120 ))
local gone=0
while [[ $(date +%s) -lt $deadline ]]; do
local rows
rows=$(wn_b_json messages timeline list "$mls_gid" --limit 50 2>/dev/null | jq_list messages)
if [[ -n "$rows" ]]; then
if printf '%s' "$rows" | jq -e --arg t "$target" "$row_of | select(.deleted == true)" >/dev/null 2>&1 \
|| ! printf '%s' "$rows" | jq -e --arg t "$target" "$row_of" >/dev/null 2>&1; then
gone=1; break
fi
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 still shows the message amy's admin deletion removed"
fi
}
test_37_admin_deletion_wn_to_amy() {
banner "Test 37 — wn, as admin, deletes amy's message; amy hides it"
local id="37 admin 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
amy_json marmot group promote "$gid" "$B_NPUB" >/dev/null 2>&1 || {
record_result "$id" fail "amy could not promote wn"; return
}
local deadline=$(( $(date +%s) + 90 )) admin=0
while [[ $(date +%s) -lt $deadline ]]; do
if [[ "$(wn_b --json groups admins "$mls_gid" 2>/dev/null | jq_list admins | jq_member_ids | tr '\n' ' ')" == *"$B_HEX"* ]]; then
admin=1; break
fi
wn_b sync >/dev/null 2>&1 || true
sleep 3
done
if [[ "$admin" -ne 1 ]]; then
printf 'test 37: wn admins after promote: %s\n' "$(wn_b_json groups admins "$mls_gid" 2>&1 | head -c 4000)" >>"$LOG_FILE"
printf 'test 37: amy admins after promote: %s\n' "$(amy_json marmot group admins "$gid" 2>&1 | head -c 2000)" >>"$LOG_FILE"
record_result "$id" fail "wn never saw itself promoted"; return
fi
local doomed="37-moderate-me-from-amethyst" 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
local del
del=$(wn_b --json messages delete "$mls_gid" "$target" 2>>"$LOG_FILE") || {
# MDK removes another account's content only with an admin moderation
# grant; if it refuses here, the gap is on the wn side of this test.
record_result "$id" skip "wn refused to delete another account's message: $(tail -n 1 "$LOG_FILE")"; return
}
printf 'wn admin delete %s -> %s\n' "$target" "$del" >>"$LOG_FILE"
deadline=$(( $(date +%s) + 120 ))
local gone=0
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; break
fi
sleep 3
done
if [[ "$gone" -eq 1 ]]; then
record_result "$id" pass
else
record_result "$id" fail "amy still shows the message wn's admin deletion removed"
fi
}