#!/usr/bin/env bash # # marmot-interop.sh — interop test harness: Amethyst <-> whitenoise-rs (wn/wnd) # # Sequential, all-or-nothing. Script drives the `wn` side automatically and # prompts the human operator at each step that requires Amethyst UI action. # # Usage: ./marmot-interop.sh [--local-relays] [--transponder] [--no-build] # set -uo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" STATE_DIR="$SCRIPT_DIR/state" LOG_DIR="$STATE_DIR/logs" B_DIR="$STATE_DIR/B" C_DIR="$STATE_DIR/C" # wnd derives its socket path as "{data_dir}/release/wnd.sock" for release # builds (and ".../dev/wnd.sock" for debug); our preflight always uses # --release, so we hardcode the "release" suffix here. B_SOCKET="$B_DIR/release/wnd.sock" C_SOCKET="$C_DIR/release/wnd.sock" RUN_TS="$(date +%Y%m%d-%H%M%S)" LOG_FILE="$LOG_DIR/run-$RUN_TS.log" RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv" WN_REPO="${WN_REPO:-$STATE_DIR/whitenoise-rs}" WN_BIN="" WND_BIN="" B_NPUB="" B_HEX="" C_NPUB="" C_HEX="" A_NPUB="" A_HEX="" DEFAULT_RELAYS=( "wss://relay.damus.io" "wss://nos.lol" "wss://relay.primal.net" "wss://nostr.bitcoiner.social" "wss://nostr.mom" ) USE_LOCAL_RELAYS=0 ENABLE_TRANSPONDER=0 NO_BUILD=0 usage() { cat < whitenoise-rs interop harness Options: --local-relays Use ws://localhost:8080 instead of public relays (requires 'just docker-up') --transponder Run Test 14 (MIP-05 push notifications) --no-build Don't rebuild wn/wnd if binaries are missing -h, --help Show this help Environment: WN_REPO Path to whitenoise-rs checkout (default: state/whitenoise-rs) EOF } while [[ $# -gt 0 ]]; do case "$1" in --local-relays) USE_LOCAL_RELAYS=1 ;; --transponder) ENABLE_TRANSPONDER=1 ;; --no-build) NO_BUILD=1 ;; -h|--help) usage; exit 0 ;; *) printf 'unknown flag: %s\n' "$1" >&2; usage; exit 2 ;; esac shift done mkdir -p "$STATE_DIR" "$LOG_DIR" "$B_DIR/logs" "$C_DIR/logs" : >"$LOG_FILE" : >"$RESULTS_FILE" # shellcheck source=../lib.sh source "$TESTS_DIR/lib.sh" # --- preflight --------------------------------------------------------------- preflight() { banner "Preflight" for cmd in jq git curl cargo; do if ! command -v "$cmd" >/dev/null 2>&1; then fail_msg "missing required tool: $cmd"; exit 1 fi printf ' %s: %s\n' "$cmd" "$(command -v "$cmd")" >>"$LOG_FILE" done WN_BIN="$WN_REPO/target/release/wn" WND_BIN="$WN_REPO/target/release/wnd" if [[ ! -x "$WN_BIN" || ! -x "$WND_BIN" ]]; then if [[ "$NO_BUILD" -eq 1 ]]; then fail_msg "wn/wnd not found and --no-build set: $WN_BIN"; exit 1 fi if [[ ! -d "$WN_REPO/.git" ]]; then step "cloning whitenoise-rs into $WN_REPO" git clone --depth 1 https://github.com/marmot-protocol/whitenoise-rs.git "$WN_REPO" \ 2>&1 | tee -a "$LOG_FILE" fi step "building wn + wnd (cargo build --release --features cli) — ~5 min first run" ( cd "$WN_REPO" && cargo build --release --features cli --bin wn --bin wnd ) \ 2>&1 | tee -a "$LOG_FILE" fi printf ' wn: %s\n wnd: %s\n' "$WN_BIN" "$WND_BIN" >>"$LOG_FILE" } # --- daemons ----------------------------------------------------------------- # One attempt at bringing wnd up. Returns 0 on ready, 1 on crash/timeout. # Caller inspects $data_dir/logs/stderr.log to decide whether to retry. _start_daemon_attempt() { local name="$1" data_dir="$2" socket="$3" rm -f "$socket" mkdir -p "$data_dir/logs" "$data_dir/release" # wnd puts its socket at {data_dir}/release/wnd.sock (release build) — we # don't pass --socket because the daemon doesn't accept that flag. nohup "$WND_BIN" --data-dir "$data_dir" --logs-dir "$data_dir/logs" \ >"$data_dir/logs/stdout.log" 2>"$data_dir/logs/stderr.log" & local pid=$! echo "$pid" > "$data_dir/pid" info "$name pid $pid; waiting for socket at $socket ..." local deadline=$(( $(date +%s) + 30 )) while [[ $(date +%s) -lt $deadline ]]; do if [[ -S "$socket" ]] && "$WN_BIN" --socket "$socket" whoami >/dev/null 2>&1; then info "$name ready" return 0 fi # Exit early if wnd already crashed — no point waiting the full 30s. if ! kill -0 "$pid" 2>/dev/null; then return 1 fi sleep 1 done # Hung rather than crashed — kill it so the retry path has a clean slot. if kill -0 "$pid" 2>/dev/null; then kill "$pid" 2>/dev/null || true sleep 1 kill -9 "$pid" 2>/dev/null || true fi return 1 } start_daemon() { local name="$1" data_dir="$2" socket="$3" step "starting $name daemon" if [[ -S "$socket" ]] && "$WN_BIN" --socket "$socket" whoami >/dev/null 2>&1; then info "$name daemon already running" return 0 fi if _start_daemon_attempt "$name" "$data_dir" "$socket"; then return 0 fi # Recover from a stale MLS SQLite DB whose keyring entry has gone # missing (e.g. the keychain entry was pruned, the data dir was # restored without the keyring, or a previous run used the mock # keyring). wnd can't open the DB in that state, but the identity is # disposable — wipe the data dir and let ensure_identity recreate it. if [[ -s "$data_dir/logs/stderr.log" ]] && \ grep -q 'KeyringEntryMissingForExistingDatabase' "$data_dir/logs/stderr.log"; then warn "$name: stale MLS DB detected (keyring entry missing) — wiping $data_dir and retrying" find "$data_dir" -mindepth 1 -maxdepth 1 ! -name 'logs' \ -exec rm -rf {} + 2>/dev/null || true if _start_daemon_attempt "$name" "$data_dir" "$socket"; then return 0 fi fi fail_msg "$name daemon failed to start" if [[ -s "$data_dir/logs/stderr.log" ]]; then printf ' --- last 40 lines of %s ---\n' "$data_dir/logs/stderr.log" >&2 tail -n 40 "$data_dir/logs/stderr.log" | sed 's/^/ /' >&2 printf ' --- end stderr ---\n' >&2 fi if [[ -s "$data_dir/logs/stdout.log" ]]; then printf ' --- last 20 lines of %s ---\n' "$data_dir/logs/stdout.log" >&2 tail -n 20 "$data_dir/logs/stdout.log" | sed 's/^/ /' >&2 printf ' --- end stdout ---\n' >&2 fi exit 1 } stop_daemons() { for d in "$B_DIR" "$C_DIR"; do if [[ -f "$d/pid" ]]; then local pid; pid=$(cat "$d/pid" 2>/dev/null || echo "") if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then info "stopping daemon pid $pid" kill "$pid" 2>/dev/null || true sleep 1 kill -9 "$pid" 2>/dev/null || true fi rm -f "$d/pid" fi done } # --- identities -------------------------------------------------------------- ensure_identity() { local who="$1" cmd if [[ "$who" == "B" ]]; then cmd=wn_b; else cmd=wn_c; fi step "ensuring Identity $who" # First try whoami (will be empty if no account yet). Try --json, then the # yaml-ish pretty form as a fallback. local out npub="" out=$("$cmd" --json whoami 2>/dev/null || true) npub=$(extract_pubkey "$out") if [[ -z "${npub:-}" ]]; then out=$("$cmd" whoami 2>/dev/null || true) npub=$(extract_pubkey "$out") fi if [[ -z "${npub:-}" ]]; then info "creating new identity for $who" out=$("$cmd" create-identity 2>&1 | tee -a "$LOG_FILE") npub=$(extract_pubkey "$out") # Some builds only print the npub on whoami after create, not on # create-identity itself — re-query as a last resort. if [[ -z "${npub:-}" ]]; then out=$("$cmd" whoami 2>/dev/null || true) npub=$(extract_pubkey "$out") fi fi if [[ -z "${npub:-}" ]]; then fail_msg "could not determine npub for $who — raw output below:" printf '%s\n' "$out" | tee -a "$LOG_FILE" >&2 exit 1 fi local hex hex=$(npub_to_hex "$npub") if [[ "$who" == "B" ]]; then B_NPUB="$npub"; B_HEX="$hex" else C_NPUB="$npub"; C_HEX="$hex"; fi save_state "${who}_NPUB" "$npub" save_state "${who}_HEX" "$hex" info "$who npub: $npub" [[ "$hex" != "$npub" ]] && info "$who hex: $hex" } prompt_for_a_npub() { A_NPUB=$(load_state "A_NPUB" || true) if [[ -n "${A_NPUB:-}" ]]; then info "A npub (cached): $A_NPUB" else printf '\n%sPaste your Amethyst account npub:%s ' "$C_CYAN" "$C_RESET" >&2 read -r A_NPUB save_state "A_NPUB" "$A_NPUB" fi A_HEX=$(npub_to_hex "$A_NPUB") save_state "A_HEX" "$A_HEX" info "A npub: $A_NPUB" [[ "$A_HEX" != "$A_NPUB" ]] && info "A hex: $A_HEX" } # Trigger wn's on-demand discovery for A and dump what wn actually # learned — kinds 10002 / 10050 / 10051 from A's configured relays. # Called after prompt_for_a_npub so wn's SQLite cache is populated # BEFORE the test suite starts, and so the operator can spot "wn sees # nothing for A" failures up front instead of as a cryptic Test 03 # timeout. # # Implementation: # - `wn keys check ` calls `resolve_user_blocking` which issues # a targeted fetch for kinds [0, 10002, 10050, 10051] on the # discovery-plane relays. That's the only published side-effect # way to populate wn's user_relays table (no public CLI reads # another user's relay lists directly). # - After the fetch, `sqlite3` is used to SELECT from `user_relays` # joined against `users` + `relays`. If sqlite3 isn't installed # the probe degrades to "trust it, go run the tests" with a warning. discover_a_relays() { banner "Discovering A's advertised relays via wn" step "wn_b keys check \$A_NPUB — populates wn's user_relays cache for A" local kp_raw kp_event_id kp_raw=$(wn_b --json keys check "$A_NPUB" 2>>"$LOG_FILE" || true) printf 'wn_b keys check A raw: %s\n' "$kp_raw" >>"$LOG_FILE" kp_event_id=$(printf '%s' "$kp_raw" | jq -r '.result.event_id // .event_id // empty' 2>/dev/null || true) if [[ -n "$kp_event_id" && "$kp_event_id" != "null" ]]; then info "wn_b found A's KeyPackage (kind:30443) — discovery plane is working" else warn "wn_b could NOT find A's KeyPackage. wn is bootstrapped on ${DEFAULT_RELAYS[*]}." warn "Either Amethyst never published a KeyPackage, or it's only on relays wn can't reach." warn "All later tests will fail. Fix this before continuing (tap KP publish in Amethyst settings)." fi # Also prime wn_c so Tests 04+ (which use C as a third member) can # target A without another round-trip later. wn_c --json keys check "$A_NPUB" >>"$LOG_FILE" 2>&1 || true # Give wn ~3s to process the kind:10002/10050/10051 events that # came back in the same fetch (they're not event_id-returned, they # just land in user_relays via the subscription callback). sleep 3 if ! command -v sqlite3 >/dev/null 2>&1; then warn "sqlite3 not installed — skipping wn user_relays cache probe." warn "If Test 03 fails with 'no invite arrived', install sqlite3 or rerun with --local-relays." return fi # wn stores its database under $data_dir/release/.sqlite. # Find the DB file by looking for the only *.sqlite under release/. local db db=$(find "$B_DIR/release" -maxdepth 1 -name '*.sqlite' -print -quit 2>/dev/null || true) if [[ -z "$db" || ! -s "$db" ]]; then warn "wn SQLite DB not found under $B_DIR/release — cannot probe user_relays." return fi # Screen: one-line summary (counts per list). Full rows to $LOG_FILE. local rows # wn's SQLite schema stores `users.pubkey` — observed as both BLOB # (raw 32 bytes) and TEXT (lowercase hex) across versions. Try both # forms so the probe doesn't falsely report "NO cached relay entries" # when the cache is actually populated. rows=$(sqlite3 -readonly "$db" \ "SELECT ur.relay_type, r.url FROM user_relays ur JOIN users u ON u.id = ur.user_id JOIN relays r ON r.id = ur.relay_id WHERE u.pubkey = X'$A_HEX' OR LOWER(CAST(u.pubkey AS TEXT)) = LOWER('$A_HEX') ORDER BY ur.relay_type, r.url;" 2>>"$LOG_FILE" || true) if [[ -z "$rows" ]]; then warn "wn has NO cached relay entries for A ($A_HEX)." warn " → Either Amethyst hasn't published kind:10002/10050/10051 to any relay wn_b" warn " can reach, OR wn's SQLite schema changed pubkey column encoding again." return fi # Collate by relay_type. Full lists go to $LOG_FILE, only a count # summary reaches stderr so the happy path stays quiet. local nip65 inbox kp nip65=$(printf '%s\n' "$rows" | awk -F'|' '$1=="nip65"{print $2}') inbox=$(printf '%s\n' "$rows" | awk -F'|' '$1=="inbox"{print $2}') kp=$(printf '%s\n' "$rows" | awk -F'|' '$1=="key_package"{print $2}') { printf "A's kind:10002 (nip65):\n%s\n" "${nip65:- }" printf "A's kind:10050 (DM inbox):\n%s\n" "${inbox:- }" printf "A's kind:10051 (KeyPackage relays):\n%s\n" "${kp:- }" } >>"$LOG_FILE" local n_nip65 n_inbox n_kp n_nip65=$(printf '%s' "$nip65" | grep -c '.' || true) n_inbox=$(printf '%s' "$inbox" | grep -c '.' || true) n_kp=$(printf '%s' "$kp" | grep -c '.' || true) info "A's relay lists: nip65=$n_nip65, inbox=$n_inbox, key_package=$n_kp (full lists in $LOG_FILE)" # Warn loudly if A's DM inbox is non-empty but shares no relay with # wn_b's own configured set — that's the exact failure mode the # operator already hit ("my DM inbox has auth.nostr1.com, wn can't # publish there") and it silently breaks Test 03+. if [[ -n "$inbox" ]]; then local wn_relays wn_relays=$(wn_b --json relays list 2>/dev/null \ | jq -r '(.result // .)[]? | (.url // .relay.url // empty)' 2>/dev/null) local overlap="" while IFS= read -r a_url; do a_url="${a_url# }" [[ -z "$a_url" ]] && continue if printf '%s\n' "$wn_relays" | grep -qxF "$a_url"; then overlap+="$a_url," fi done <<< "$inbox" overlap="${overlap%,}" if [[ -z "$overlap" ]]; then warn "A's DM inbox (kind:10050) shares ZERO relays with wn_b's bootstrap set." warn " wn may still reach A's 10050 relays if they're public — but if any require" warn " NIP-42 AUTH (auth.nostr1.com) or a whitelist (relay.0xchat.com), the welcome" warn " gift wrap silently fails to publish." warn " Real-world fix: edit Amethyst's DM Inbox list to include at least one of:" warn " ${DEFAULT_RELAYS[*]}" else info "A's DM inbox overlaps wn's bootstrap at: $overlap — welcomes should flow" fi fi } # --- relays ------------------------------------------------------------------ configure_relays() { banner "Configuring relays" local relays=() if [[ "$USE_LOCAL_RELAYS" -eq 1 ]]; then relays=( "ws://localhost:8080" ) else relays=( "${DEFAULT_RELAYS[@]}" ) fi # Each relay × 3 types × 2 daemons produces a lot of repetitive "ok" # lines — the happy path doesn't need any of it on screen. Quiet the # per-add logging into $LOG_FILE and only surface real failures as # warnings. A single summary line prints at the end. local adds_ok=0 adds_dup=0 adds_fail=0 add_relay() { local wn_fn="$1" r="$2" t add_out for t in nip65 inbox key_package; do if add_out=$("$wn_fn" relays add --type "$t" "$r" 2>&1); then adds_ok=$((adds_ok+1)) printf '%s relays add --type %s %s: ok\n' "$wn_fn" "$t" "$r" >>"$LOG_FILE" else case "$add_out" in *"already exists"*|*"already added"*|*"duplicate"*) adds_dup=$((adds_dup+1)) printf '%s relays add --type %s %s: already exists\n' "$wn_fn" "$t" "$r" >>"$LOG_FILE" ;; *) adds_fail=$((adds_fail+1)) warn "$wn_fn relays add --type $t $r failed: $add_out" printf '%s relays add --type %s %s: %s\n' "$wn_fn" "$t" "$r" "$add_out" >>"$LOG_FILE" ;; esac fi done } step "adding ${#relays[@]} relay(s) × 3 types × 2 daemons" for r in "${relays[@]}"; do add_relay wn_b "$r" add_relay wn_c "$r" done info "relay add: $adds_ok ok, $adds_dup already-present, $adds_fail failed" # Push a kind:0 profile for each wn identity BEFORE the kind:30443 / # 10050 / 1059 probes. Several public relays (damus.io in particular) # silently drop non-profile kinds from accounts they've never seen # before, so a fresh wn identity created inside the harness never gets # its gift wraps or inbox lists stored. Publishing kind:0 first # registers the account in the relay's "known users" set, which lets # every later publish go through. publish_profile() { local who="$1" wnfn if [[ "$who" == "B" ]]; then wnfn=wn_b; else wnfn=wn_c; fi local name="marmot-interop $who" local about="Scripted wn identity for Amethyst<->whitenoise-rs interop harness" local out if out=$("$wnfn" profile update --name "$name" --about "$about" 2>&1); then printf '%s profile update ok: %s\n' "$who" "$out" >>"$LOG_FILE" else warn "$who profile update failed: $out" printf '%s profile update: %s\n' "$who" "$out" >>"$LOG_FILE" fi } step "publishing kind:0 profiles so relays treat B and C as 'known' accounts" publish_profile B publish_profile C # Give the relay a beat to persist + ack the kind:0 before the next # publish (some relays gate subsequent writes on the profile being # indexed, and without this pause the first `keys publish` that # follows sometimes races ahead of the kind:0 commit). sleep 2 # Full relay list goes to $LOG_FILE only — the per-relay status rarely # matters unless a later test fails, and it's 5-10 noisy lines otherwise. local relay_list relay_list=$(wn_b --json relays list 2>/dev/null || true) printf 'B relay list: %s\n' "$relay_list" >>"$LOG_FILE" # Sanity check is split across three relay-level surfaces so a failure # points at the exact kind the relay set can't handle, instead of 10 # later tests all timing out with the same "no invite" symptom: # # kind:30443 (KeyPackage) — both sides publish + discover # kind:10050 (Inbox advertise) — C must be reachable as a giftwrap target # kind:1059 (Gift wrap) — B->C welcome delivery # kind:445 (MLS commit/msg) — real group message round-trip # # All failures are warnings (not hard fails) so a known-broken relay set # still lets the harness continue into the 13 tests — they will fail # anyway but at least the operator will have the diagnostic hint here. step "sanity-check kind:30443: B+C publish + symmetric discover" local publish_out if ! publish_out=$(wn_b keys publish 2>&1); then warn "wn_b keys publish failed: $publish_out" fi printf 'wn_b keys publish: %s\n' "$publish_out" >>"$LOG_FILE" if ! publish_out=$(wn_c keys publish 2>&1); then warn "wn_c keys publish failed: $publish_out" fi printf 'wn_c keys publish: %s\n' "$publish_out" >>"$LOG_FILE" sleep 4 local sanity_raw sanity_id sanity_cb="" sanity_bc="" sanity_raw=$(wn_c --json keys check "$B_NPUB" 2>>"$LOG_FILE" || true) sanity_id=$(printf '%s' "$sanity_raw" | jq -r '.result.event_id // .event_id // empty' 2>/dev/null || true) printf 'sanity keys check B raw: %s\n' "$sanity_raw" >>"$LOG_FILE" if [[ -n "$sanity_id" && "$sanity_id" != "null" ]]; then sanity_cb="$sanity_id" else warn "kind:30443 C->B failed — relays may be rejecting KeyPackages" fi sanity_raw=$(wn_b --json keys check "$C_NPUB" 2>>"$LOG_FILE" || true) sanity_id=$(printf '%s' "$sanity_raw" | jq -r '.result.event_id // .event_id // empty' 2>/dev/null || true) printf 'sanity keys check C raw: %s\n' "$sanity_raw" >>"$LOG_FILE" if [[ -n "$sanity_id" && "$sanity_id" != "null" ]]; then sanity_bc="$sanity_id" else warn "kind:30443 B->C failed — relays may be rejecting KeyPackages" fi if [[ -n "$sanity_cb" && -n "$sanity_bc" ]]; then info "kind:30443 B<->C ok (C->B=${sanity_cb:0:8}… B->C=${sanity_bc:0:8}…)" fi step "sanity-check kinds 10050/1059/445: B creates group + invites C + exchanges a message" local sanity_gid sanity_c_gid sanity_create c_ignore # Snapshot C's pending invites before we publish, so wait_for_invite # doesn't fire on a stale welcome left over from a previous run (wnd # persists pending invites across restarts — we saw this cause the # kind:445 sanity probe to send to B's new group while C was still # sitting on an unaccepted welcome from a previous group that B # wasn't a member of anymore). c_ignore=$(snapshot_invites C) [[ -n "$c_ignore" ]] && info "C already has stale invites, ignoring: $c_ignore" sanity_create=$(wn_b --json groups create "sanity-check" "$C_NPUB" 2>>"$LOG_FILE" || true) sanity_gid=$(printf '%s' "$sanity_create" | jq_group_id) printf 'sanity groups create raw: %s\n' "$sanity_create" >>"$LOG_FILE" if [[ -z "$sanity_gid" ]]; then warn "sanity group create failed — see $LOG_FILE (stale account state? rerun with 'rm -rf state/')" else printf 'sanity group id: %s\n' "$sanity_gid" >>"$LOG_FILE" if sanity_c_gid=$(wait_for_invite C 30 "$c_ignore"); then if [[ "$sanity_c_gid" != "$sanity_gid" ]]; then warn "gid mismatch — C saw $sanity_c_gid, B created $sanity_gid (likely a stale welcome slipped past the filter)" fi wn_c groups accept "$sanity_c_gid" >/dev/null 2>&1 || true wn_b messages send "$sanity_gid" "sanity-ping" >/dev/null 2>&1 || true if wait_for_message C "$sanity_c_gid" "sanity-ping" 30; then info "sanity kinds 10050/1059/445 ok (B->C welcome + message round-trip)" else warn "kind:445 failed — C never decrypted sanity-ping (relays may be dropping group messages)" warn "Consider rerunning with --local-relays." fi # best-effort cleanup so re-runs don't accumulate dead sanity groups wn_c groups leave "$sanity_c_gid" >/dev/null 2>&1 || true wn_b groups leave "$sanity_gid" >/dev/null 2>&1 || true else warn "kind:10050/1059 failed — C never received welcome; relays likely dropping gift wraps or inbox lists" warn "Consider rerunning with --local-relays (requires 'just docker-up' in whitenoise-rs)." fi fi } instruct_amethyst_setup() { if [[ "$USE_LOCAL_RELAYS" -eq 1 ]]; then # Offline/sandbox path: we own the only relay, so the harness DOES # need to dictate Amethyst's relay config — nothing is discoverable # via the public network. prompt_human "Configure Amethyst to match this --local-relays harness: 1. Settings -> Relays: add as READ+WRITE ws://10.0.2.2:8080 (Android emulator) ws://:8080 (physical device on same Wi-Fi) 2. Settings -> Key Package Relays: add the SAME URL 3. Settings -> DM Inbox Relays (NIP-17/kind:10050): add the SAME URL 4. Trigger key-package publish (toggle KP relay on/off if needed) 5. Confirm your Amethyst account is logged in with npub: $A_NPUB" return fi # Public-relay path: the harness should behave like any real Nostr # client — discover A's advertised relays via kind:10002 / 10050 / # 10051 and publish there, rather than forcing A to adopt the # harness's own relay set. That lets the tests surface real-world # interop failures (e.g. A's DM inbox points at auth-required or # unreachable relays) instead of hiding them behind shared config. prompt_human "No relay reconfiguration required. Just confirm: 1. Amethyst is logged in as npub: $A_NPUB 2. Amethyst has published (on its own chosen relays): - kind:30443 KeyPackage - kind:10051 Key Package Relay List - kind:10050 DM Inbox Relay List - kind:10002 NIP-65 Outbox/Inbox Relay List Any one of Amethyst's public write relays will do — the wn daemons below are bootstrapped on $(printf '%s, ' "${DEFAULT_RELAYS[@]}" | sed 's/, $//') and will discover A's advertised relays automatically. 3. If the wn-side diagnostic after this prompt shows that wn cannot see any of A's four lists, Amethyst hasn't published them to any relay wn can reach — republish them (toggle a relay off/on in Settings, then verify via an external relay inspector)." } # ==== tests ================================================================== test_01_keypackage_discovery() { banner "Test 01 — KeyPackage publish & discovery (MIP-00)" step "B fetches A's KeyPackage from relays" local kp_relays kp_raw kp_event_id kp_relays=$(wn_b --json relays list 2>/dev/null || wn_b relays list 2>/dev/null || true) printf 'B relay list: %s\n' "$kp_relays" >>"$LOG_FILE" kp_raw=$(wn_b --json keys check "$A_NPUB" 2>>"$LOG_FILE" || true) printf 'wn keys check raw output: %s\n' "$kp_raw" >>"$LOG_FILE" kp_event_id=$(printf '%s' "$kp_raw" | jq -r '.result.event_id // .event_id // empty' 2>/dev/null || true) if [[ -n "$kp_event_id" && "$kp_event_id" != "null" ]]; then pass_msg "B discovered A's KeyPackage (event_id: $kp_event_id)" record_result "01a KeyPackage A->B" pass else fail_msg "B cannot find A's KeyPackage. Did Amethyst publish to the same relays?" printf 'raw response from wn keys check: %s\n' "$kp_raw" >>"$LOG_FILE" record_result "01a KeyPackage A->B" fail "wn keys check returned no event_id" fi prompt_human "In Amethyst: open 'Create Group' (or 'Add Member' on an existing group). Type or paste: $B_NPUB Amethyst should resolve the npub and enable 'Add' (KeyPackage discovered)." if confirm "Does Amethyst resolve $B_NPUB as having a valid KeyPackage?"; then record_result "01b KeyPackage B->A" pass else record_result "01b KeyPackage B->A" fail "Amethyst did not resolve B" fi } test_02_amethyst_creates_group() { banner "Test 02 — Amethyst creates group, invites B" # Snapshot B's already-pending invites so we can tell the new welcome # apart from a leftover from a previous run. Baseline daemon dump is # skipped on the happy path — dumped on timeout below instead. local b_ignore b_ignore=$(snapshot_invites B) [[ -n "$b_ignore" ]] && info "B already has stale invites, ignoring: $b_ignore" prompt_human "In Amethyst: 1. Tap '+' -> Create Group 2. Name: Interop-02 3. Add member: $B_NPUB 4. Tap Create / Send Invite Tip: watch the Android logcat for tag 'MarmotDbg' — you should see 'publishing welcome gift wrap id=… kind:1059 → N relay(s): [...]' listing the same relays as B's inbox above. If the list is empty or different, that's the bug." step "polling B's daemon for invite (60s, heartbeat every ~10s)" local gid if ! gid=$(wait_for_invite B 60 "$b_ignore"); then fail_msg "no invite arrived at B" dump_daemon_diagnostics B "post-timeout (test_02)" prompt_amethyst_logcat "test_02 timeout" record_result "02 Amethyst->B create+invite" fail "invite never arrived" return fi save_state "GROUP_02" "$gid" info "group_id: $gid" step "B accepts invite" if ! wn_b groups accept "$gid" >/dev/null 2>&1; then fail_msg "B failed to accept invite" record_result "02 Amethyst->B create+invite" fail "accept failed" return fi step "verifying member list on B" local members members=$(wn_b --json groups members "$gid" 2>/dev/null | jq -r '(.result // .) | .[].pubkey // .[].public_key' 2>/dev/null | sort | tr '\n' ' ') if ! expect_contains "$members" "$A_HEX" "member list" \ || ! expect_contains "$members" "$B_HEX" "member list"; then record_result "02 Amethyst->B create+invite" fail "member list mismatch" return fi prompt_human "In Amethyst, send this message in group 'Interop-02': hello from amethyst" step "B waits for the message (30s)" if wait_for_message B "$gid" "hello from amethyst" 30; then pass_msg "B decrypted message from Amethyst" else fail_msg "B never saw 'hello from amethyst'" record_result "02 Amethyst->B create+invite" fail "inbound message missing" return fi step "B sends reply" local send_out if ! send_out=$(wn_b messages send "$gid" "hello from wn" 2>&1); then warn "wn messages send returned nonzero: $send_out" fi printf 'wn messages send (test_02 reply) raw: %s\n' "$send_out" >>"$LOG_FILE" # Give the relay a couple seconds to fan the commit/message back out to # Amethyst's subscription before we ask the operator to eyeball the UI. sleep 4 if confirm "Does Amethyst now show 'hello from wn' in the same group?"; then record_result "02 Amethyst->B create+invite" pass else # Dump the B side so the failure report shows whether the send reached # B's own DB and which relays wn tagged on the kind:445, then ask for # the Amethyst side so the report has both halves of the pipe. dump_outbound_diagnostics B "$gid" "hello from wn" "test_02 B->A reply" prompt_human "Capture Amethyst's side of the B->A kind:445 path. On the adb host: adb logcat -d -v time | grep -E \ 'MarmotDbg|GroupEventHandler|MarmotGroupEventsEoseManager|kind:445' \\ | tail -n 200 > /tmp/amethyst-marmot.log Then paste /tmp/amethyst-marmot.log here. Key lines to look for: - 'MarmotGroupEventsEoseManager.updateFilter: group=${gid:0:8}…' + the relay list — must match the 'group relays (MLS extension)' in the B-side dump. - 'GroupEventHandler.add: kind:445 id=… groupId=${gid:0:8}…' (event arrived). - 'GroupEventHandler.add: not a member of group=${gid:0:8}…' (dropped). - 'GroupEventHandler.add: processGroupEvent returned ApplicationMessage…' (decrypted)." record_result "02 Amethyst->B create+invite" fail "Amethyst did not display reply" fi } test_03_wn_creates_group() { banner "Test 03 — B creates group, invites Amethyst" step "B creates group 'Interop-03' with A as sole invitee" local out gid out=$(wn_b --json groups create "Interop-03" "$A_NPUB" 2>>"$LOG_FILE") printf 'wn groups create raw output: %s\n' "$out" >>"$LOG_FILE" gid=$(printf '%s' "$out" | jq_group_id) if [[ -z "$gid" ]]; then fail_msg "could not parse group_id from 'wn groups create' output" info "raw output: $out" record_result "03 B->Amethyst create+invite" fail "no group_id" return fi save_state "GROUP_03" "$gid" info "group_id: $gid" prompt_human "In Amethyst, you should see a new pending group invite 'Interop-03' from $B_NPUB. Accept the invite and open the chat." if ! confirm "Did Amethyst accept and open group 'Interop-03'?"; then record_result "03 B->Amethyst create+invite" fail "Amethyst did not accept invite" return fi step "B sends: 'ping from wn'" wn_b messages send "$gid" "ping from wn" >/dev/null 2>&1 || true if ! confirm "Does Amethyst show 'ping from wn'?"; then record_result "03 B->Amethyst create+invite" fail "Amethyst did not show wn message" return fi prompt_human "In Amethyst, send: 'pong from amethyst'" if wait_for_message B "$gid" "pong from amethyst" 30; then record_result "03 B->Amethyst create+invite" pass else record_result "03 B->Amethyst create+invite" fail "B did not receive pong" fi } test_04_three_member_group() { banner "Test 04 — 3-member group, add C after create" step "C publishes KeyPackage so A can discover it" wn_c keys publish >/dev/null 2>&1 || true sleep 4 local gid gid=$(load_state GROUP_02 || true) if [[ -z "${gid:-}" ]]; then warn "GROUP_02 not set (Test 02 likely skipped/failed); creating new group here" local out out=$(wn_b --json groups create "Interop-04-bootstrap" "$A_NPUB" 2>>"$LOG_FILE") gid=$(printf '%s' "$out" | jq_group_id) save_state GROUP_02 "$gid" prompt_human "In Amethyst, accept the new group invite 'Interop-04-bootstrap'." fi info "using group $gid" # Baseline C-side daemon dump is skipped on the happy path; the # invite-timeout branch below still dumps on failure. local c_ignore c_ignore=$(snapshot_invites C) [[ -n "$c_ignore" ]] && info "C already has stale invites, ignoring: $c_ignore" prompt_human "In Amethyst, open the group from Test 02 (or 'Interop-04-bootstrap'). Group Info -> Add Member -> paste: $C_NPUB Confirm the invite is sent." step "polling C for invite (60s, heartbeat every ~10s)" local c_gid if ! c_gid=$(wait_for_invite C 60 "$c_ignore"); then fail_msg "C never received invite" dump_daemon_diagnostics C "post-timeout (test_04)" prompt_amethyst_logcat "test_04 timeout" record_result "04 3-member add-after-create" fail "invite to C missing" return fi wn_c groups accept "$c_gid" >/dev/null 2>&1 || true info "C accepted invite ($c_gid)" step "verifying all three members on all three clients" local members_b members_b=$(wn_b --json groups members "$gid" 2>/dev/null | jq -r '(.result // .) | .[].pubkey // .[].public_key' | tr '\n' ' ') expect_contains "$members_b" "$A_HEX" "B's member list" || : expect_contains "$members_b" "$B_HEX" "B's member list" || : expect_contains "$members_b" "$C_HEX" "B's member list" || : prompt_human "In Amethyst, send: 'hello three-member world'" if wait_for_message B "$gid" "hello three-member world" 30 \ && wait_for_message C "$c_gid" "hello three-member world" 30; then pass_msg "both B and C decrypted Amethyst's message after Add" record_result "04 3-member add-after-create" pass else fail_msg "B or C did not see Amethyst's post-Add message" record_result "04 3-member add-after-create" fail "message not received after Add" fi } test_05_wn_adds_amethyst_existing() { banner "Test 05 — wn adds Amethyst to existing B+C group" step "B creates group with only C, then adds A" local out gid c_ignore c_ignore=$(snapshot_invites C) out=$(wn_b --json groups create "Interop-05" "$C_NPUB" 2>>"$LOG_FILE") printf 'wn groups create raw output: %s\n' "$out" >>"$LOG_FILE" gid=$(printf '%s' "$out" | jq_group_id) if [[ -z "$gid" ]]; then fail_msg "could not create Interop-05" record_result "05 wn adds Amethyst existing" fail "create failed" return fi save_state GROUP_05 "$gid" local c_new_gid if c_new_gid=$(wait_for_invite C 30 "$c_ignore"); then wn_c groups accept "$c_new_gid" >/dev/null 2>&1 || true fi step "B adds A to the group" wn_b groups add-members "$gid" "$A_NPUB" >/dev/null 2>&1 || { fail_msg "wn groups add-members failed" record_result "05 wn adds Amethyst existing" fail "add-members failed" return } prompt_human "In Amethyst, you should see a new invite 'Interop-05'. Accept and open the chat." prompt_human "In Amethyst, send: 'joined from amethyst'" if wait_for_message B "$gid" "joined from amethyst" 30 \ && wait_for_message C "$gid" "joined from amethyst" 30; then record_result "05 wn adds Amethyst existing" pass else record_result "05 wn adds Amethyst existing" fail "message not seen by B or C" fi } test_06_member_removal() { banner "Test 06 — Member removal, forward secrecy at next epoch" local gid gid=$(load_state GROUP_05 || true) if [[ -z "${gid:-}" ]]; then skip_msg "Test 06 requires Test 05 state"; record_result "06 member removal" skip "no GROUP_05"; return fi # Interop-05 was created by B (wn), so B is the sole admin. Per # MIP-03 `enforceAuthorizedProposalSet`, non-admin members may only # commit a self-Update or a SelfRemove-only proposal set — a Remove # proposal from A (a plain member here) is rejected before publish # with "non-admin members may only commit …". The previous revision # of this test asked the operator to remove C from Amethyst's UI, # which is correct per spec to REJECT — so the test itself was # misaligned with the admin model. Drive the removal from B (the # admin) instead, and observe its effect on A + C. step "B (admin) removes C from Interop-05" local remove_out if ! remove_out=$(wn_b groups remove-members "$gid" "$C_NPUB" 2>&1); then fail_msg "wn_b groups remove-members failed: $remove_out" printf 'wn_b groups remove-members: %s\n' "$remove_out" >>"$LOG_FILE" record_result "06 member removal" fail "wn remove returned nonzero" return fi printf 'wn_b groups remove-members: %s\n' "$remove_out" >>"$LOG_FILE" step "verifying C is removed from B's + C's view (60s poll)" local deadline=$(( $(date +%s) + 60 )) removed_b=0 removed_c=0 while [[ $(date +%s) -lt $deadline ]]; do if ! wn_b --json groups members "$gid" 2>/dev/null \ | jq -e --arg p "$C_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' >/dev/null 2>&1; then removed_b=1 fi if ! wn_c --json groups members "$gid" 2>/dev/null \ | jq -e --arg p "$C_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' >/dev/null 2>&1; then removed_c=1 fi [[ "$removed_b" -eq 1 ]] && break sleep 3 done if [[ "$removed_b" -ne 1 ]]; then warn "B still shows C as a member after remove — commit may not have applied" fi info "post-remove: B sees C gone=$removed_b, C sees self gone=$removed_c" prompt_human "In Amethyst, open 'Interop-05' -> Group Info and confirm C is no longer listed." if ! confirm "Does Amethyst's member list now show only you and B (not C)?"; then record_result "06 member removal" fail "Amethyst still shows C as a member" return fi prompt_human "In Amethyst, send: 'after removing C'" if wait_for_message B "$gid" "after removing C" 30; then info "B still receives messages (expected — B + A are the remaining members)" else fail_msg "B stopped receiving messages (unexpected)" record_result "06 member removal" fail "B lost access"; return fi # C should NOT be able to decrypt the new message — forward secrecy # at the post-remove epoch means C's retained keys cannot open any # kind:445 sealed under the new epoch's exporter. sleep 5 if wait_for_message C "$gid" "after removing C" 10; then fail_msg "C still decrypted a post-removal message — forward secrecy broken" record_result "06 member removal" fail "forward secrecy broken" else pass_msg "C correctly cannot read post-removal messages" record_result "06 member removal" pass fi } test_07_metadata_rename() { banner "Test 07 — Group metadata rename round-trip (MIP-01)" # Forward direction: A (admin of Interop-02) renames → B observes. local gid_a gid_a=$(load_state GROUP_02 || true) if [[ -z "${gid_a:-}" ]]; then skip_msg "no GROUP_02"; record_result "07a metadata rename A->B" skip "no group state" else prompt_human "In Amethyst, open group 'Interop-02' -> Group Info -> Edit. Rename to: Interop-02-renamed Save." step "polling B for name change (60s)" local deadline_a=$(( $(date +%s) + 60 )) name_a="" while [[ $(date +%s) -lt $deadline_a ]]; do name_a=$(wn_b --json groups show "$gid_a" 2>/dev/null | jq -r '(.result // .) | .name // empty') [[ "$name_a" == "Interop-02-renamed" ]] && break sleep 3 done if [[ "$name_a" == "Interop-02-renamed" ]]; then pass_msg "B sees renamed Interop-02" record_result "07a metadata rename A->B" pass else fail_msg "B still sees name: $name_a" record_result "07a metadata rename A->B" fail "rename did not propagate to B" fi fi # Reverse direction: B (admin of Interop-03) renames → A observes. # # The previous revision issued the reverse rename on Interop-02 via # `wn_b groups rename` — but B is a plain member of Interop-02, so # MIP-03's `enforceAuthorizedProposalSet` rejects the GCE commit on # the wn side and the rename never reaches any relay. Use the group # B actually owns (Interop-03) for the reverse leg instead. local gid_b gid_b=$(load_state GROUP_03 || true) if [[ -z "${gid_b:-}" ]]; then skip_msg "no GROUP_03 — skipping reverse rename" record_result "07b metadata rename B->A" skip "no GROUP_03" return fi step "B (admin) renames Interop-03 -> Interop-03-renamed" local rename_out if ! rename_out=$(wn_b groups rename "$gid_b" "Interop-03-renamed" 2>&1); then warn "wn_b groups rename returned nonzero: $rename_out" printf 'wn_b groups rename: %s\n' "$rename_out" >>"$LOG_FILE" fi if confirm "Does Amethyst now show the group previously named 'Interop-03' as 'Interop-03-renamed'?"; then record_result "07b metadata rename B->A" pass else record_result "07b metadata rename B->A" fail "Amethyst did not pick up rename" fi } test_08_admin_promote_demote() { banner "Test 08 — Admin promote/demote" local gid gid=$(load_state GROUP_03 || true) if [[ -z "${gid:-}" ]]; then skip_msg "no GROUP_03"; record_result "08 admin promote/demote" skip "no group"; return fi step "B (creator+admin) adds C so we have 3 members" wn_c keys publish >/dev/null 2>&1 || true sleep 3 local c_ignore_08 c_new_gid_08 c_ignore_08=$(snapshot_invites C) wn_b groups add-members "$gid" "$C_NPUB" >/dev/null 2>&1 || warn "add-members C returned nonzero" if c_new_gid_08=$(wait_for_invite C 30 "$c_ignore_08"); then wn_c groups accept "$c_new_gid_08" >/dev/null 2>&1 || true fi step "B promotes A to admin" if ! wn_b groups promote "$gid" "$A_NPUB" >/dev/null 2>&1; then fail_msg "promote failed" record_result "08 admin promote/demote" fail "promote returned nonzero"; return fi step "verifying admin list contains both B and A" local admins admins=$(wn_b --json groups admins "$gid" 2>/dev/null | jq -r '(.result // .) | .[].pubkey // .[].public_key // .[]' | tr '\n' ' ') if expect_contains "$admins" "$A_HEX" "admin list" && expect_contains "$admins" "$B_HEX" "admin list"; then pass_msg "admin promote worked" else record_result "08 admin promote/demote" fail "admin list missing A or B"; return fi step "B demotes A" wn_b groups demote "$gid" "$A_NPUB" >/dev/null 2>&1 || warn "demote returned nonzero" sleep 5 admins=$(wn_b --json groups admins "$gid" 2>/dev/null | jq -r '(.result // .) | .[].pubkey // .[].public_key // .[]' | tr '\n' ' ') if [[ "$admins" != *"$A_HEX"* ]]; then pass_msg "demote removed A" record_result "08 admin promote/demote" pass else record_result "08 admin promote/demote" fail "A still admin after demote" fi } test_09_reply_react_unreact() { banner "Test 09 — Reply / react / unreact (inner event kinds 9, 7)" local gid gid=$(load_state GROUP_02 || true) if [[ -z "${gid:-}" ]]; then skip_msg "no GROUP_02"; record_result "09 reply/react" skip "no group"; return fi step "B sends anchor message" wn_b messages send "$gid" "anchor for reactions" >/dev/null 2>&1 || true sleep 3 local msg_id msg_id=$(wn_b --json messages list "$gid" --limit 10 2>/dev/null \ | jq -r '(.result // .) | [.[]? | select((.content // .text // "") == "anchor for reactions")][0].id // .[0].event_id // empty' 2>/dev/null || true) if [[ -z "$msg_id" || "$msg_id" == "null" ]]; then fail_msg "could not find anchor message id" record_result "09 reply/react" fail "anchor id missing"; return fi info "anchor id: $msg_id" step "B reacts with 🌮 and then unreacts" wn_b messages react "$gid" "$msg_id" "🌮" >/dev/null 2>&1 || true sleep 3 if ! confirm "Does Amethyst show 🌮 reaction on 'anchor for reactions'?"; then record_result "09 reply/react" fail "reaction not visible in Amethyst"; return fi wn_b messages unreact "$gid" "$msg_id" >/dev/null 2>&1 || true sleep 3 if ! confirm "Did Amethyst remove the 🌮 reaction?"; then record_result "09 reply/react" fail "unreact not visible"; return fi step "B replies to the anchor" # clap v4 converts snake_case fields to kebab-case flags by default, so the # `reply_to: Option` field on `wn messages send` exposes as # `--reply-to`. Passing `--reply_to` is silently rejected (the script's # `|| true` hides the error) and the reply is never actually published. wn_b messages send "$gid" "replying via wn" --reply-to "$msg_id" >/dev/null 2>&1 || true sleep 3 if confirm "Does Amethyst show 'replying via wn' as a threaded reply to the anchor?"; then record_result "09 reply/react" pass else record_result "09 reply/react" fail "reply not threaded" fi } test_10_concurrent_commits() { banner "Test 10 — Concurrent commits (race)" local gid gid=$(load_state GROUP_02 || true) if [[ -z "${gid:-}" ]]; then skip_msg "no GROUP_02"; record_result "10 concurrent commits" skip "no group"; return fi prompt_human "Get ready to trigger two conflicting commits SIMULTANEOUSLY. In Amethyst: open group 'Interop-02' -> Group Info -> Edit name. Type a new name (e.g. 'race-from-amethyst') but DO NOT SAVE YET. Press here; the script will immediately issue a conflicting rename from B. Then within 1 second tap SAVE in Amethyst." ( sleep 0; wn_b groups rename "$gid" "race-from-wn" >/dev/null 2>&1 ) & local pid=$! info "issued wn rename (pid $pid). Tap SAVE in Amethyst NOW." wait "$pid" 2>/dev/null || true step "sleeping 10s for both sides to converge" sleep 10 step "verifying B's view is consistent (name + epoch)" local b_view # Post-v0.2 wn wraps `groups show` output in {"result": {...}}; peel # that wrapper before extracting the fields so the info log isn't # "name: null, epoch: null" on every run. b_view=$(wn_b --json groups show "$gid" 2>/dev/null | jq '(.result // .) | {name, epoch}' 2>/dev/null || echo "{}") info "B state: $b_view" prompt_human "Read the group name now shown in Amethyst." if confirm "Do Amethyst and wn (B view above) agree on the same group name?"; then step "post-race message exchange" wn_b messages send "$gid" "post-race ping" >/dev/null 2>&1 || true if confirm "Does Amethyst show 'post-race ping'? (proves encryption still works)"; then record_result "10 concurrent commits" pass else record_result "10 concurrent commits" fail "encryption broken after race" fi else record_result "10 concurrent commits" fail "diverged state after race" fi } test_11_leave_group() { banner "Test 11 — Leave group" local gid gid=$(load_state GROUP_02 || true) if [[ -z "${gid:-}" ]]; then skip_msg "no GROUP_02"; record_result "11 leave group" skip "no group"; return fi prompt_human "In Amethyst, open group 'Interop-02' -> Group Info -> Leave Group. Confirm the action." step "verifying B's member list no longer contains A (60s poll)" local deadline=$(( $(date +%s) + 60 )) gone=0 while [[ $(date +%s) -lt $deadline ]]; do if ! wn_b --json groups members "$gid" 2>/dev/null \ | jq -e --arg p "$A_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' >/dev/null 2>&1; then gone=1; break fi sleep 3 done if [[ "$gone" -eq 1 ]]; then pass_msg "A removed from B's view of the group" record_result "11 leave group" pass else record_result "11 leave group" fail "A still in member list after leave" fi } test_12_offline_catchup() { banner "Test 12 — Offline catch-up (replay multiple commits)" # Use a fresh group for cleanliness wn_c keys publish >/dev/null 2>&1 || true sleep 3 local out gid out=$(wn_b --json groups create "Interop-12" "$A_NPUB" 2>>"$LOG_FILE") printf 'wn groups create raw output: %s\n' "$out" >>"$LOG_FILE" gid=$(printf '%s' "$out" | jq_group_id) if [[ -z "$gid" ]]; then fail_msg "could not create Interop-12"; record_result "12 offline catchup" fail "create failed"; return fi save_state GROUP_12 "$gid" prompt_human "In Amethyst, accept the new invite 'Interop-12' and confirm you see the group." prompt_human "Now force-Amethyst offline: - Turn on Airplane Mode, OR - Force-stop the app from Android settings, OR - On emulator: adb shell svc wifi disable Press when Amethyst is offline." step "B sends 5 messages" for i in 1 2 3 4 5; do wn_b messages send "$gid" "offline-msg-$i" >/dev/null 2>&1 || true sleep 1 done step "B adds C, then sends 3 more messages" local c_ignore_12 c_new_gid_12 c_ignore_12=$(snapshot_invites C) wn_b groups add-members "$gid" "$C_NPUB" >/dev/null 2>&1 || true if c_new_gid_12=$(wait_for_invite C 30 "$c_ignore_12"); then wn_c groups accept "$c_new_gid_12" >/dev/null 2>&1 || true fi for i in 6 7 8; do wn_b messages send "$gid" "offline-msg-$i" >/dev/null 2>&1 || true sleep 1 done step "B renames the group" wn_b groups rename "$gid" "Interop-12-renamed" >/dev/null 2>&1 || true prompt_human "Now bring Amethyst back online and open group 'Interop-12-renamed'. Wait ~10 seconds for replay." if confirm "Does Amethyst show all 8 messages (offline-msg-1..8) AND the new name 'Interop-12-renamed' AND C as a member?"; then record_result "12 offline catchup" pass else record_result "12 offline catchup" fail "replay incomplete" fi } test_13_keypackage_rotation() { banner "Test 13 — KeyPackage rotation (MIP-00 consumption-driven)" # Per MIP-00, a KeyPackage is rotated when it's *consumed* by an incoming # Welcome — the init_key is effectively spent and the client must # publish a fresh KeyPackage to the same d-tag slot. # # Earlier revisions of this test asked the human to restart Amethyst or # toggle KP relays, on the assumption that either would republish a # fresh KP. Neither actually does in current Amethyst: # - Restart: `Account.ensureMarmotKeyPackagePublished` early-returns # whenever a bundle already exists on disk — no rotation. # - Toggle KP relays: `Account.saveKeyPackageRelayList` re-publishes # the existing kind:30443 to the new relay set, same event_id. # # So drive the real MIP-00 flow instead: have B create a one-off group # with A as invitee. The Welcome gift-wrap delivered to A consumes A's # KeyPackage, `DecryptAndIndexProcessor` sees `needsKeyPackageRotation` # on the ingest result, and calls `publishMarmotKeyPackages` which # rotates the consumed slot and publishes a new kind:30443 to the same # d-tag. B's `keys check` then returns a different event_id. # # Rotation fires on Welcome *receive* (decrypt), not on human accept, so # the operator's accept is nice-to-have for group hygiene but not on the # critical path for this test. step "B records A's current KP event id" local before before=$(wn_b --json keys check "$A_NPUB" 2>/dev/null | jq -r '.result.event_id // .event_id // empty') info "A KP before rotation: $before" if [[ -z "$before" ]]; then fail_msg "no existing KP for A"; record_result "13 keypackage rotation" fail "no KP found"; return fi step "B creates group 'Interop-13-rotation' with A as sole invitee (consumes A's KP)" local out gid out=$(wn_b --json groups create "Interop-13-rotation" "$A_NPUB" 2>>"$LOG_FILE") printf 'wn groups create (test_13) raw output: %s\n' "$out" >>"$LOG_FILE" gid=$(printf '%s' "$out" | jq_group_id) if [[ -z "$gid" ]]; then fail_msg "could not create Interop-13-rotation" record_result "13 keypackage rotation" fail "create failed" return fi save_state GROUP_13 "$gid" info "group_id: $gid" prompt_human "In Amethyst, you should see a new invite 'Interop-13-rotation' from $B_NPUB. Accepting is NOT required for rotation (processing the Welcome is enough), but please accept it to keep group state tidy. Press when done." step "waiting for A's rotated KP event_id at B (60s)" local deadline=$(( $(date +%s) + 60 )) after="" while [[ $(date +%s) -lt $deadline ]]; do after=$(wn_b --json keys check "$A_NPUB" 2>/dev/null | jq -r '.result.event_id // .event_id // empty') [[ -n "$after" && "$after" != "$before" ]] && break sleep 3 done if [[ -n "$after" && "$after" != "$before" ]]; then pass_msg "A's KP rotated after Welcome consumption: $before -> $after" record_result "13 keypackage rotation" pass else fail_msg "A's KP event_id unchanged ($before) 60s after Welcome — MIP-00 rotation did not fire" record_result "13 keypackage rotation" fail "no new KP event_id after invite" fi } test_14_push_notifications() { banner "Test 14 — Push notifications (MIP-05)" if [[ "$ENABLE_TRANSPONDER" -ne 1 ]]; then skip_msg "use --transponder to enable" record_result "14 push notifications" skip "not requested" return fi local gid gid=$(load_state GROUP_12 || load_state GROUP_02 || true) if [[ -z "${gid:-}" ]]; then skip_msg "no group state available"; record_result "14 push notifications" skip "no group"; return fi step "B subscribes to notifications" wn_b notifications subscribe "$gid" >/dev/null 2>&1 || warn "notifications subscribe returned nonzero" prompt_human "In Amethyst, send: 'ping for push'" sleep 5 if confirm "Did B's transponder instance receive a push notification log entry (check $B_DIR/logs/stderr.log)?"; then record_result "14 push notifications" pass else record_result "14 push notifications" fail "no push observed" fi } # ==== main =================================================================== main() { # Make sure Ctrl+C / SIGTERM / SIGHUP all run the full cleanup path — # otherwise wnd is nohup'd and keeps running after the script dies. # Trap INT/TERM/HUP forces `exit`, which triggers the EXIT handler once. cleanup() { local rc=$? trap - EXIT INT TERM HUP stop_daemons print_summary exit "$rc" } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM trap 'exit 129' HUP banner "Amethyst <-> whitenoise-rs interop harness ($RUN_TS)" preflight start_daemon B "$B_DIR" "$B_SOCKET" start_daemon C "$C_DIR" "$C_SOCKET" ensure_identity B ensure_identity C prompt_for_a_npub configure_relays # Baseline daemon dumps are no longer run unconditionally — they produce # 100+ lines per daemon and the happy path doesn't need them. Each test # that polls for an invite still calls `dump_daemon_diagnostics` on # timeout, which is the failure mode those dumps are meant to diagnose. # Let wn discover A's advertised relays via its normal subscription # plane, then summarise what wn sees. Surfaces up front the kind of # failure (A's 10050 unreachable from wn, missing KP list, etc.) that # would otherwise bite as a silent Test 03 timeout. if [[ "$USE_LOCAL_RELAYS" -ne 1 ]]; then discover_a_relays fi instruct_amethyst_setup test_01_keypackage_discovery test_02_amethyst_creates_group test_03_wn_creates_group test_04_three_member_group test_05_wn_adds_amethyst_existing test_06_member_removal test_07_metadata_rename test_08_admin_promote_demote test_09_reply_react_unreact test_10_concurrent_commits test_11_leave_group test_12_offline_catchup test_13_keypackage_rotation test_14_push_notifications } main "$@"