mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
test(marmot): interop coverage for avatars, edits, deletions and media
Six new harness tests against MDK, all green in one clean run: 20 avatar-url amy->wn 21 avatar-url wn->amy 22 edit amy->wn 23 deletion amy->wn 24 media-v2 amy->wn 25 media-v2 wn->amy The media pair needed a blob store, so the harness now runs a loopback Blossom server of its own (`blossom-server.py`, PUT /upload + GET /<sha256>). It holds nothing but ciphertext — the file key comes from each group's MLS exporter — so a download that hashes back to the original bytes is proof both implementations derived the same key. That is the whole point of tests 24 and 25, and they pass in both directions. Test 20 sends a URL that is deliberately NOT normalized (`https://Example.COM:443/a/./avatars/../pic.png`) and asserts both what we store and what wn reads back. Normalization is the wire format for this component — a decoder rejects bytes that differ from its own serialization — so a disagreement here is a group the other side cannot read at all, not a cosmetic difference. Tests 22 and 23 assert what the protocol actually says rather than what a renderer happens to do. For the edit that means a well-formed kind:1009 reaching wn (one `e` tag naming the target, the replacement as its body, the right author) plus our own reader applying the overlay — 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. For the deletion it means the `deleted` flag on wn's materialized timeline, which is its user-visible truth. Two harness bugs surfaced while getting there, both of the kind that make a failure unreadable rather than wrong. `run.env` — where tests hand each other group ids — survived the per-run state wipe, so a `--tests` subset that consumed without re-creating failed on "not a member" for a group id from a previous run. And `amy_json` read `$?` inside `if ! cmd`, where it is the status of the negation, so every failure reported "exit 0". Push (kind 451) has no cross-implementation test here and cannot: the owner proof is an UNPUBLISHED event handed to a push service, the reference CLI exposes no command that emits one, and the harness runs no push service. There is nothing for two implementations to disagree about on the wire. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
This commit is contained in:
@@ -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
|
||||
|
||||
Executable
+109
@@ -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 /<sha256>` 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: `/<sha256>` or `/<sha256>.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()
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:-<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
|
||||
}
|
||||
Reference in New Issue
Block a user