mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
A Marmot message is an unsigned rumor, so the app's generic reaction and deletion paths treated it as a NIP-17 private note: a reaction was gift-wrapped to the author as a DM, and a deletion went the same way (or to nobody, for our own message). Neither reached the MLS group. White Noise never showed an Amethyst reaction or deletion, and group activity left the group's channel as DMs. Only the CLI used the in-group builders, which is why the interop harness passed. Account.reactTo, delete and deletePrivately now check whether the target is a Marmot message (MarmotGroupList.groupIdForNote) and send an inner kind:7 or kind:5 through sendMarmotGroupMessage, like any other group message. Unreact already goes through deletePrivately with the reacted-to message as its target, so it is covered. Custom-emoji reactions carry their emoji tag, as on the public path. Verified on device (Amethyst tablet <-> White Noise Android, MDK 0.10.4): react, unreact and delete from Amethyst all show in White Noise. No gift wraps were sent for them. The White Noise app only shows an incoming reaction after its chat is reopened; wn's materialized timeline has it immediately, so that is White Noise's live refresh. Harness test 31 checks that an amy reaction appears in wn's MATERIALIZED timeline (test 09 only checks the raw event log, which the app does not render). Builder unit tests added. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
289 lines
12 KiB
Bash
289 lines
12 KiB
Bash
# shellcheck shell=bash
|
|
#
|
|
# tests-manage.sh — tests 06, 07, 08, 11.
|
|
# Focus: removal, metadata rename, admin promote/demote, leave.
|
|
|
|
test_06_member_removal() {
|
|
banner "Test 06 — Member removal + forward secrecy"
|
|
local id="06 member removal"
|
|
|
|
# MIP-03 only admins may commit Remove proposals. In GROUP_05 (wn-created
|
|
# by B, A joined later) A is not an admin, so the test used to fail with
|
|
# `IllegalStateException: non-admin members may only commit...`. Test on
|
|
# GROUP_02 instead, where amy is the creator and therefore sole admin,
|
|
# and where test 04 has already added C. amy calls use the nostr id,
|
|
# wn calls use the MLS id.
|
|
local gid mls_gid
|
|
gid=$(load_state GROUP_02 || true)
|
|
mls_gid=$(load_state GROUP_02_MLS || true)
|
|
if [[ -z "${gid:-}" || -z "${mls_gid:-}" ]]; then
|
|
record_result "$id" skip "no GROUP_02"; return
|
|
fi
|
|
|
|
amy_json marmot group remove "$gid" "$C_NPUB" >/dev/null || {
|
|
record_result "$id" fail "amy remove C failed"; return
|
|
}
|
|
|
|
# C should no longer see the group on its own member view.
|
|
local deadline=$(( $(date +%s) + 120 )) removed=0
|
|
while [[ $(date +%s) -lt $deadline ]]; do
|
|
if ! wn_c --json groups members "$mls_gid" 2>/dev/null \
|
|
| jq_list members | jq -e --arg p "$C_HEX" \
|
|
'select((.member_id // .pubkey // .public_key) == $p)' \
|
|
>/dev/null 2>&1; then
|
|
removed=1; break
|
|
fi
|
|
sleep 3
|
|
done
|
|
if [[ "$removed" -ne 1 ]]; then
|
|
warn "C still appears as a member — continuing"
|
|
fi
|
|
|
|
amy_json marmot message send "$gid" "after removing C" >/dev/null || {
|
|
record_result "$id" fail "amy send failed"; return
|
|
}
|
|
wait_for_message B "$mls_gid" "after removing C" 90 || {
|
|
record_result "$id" fail "B lost access after C's removal"; return
|
|
}
|
|
|
|
# Forward secrecy: C must NOT see the post-removal message.
|
|
sleep 5
|
|
if wait_for_message C "$mls_gid" "after removing C" 10; then
|
|
record_result "$id" fail "C still decrypted a post-removal message"
|
|
else
|
|
record_result "$id" pass
|
|
fi
|
|
}
|
|
|
|
test_07_metadata_rename() {
|
|
banner "Test 07 — Metadata rename round-trip (MIP-01)"
|
|
local id="07 metadata rename"
|
|
|
|
# amy was the creator of GROUP_02 so its own nostr_group_id is saved as
|
|
# GROUP_02; wn keys its copy by the MLS group id saved as GROUP_02_MLS.
|
|
# Pass each CLI the id it understands.
|
|
local gid mls_gid
|
|
gid=$(load_state GROUP_02 || true)
|
|
mls_gid=$(load_state GROUP_02_MLS || true)
|
|
if [[ -z "${gid:-}" || -z "${mls_gid:-}" ]]; then
|
|
record_result "$id" skip "no GROUP_02"; return
|
|
fi
|
|
|
|
amy_json marmot group rename "$gid" "Interop-02-renamed" >/dev/null || {
|
|
record_result "$id" fail "amy rename failed"; return
|
|
}
|
|
|
|
local deadline=$(( $(date +%s) + 120 )) seen=""
|
|
while [[ $(date +%s) -lt $deadline ]]; do
|
|
# whitenoise-rs ≥ v0.2.x wraps the group payload one level deeper as
|
|
# `{"result": {"group": {…name…}}}`; older builds returned the bare
|
|
# group object under `.result`. Probe both shapes so the test survives
|
|
# either schema.
|
|
# MDK 0.9.x keeps the display name in the profile component, not a
|
|
# top-level `name`: `.result.group.profile.name`.
|
|
seen=$(wn_b --json groups show "$mls_gid" 2>/dev/null \
|
|
| jq -r '(.result // .) | (.group // .) | (.profile.name // .name) // empty')
|
|
[[ "$seen" == "Interop-02-renamed" ]] && break
|
|
sleep 3
|
|
done
|
|
[[ "$seen" == "Interop-02-renamed" ]] || {
|
|
record_result "$id" fail "B saw name=\"$seen\" not \"Interop-02-renamed\""; return
|
|
}
|
|
|
|
# MIP-01: only admins may rename. GROUP_02 was created by amy (sole
|
|
# admin), so for B's rename to be accepted by wn's MIP-01 check amy
|
|
# must first promote B. amy adds B to admin_pubkeys via GCE, which
|
|
# the harness consumes via `marmot group promote` — equivalent to
|
|
# `wn groups promote` but issued by the quartz side. Without this
|
|
# step B's own wn silently refuses the rename on its MIP-01
|
|
# `ensure_account_is_group_admin` check, and the kind:445 is never
|
|
# published.
|
|
amy_json marmot group promote "$gid" "$B_NPUB" >/dev/null 2>&1 || {
|
|
record_result "$id" fail "amy promote-B failed"; return
|
|
}
|
|
# Let wn apply the promote commit before issuing the rename.
|
|
sleep 3
|
|
|
|
# Now B renames back and A should pick it up.
|
|
wn_b groups rename "$mls_gid" "Interop-02-reverse" >/dev/null 2>&1 || true
|
|
if amy_json marmot await rename "$gid" --name "Interop-02-reverse" --timeout 120 >/dev/null; then
|
|
record_result "$id" pass
|
|
else
|
|
record_result "$id" fail "A did not pick up B's rename"
|
|
fi
|
|
}
|
|
|
|
test_08_admin_promote_demote() {
|
|
banner "Test 08 — Admin promote / demote"
|
|
local id="08 admin promote/demote"
|
|
|
|
# GROUP_03 was created by wn so both sides need different ids:
|
|
# GROUP_03 → amy's nostr_group_id (captured in test 03 after
|
|
# `amy await group` returned `.group_id`)
|
|
# GROUP_03_MLS → wn's mls_group_id (wn's `groups create` output)
|
|
local a_gid mls_gid
|
|
a_gid=$(load_state GROUP_03 || true)
|
|
mls_gid=$(load_state GROUP_03_MLS || true)
|
|
if [[ -z "${mls_gid:-}" ]]; then
|
|
record_result "$id" skip "no GROUP_03"; return
|
|
fi
|
|
|
|
# Ensure 3 members (add C if missing).
|
|
wn_c keys publish >/dev/null 2>&1 || true
|
|
sleep 2
|
|
wn_b groups add-members "$mls_gid" "$C_NPUB" >/dev/null 2>&1 || true
|
|
wait_for_invite C 30 >/dev/null && wn_c groups accept "$mls_gid" >/dev/null 2>&1 || true
|
|
|
|
# B promotes A.
|
|
wn_b groups promote "$mls_gid" "$A_NPUB" >/dev/null 2>&1 || {
|
|
record_result "$id" fail "wn promote failed"; return
|
|
}
|
|
|
|
# A should reflect the new admin set — poll via amy.
|
|
if ! amy_json marmot await admin "$a_gid" "$A_NPUB" --timeout 90 >/dev/null; then
|
|
record_result "$id" fail "A never saw itself promoted"; return
|
|
fi
|
|
|
|
# A now commits a rename — only possible if we're admin.
|
|
amy_json marmot group rename "$a_gid" "Interop-03-by-A" >/dev/null || {
|
|
record_result "$id" fail "A (now admin) could not rename"; return
|
|
}
|
|
|
|
# B demotes A.
|
|
wn_b groups demote "$mls_gid" "$A_NPUB" >/dev/null 2>&1 || warn "demote returned nonzero"
|
|
sleep 5
|
|
|
|
local admins
|
|
admins=$(wn_b --json groups admins "$mls_gid" 2>/dev/null \
|
|
| jq_list admins | jq_member_ids | tr '\n' ' ')
|
|
if [[ "$admins" == *"$A_HEX"* ]]; then
|
|
record_result "$id" fail "A still admin after demote"
|
|
else
|
|
record_result "$id" pass
|
|
fi
|
|
}
|
|
|
|
test_11_leave_group() {
|
|
banner "Test 11 — Leave group"
|
|
local id="11 leave group"
|
|
|
|
local gid mls_gid
|
|
gid=$(load_state GROUP_02 || true)
|
|
mls_gid=$(load_state GROUP_02_MLS || true)
|
|
if [[ -z "${gid:-}" ]]; then
|
|
record_result "$id" skip "no GROUP_02"; return
|
|
fi
|
|
|
|
amy_json marmot group leave "$gid" >/dev/null || {
|
|
record_result "$id" fail "amy leave failed"; return
|
|
}
|
|
|
|
local deadline=$(( $(date +%s) + 120 )) gone=0
|
|
while [[ $(date +%s) -lt $deadline ]]; do
|
|
if ! wn_b --json groups members "$mls_gid" 2>/dev/null \
|
|
| jq_list admins | jq -e --arg p "$A_HEX" \
|
|
'select((.admin_id // .pubkey // .public_key) == $p)' \
|
|
>/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 "A still in B's member list after leave"
|
|
fi
|
|
}
|
|
|
|
# Regression guard for the Marmot group-icon feature: setting a group image writes
|
|
# the MIP-01 v2 image fields into the NostrGroupData extension. mdk-core (the library
|
|
# whitenoise uses) rejects ANY trailing bytes in that extension at a known version, so
|
|
# a mis-encoded image commit would make the whole group unprocessable for wn and stall
|
|
# it a full epoch behind A.
|
|
#
|
|
# We prove wn applied the image commit the hard way: A sets an image, then sends an
|
|
# application message at the POST-image epoch. wn can only decrypt that message if it
|
|
# advanced past the image-bearing GCE commit — so wn receiving it is direct evidence
|
|
# the image extension parsed. (Once it parses, whitenoise even tries to fetch the
|
|
# avatar from Blossom via background_sync_group_image_cache_if_needed.)
|
|
#
|
|
# A single application message is used rather than a second commit on purpose: two
|
|
# commits fired 3s apart race wn's per-epoch processing and give a flaky signal.
|
|
test_17_group_image_commit() {
|
|
banner "Test 17 — Group image commit stays parseable on whitenoise (MIP-01 v2)"
|
|
local id="17 group image"
|
|
|
|
local gid mls_gid
|
|
gid=$(load_state GROUP_02 || true)
|
|
mls_gid=$(load_state GROUP_02_MLS || true)
|
|
if [[ -z "${gid:-}" || -z "${mls_gid:-}" ]]; then
|
|
record_result "$id" skip "no GROUP_02"; return
|
|
fi
|
|
|
|
# Skip cleanly if A is no longer a member of GROUP_02 (a later test may have removed
|
|
# A) — this test only makes sense while A can still commit to the group.
|
|
if ! wn_b --json groups members "$mls_gid" 2>/dev/null \
|
|
| jq_list members | jq -e --arg p "$A_HEX" \
|
|
'select((.member_id // .pubkey // .public_key) == $p)' \
|
|
>/dev/null 2>&1; then
|
|
record_result "$id" skip "A not in GROUP_02"; return
|
|
fi
|
|
|
|
# Contents are irrelevant — amy encrypts whatever bytes it's given; the interop
|
|
# question is purely whether the resulting image extension parses on wn.
|
|
local img="$STATE_DIR/marmot-icon.bin"
|
|
head -c 1024 /dev/urandom >"$img" 2>/dev/null || printf 'fake-avatar-bytes-for-interop' >"$img"
|
|
|
|
if ! amy_json marmot group set-image "$gid" "$img" >/dev/null; then
|
|
record_result "$id" fail "amy set-image failed"; return
|
|
fi
|
|
|
|
sleep 3
|
|
local tag="post-image-ping-from-amethyst"
|
|
if ! amy_json marmot message send "$gid" "$tag" >/dev/null; then
|
|
record_result "$id" fail "amy post-image send failed"; return
|
|
fi
|
|
|
|
if wait_for_message B "$mls_gid" "$tag" 120; then
|
|
record_result "$id" pass
|
|
else
|
|
record_result "$id" fail "wn could not decrypt A's post-image message — image commit not applied"
|
|
fi
|
|
}
|
|
|
|
# A reaction White Noise can SEE. Test 09 only proves wn's raw event log holds a
|
|
# kind:7; the app renders the materialized timeline, which attaches a reaction
|
|
# to its target by its own rules. An amy reaction the raw log kept but the
|
|
# timeline dropped read as a pass there and as "no reaction" in the app.
|
|
test_31_reaction_materializes_on_wn() {
|
|
banner "Test 31 — amy's reaction shows in wn's materialized timeline"
|
|
local id="31 reaction materialized"
|
|
|
|
local out gid mls_gid b_gid anchor_id
|
|
out=$(amy_json marmot group create --name "Interop-31") || { 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')
|
|
amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || { record_result "$id" fail "amy could not invite wn"; return; }
|
|
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
|
|
wn_group_field_becomes "$mls_gid" '.group.group_id // empty' "$mls_gid" 120 || { record_result "$id" fail "wn never surfaced the group"; return; }
|
|
|
|
wn_b messages send "$mls_gid" "31 anchor" >/dev/null 2>&1 || true
|
|
amy_json marmot await message "$gid" --match "31 anchor" --timeout 90 >/dev/null || { record_result "$id" fail "amy never got the anchor"; return; }
|
|
anchor_id=$(amy_json marmot message list "$gid" --limit 50 2>/dev/null | jq_list messages \
|
|
| jq -r 'select((.plaintext // .content // "") == "31 anchor") | (.message_id // .event_id)' | head -n 1)
|
|
[[ -n "$anchor_id" && "$anchor_id" != "null" ]] || { record_result "$id" fail "no anchor id in amy's log"; return; }
|
|
amy_json marmot message react "$gid" "$anchor_id" "🍕" >/dev/null || { record_result "$id" fail "amy react failed"; return; }
|
|
|
|
local deadline=$(( $(date +%s) + 90 )) tl=""
|
|
while [[ $(date +%s) -lt $deadline ]]; do
|
|
tl=$(wn_b --json messages timeline list "$mls_gid" --limit 50 2>/dev/null || true)
|
|
if printf '%s' "$tl" | grep -q '🍕'; then
|
|
record_result "$id" pass; return
|
|
fi
|
|
sleep 3
|
|
done
|
|
printf '%s\n' "$tl" >> "$LOG_FILE"
|
|
record_result "$id" fail "wn's timeline never showed amy's reaction (timeline JSON in the log)"
|
|
}
|