diff --git a/cli/tests/headless/helpers.sh b/cli/tests/headless/helpers.sh index e6f6384f98..e01069bb3f 100644 --- a/cli/tests/headless/helpers.sh +++ b/cli/tests/headless/helpers.sh @@ -18,9 +18,13 @@ amy_a() { HOME="$STATE_DIR" "$AMY_BIN" --account A --secret-backend plaintext -- # Run amy, log stderr, surface JSON on stdout, remember last result. amy_json() { - local out - if ! out=$(amy_a "$@" 2>>"$LOG_FILE"); then - fail_msg "amy $*: exit $? (see $LOG_FILE)" + local out rc + # Capture the status separately: inside `if ! cmd; then`, `$?` is the status + # of the negation (always 0), so the message reported "exit 0" for every + # failure and told a reader nothing about what went wrong. + out=$(amy_a "$@" 2>>"$LOG_FILE"); rc=$? + if [[ $rc -ne 0 ]]; then + fail_msg "amy $*: exit $rc (see $LOG_FILE)" printf '%s\n' "$out" >>"$LOG_FILE" return 1 fi diff --git a/cli/tests/marmot/blossom-server.py b/cli/tests/marmot/blossom-server.py new file mode 100755 index 0000000000..73a93d7e5e --- /dev/null +++ b/cli/tests/marmot/blossom-server.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""A throwaway Blossom server for the Marmot interop harness. + +Enough of BUD-01/BUD-02 for both implementations to store and fetch an +encrypted attachment: `PUT /upload` stores the body under its SHA-256 and +returns the blob descriptor, `GET /` serves it back, `HEAD` answers +existence checks. + +Deliberately unauthenticated. Real Blossom servers verify a kind-24242 +authorization event; this one runs on loopback for the duration of a test run +and holds nothing but ciphertext the group already encrypted. Checking the +signature would test the harness, not the protocol. +""" + +import argparse +import hashlib +import json +import os +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +class Handler(BaseHTTPRequestHandler): + blob_dir = "." + base_url = "" + + def _blob_path(self, sha): + return os.path.join(self.blob_dir, sha) + + def _sha_from_path(self): + # BUD-01 allows an optional extension: `/` or `/.bin`. + name = self.path.lstrip("/").split("?")[0] + sha = name.split(".")[0] + if len(sha) != 64 or any(c not in "0123456789abcdef" for c in sha.lower()): + return None + return sha.lower() + + def _send_json(self, code, payload): + body = json.dumps(payload).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_PUT(self): + if not self.path.startswith("/upload"): + self._send_json(404, {"message": "not found"}) + return + length = int(self.headers.get("Content-Length", "0")) + body = self.rfile.read(length) + sha = hashlib.sha256(body).hexdigest() + with open(self._blob_path(sha), "wb") as handle: + handle.write(body) + self._send_json( + 200, + { + "sha256": sha, + "size": len(body), + "type": self.headers.get("Content-Type", "application/octet-stream"), + "uploaded": int(time.time()), + "url": f"{self.base_url}/{sha}", + }, + ) + + def do_GET(self): + sha = self._sha_from_path() + if sha is None or not os.path.exists(self._blob_path(sha)): + self._send_json(404, {"message": "blob not found"}) + return + with open(self._blob_path(sha), "rb") as handle: + body = handle.read() + self.send_response(200) + self.send_header("Content-Type", "application/octet-stream") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_HEAD(self): + sha = self._sha_from_path() + exists = sha is not None and os.path.exists(self._blob_path(sha)) + self.send_response(200 if exists else 404) + self.send_header("Content-Type", "application/octet-stream") + self.end_headers() + + def log_message(self, fmt, *args): + # The harness captures stdout; one line per request is useful when a + # media test fails and useless otherwise. + print("blossom %s - %s" % (self.address_string(), fmt % args), flush=True) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8081) + parser.add_argument("--dir", required=True) + args = parser.parse_args() + + os.makedirs(args.dir, exist_ok=True) + Handler.blob_dir = args.dir + Handler.base_url = f"http://{args.host}:{args.port}" + + server = ThreadingHTTPServer((args.host, args.port), Handler) + print(json.dumps({"ready": True, "base_url": Handler.base_url}), flush=True) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/cli/tests/marmot/marmot-interop-headless.sh b/cli/tests/marmot/marmot-interop-headless.sh index eadd5f00c9..b754ec64b0 100755 --- a/cli/tests/marmot/marmot-interop-headless.sh +++ b/cli/tests/marmot/marmot-interop-headless.sh @@ -62,6 +62,13 @@ BROKER_PORT="${BROKER_PORT:-4455}" BROKER_URI="quic://$BROKER_HOST:$BROKER_PORT" BROKER_PID="" +# A loopback Blossom blob store for the encrypted-media tests. Ciphertext only: +# the file key comes from each group's MLS exporter and never reaches it. +BLOSSOM_HOST="${BLOSSOM_HOST:-127.0.0.1}" +BLOSSOM_PORT="${BLOSSOM_PORT:-8456}" +BLOSSOM_URL="http://$BLOSSOM_HOST:$BLOSSOM_PORT" +BLOSSOM_PID="" + NO_BUILD=0 # Every run starts from empty stores. wnd already wipes B's and C's data dirs # on each start, but A's amy home and the relay's SQLite file used to survive, @@ -82,6 +89,10 @@ ONLY_TESTS="" # address. Exported once here so `wn` and `wnd` both inherit it — `wn` runs the # same validation on any relay argument. export WN_ALLOW_LOOPBACK_RELAYS=1 +# Same shape for blob stores: wn refuses a loopback Blossom endpoint unless it +# is told this is a dev/test run. The harness's blob store is loopback by +# design — nothing in a test run may leave the machine. +export WN_ALLOW_LOOPBACK_BLOB_ENDPOINTS=1 A_NPUB="" A_HEX="" @@ -108,7 +119,12 @@ done if [[ $RESET_STATE -eq 1 && -d "$STATE_DIR" ]]; then # Keep the relay checkout + its build (minutes to rebuild) and the log and # results history; drop everything that holds protocol state. - rm -rf "$STATE_DIR/.amy" "$B_DIR" "$C_DIR" "$RELAY_DATA" + # + # run.env counts as protocol state: it is where tests hand each other group + # ids. Leaving it behind a wipe leaves ids naming groups nobody is in any + # more, and a later `--tests` subset that consumes without re-creating then + # fails on "not a member" for a group id from a previous run. + rm -rf "$STATE_DIR/.amy" "$B_DIR" "$C_DIR" "$RELAY_DATA" "$STATE_DIR/run.env" fi mkdir -p "$STATE_DIR" "$LOG_DIR" "$B_DIR/logs" "$C_DIR/logs" @@ -129,6 +145,8 @@ source "$SCRIPT_DIR/tests-create.sh" source "$SCRIPT_DIR/tests-manage.sh" # shellcheck source=tests-extras.sh source "$SCRIPT_DIR/tests-extras.sh" +# shellcheck source=tests-media.sh +source "$SCRIPT_DIR/tests-media.sh" # Make sure Ctrl+C / SIGTERM / SIGHUP all run the full cleanup path — # otherwise wnd is nohup'd and keeps running after the script dies, @@ -139,6 +157,7 @@ cleanup() { trap - EXIT INT TERM HUP stop_daemons stop_quic_broker + stop_blossom stop_local_relay print_summary exit "$rc" @@ -152,6 +171,7 @@ banner "Marmot headless interop harness ($RUN_TS)" preflight start_local_relay start_quic_broker || true +start_blossom || true start_daemon B "$B_DIR" "$B_SOCKET" start_daemon C "$C_DIR" "$C_SOCKET" ensure_identity_a @@ -179,6 +199,12 @@ ALL_TESTS=( test_16_wn_keypackage_rotation test_18_agent_stream_amy_publishes test_19_agent_stream_wn_publishes + test_20_avatar_url_amy_to_wn + test_21_avatar_url_wn_to_amy + test_22_message_edit_amy_to_wn + test_23_deletion_amy_to_wn + test_24_media_v2_amy_to_wn + test_25_media_v2_wn_to_amy ) # --tests runs a subset in the order given. Most tests read state a previous diff --git a/cli/tests/marmot/setup.sh b/cli/tests/marmot/setup.sh index 82fe0e3c1b..88aa4150f6 100644 --- a/cli/tests/marmot/setup.sh +++ b/cli/tests/marmot/setup.sh @@ -155,6 +155,42 @@ start_quic_broker() { return 1 } +# --- blossom blob store ------------------------------------------------------ +# A loopback Blossom server for the encrypted-media tests. Both implementations +# upload ciphertext to it and fetch each other's back; it never sees a key. +start_blossom() { + if ! command -v python3 >/dev/null 2>&1; then + info "python3 not found — encrypted-media tests will skip" + return 1 + fi + step "starting blossom blob store on $BLOSSOM_URL" + mkdir -p "$STATE_DIR/blossom/blobs" + nohup python3 "$SCRIPT_DIR/blossom-server.py" \ + --host "$BLOSSOM_HOST" --port "$BLOSSOM_PORT" --dir "$STATE_DIR/blossom/blobs" \ + >"$STATE_DIR/blossom/stdout.log" 2>"$STATE_DIR/blossom/stderr.log" & + BLOSSOM_PID=$! + local deadline=$(( $(date +%s) + 15 )) + while [[ $(date +%s) -lt $deadline ]]; do + if grep -q '"ready"' "$STATE_DIR/blossom/stdout.log" 2>/dev/null; then + info "blossom pid $BLOSSOM_PID ready" + return 0 + fi + if ! kill -0 "$BLOSSOM_PID" 2>/dev/null; then break; fi + sleep 1 + done + fail_msg "blossom never came up (see $STATE_DIR/blossom/stderr.log)" + tail -n 20 "$STATE_DIR/blossom/stderr.log" 2>/dev/null | sed 's/^/ /' >&2 || true + BLOSSOM_PID="" + return 1 +} + +stop_blossom() { + [[ -n "${BLOSSOM_PID:-}" ]] || return 0 + step "stopping blossom pid $BLOSSOM_PID" + kill "$BLOSSOM_PID" 2>/dev/null || true + BLOSSOM_PID="" +} + stop_quic_broker() { [[ -n "${BROKER_PID:-}" ]] || return 0 step "stopping broker pid $BROKER_PID" diff --git a/cli/tests/marmot/tests-media.sh b/cli/tests/marmot/tests-media.sh new file mode 100644 index 0000000000..33c5dc28d7 --- /dev/null +++ b/cli/tests/marmot/tests-media.sh @@ -0,0 +1,344 @@ +# 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:-}" "$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:-}', 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 +}