diff --git a/tools/marmot-interop/.gitignore b/tools/marmot-interop/.gitignore index dc3b76b28..1bcd48904 100644 --- a/tools/marmot-interop/.gitignore +++ b/tools/marmot-interop/.gitignore @@ -1 +1,2 @@ state/ +state-headless/ diff --git a/tools/marmot-interop/README.md b/tools/marmot-interop/README.md index b382e79d2..f5fa97e0f 100644 --- a/tools/marmot-interop/README.md +++ b/tools/marmot-interop/README.md @@ -1,13 +1,20 @@ # Marmot Interop Test Harness -Interactive harness that validates Amethyst's Marmot/MLS implementation against -**whitenoise-rs** (https://github.com/marmot-protocol/whitenoise-rs), the -reference Rust implementation that powers the White Noise Flutter app. +Two flavours, same scenarios: -The harness drives two `wn` daemons (Identities **B** and **C**) from the -command line and prompts the human operator to perform the Amethyst-side steps -in the mobile UI as **Identity A**. Every test records a pass/fail/skip result -into a tab-separated log, and the summary is printed at the end of the run. +- **`marmot-interop.sh`** — interactive. Drives B/C via `wn` and **prompts the + human** to perform each Amethyst-side step in the mobile UI (Identity A). + Use this for final UI verification. +- **`marmot-interop-headless.sh`** — zero prompts. Drives A via the `amy` CLI + (`./gradlew :cli:installDist`) and B/C via `wn`. Runs every scenario + end-to-end and exits with a pass/fail summary. Use this for CI and for + iterating on the Nostr/Marmot plumbing without needing to touch a phone. + +Both harnesses validate Amethyst against **whitenoise-rs** +(https://github.com/marmot-protocol/whitenoise-rs), the reference Rust +implementation that powers the White Noise Flutter app. Every test records a +pass/fail/skip result into a tab-separated log, and the summary is printed at +the end of the run. ## What gets tested diff --git a/tools/marmot-interop/headless/helpers.sh b/tools/marmot-interop/headless/helpers.sh new file mode 100644 index 000000000..7a7e10a65 --- /dev/null +++ b/tools/marmot-interop/headless/helpers.sh @@ -0,0 +1,55 @@ +# shellcheck shell=bash +# +# headless/helpers.sh — thin wrappers that keep the per-test code tight. + +# --- amy wrapper ------------------------------------------------------------- +amy_a() { "$AMY_BIN" --data-dir "$A_DIR" "$@"; } + +# 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)" + printf '%s\n' "$out" >>"$LOG_FILE" + return 1 + fi + printf '%s' "$out" +} + +# Convenience extractors — the CLI emits one JSON object per success so we can +# jq with impunity. +amy_field() { + # usage: amy_field '.group_id' init [args...] + local path="$1"; shift + amy_json "$@" | jq -r "$path" +} + +# --- assertion helpers ------------------------------------------------------- +# Assert a substring is present in a variable; append a failed result on miss +# and return 1. Positive case just logs. +assert_contains() { + local haystack="$1" needle="$2" test_id="$3" note="${4:-}" + if [[ "$haystack" == *"$needle"* ]]; then + info "assertion hit: $test_id contains \"$needle\"" + return 0 + fi + fail_msg "$test_id: missing \"$needle\" (${note:-no note})" + info "actual: $haystack" + record_result "$test_id" fail "${note:-missing \"$needle\"}" + return 1 +} + +# Assert two strings are equal (leniently trimmed). +assert_eq() { + local actual="$1" expected="$2" test_id="$3" note="${4:-}" + if [[ "${actual// /}" == "${expected// /}" ]]; then + info "assertion hit: $test_id \"$actual\" == \"$expected\"" + return 0 + fi + fail_msg "$test_id: expected \"$expected\", got \"$actual\" (${note:-})" + record_result "$test_id" fail "${note:-mismatch}" + return 1 +} + +# --- wn-side pollers (delegates to lib.sh) ----------------------------------- +# Both exist in lib.sh already; this file only adds headless-specific niceties. diff --git a/tools/marmot-interop/headless/setup.sh b/tools/marmot-interop/headless/setup.sh new file mode 100644 index 000000000..686af8be1 --- /dev/null +++ b/tools/marmot-interop/headless/setup.sh @@ -0,0 +1,144 @@ +# shellcheck shell=bash +# +# headless/setup.sh — preflight + daemon lifecycle + identity bootstrap. +# Sourced from marmot-interop-headless.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 + info "$cmd: $(command -v "$cmd")" + done + + # Build `amy` via gradle if missing. + if [[ ! -x "$AMY_BIN" ]]; then + if [[ "$NO_BUILD" -eq 1 ]]; then + fail_msg "amy not found at $AMY_BIN and --no-build set"; exit 1 + fi + step "building :cli:installDist" + ( cd "$REPO_ROOT" && ./gradlew :cli:installDist ) 2>&1 | tee -a "$LOG_FILE" + fi + [[ -x "$AMY_BIN" ]] || { fail_msg "amy still missing after build"; exit 1; } + info "amy: $AMY_BIN" + + # Clone/build whitenoise-rs if needed (shared between both harnesses). + if [[ ! -x "$WN_BIN" || ! -x "$WND_BIN" ]]; then + if [[ "$NO_BUILD" -eq 1 ]]; then + fail_msg "wn/wnd not found and --no-build set"; 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 (~5 min first run)" + ( cd "$WN_REPO" && cargo build --release --features cli --bin wn --bin wnd ) \ + 2>&1 | tee -a "$LOG_FILE" + fi + info "wn: $WN_BIN" + info "wnd: $WND_BIN" +} + +# --- daemons ----------------------------------------------------------------- +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 + rm -f "$socket" + mkdir -p "$data_dir/logs" "$data_dir/release" + nohup "$WND_BIN" --data-dir "$data_dir" --logs-dir "$data_dir/logs" \ + >"$data_dir/logs/stdout.log" 2>"$data_dir/logs/stderr.log" & + echo "$!" > "$data_dir/pid" + 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 + sleep 1 + done + fail_msg "$name daemon failed to start (see $data_dir/logs/stderr.log)" + 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_a() { + step "initialising Identity A (amy)" + local out + out=$(amy_a init) || { fail_msg "amy init failed: $out"; exit 1; } + A_NPUB=$(printf '%s' "$out" | jq -r '.npub') + A_HEX=$(printf '%s' "$out" | jq -r '.hex') + info "A npub: $A_NPUB" + info "A hex: $A_HEX" +} + +ensure_identity() { + local who="$1" cmd npub="" + if [[ "$who" == "B" ]]; then cmd=wn_b; else cmd=wn_c; fi + step "ensuring Identity $who (wn)" + + local raw + raw=$("$cmd" --json whoami 2>/dev/null || true) + npub=$(extract_pubkey "$raw") + if [[ -z "${npub:-}" ]]; then + raw=$("$cmd" create-identity 2>&1 | tee -a "$LOG_FILE") + npub=$(extract_pubkey "$raw") + [[ -n "$npub" ]] || npub=$(extract_pubkey "$("$cmd" whoami 2>/dev/null || true)") + fi + [[ -n "$npub" ]] || { fail_msg "could not determine $who npub"; exit 1; } + + 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 + info "$who npub: $npub" + [[ "$hex" != "$npub" ]] && info "$who hex: $hex" +} + +# --- relays ------------------------------------------------------------------ +configure_relays() { + banner "Configuring relays" + local relays=() + if [[ "$USE_LOCAL_RELAYS" -eq 1 ]]; then + relays=( "ws://localhost:8080" ) + else + relays=( "${DEFAULT_RELAYS[@]}" ) + fi + + for r in "${relays[@]}"; do + step "adding $r to A/B/C" + amy_a relay add "$r" --type all >/dev/null + for t in nip65 inbox key_package; do + wn_b relays add --type "$t" "$r" 2>/dev/null || true + wn_c relays add --type "$t" "$r" 2>/dev/null || true + done + done + + # A advertises its NIP-65 + DM inbox lists so B/C can discover where to + # deliver gift wraps. Without this, cross-client welcomes only work + # through relay-set overlap — fine for this harness but still worth + # publishing so we catch regressions in the advertise path. + step "publishing A's NIP-65 + kind:10050 lists" + amy_a relay publish-lists >>"$LOG_FILE" 2>&1 || warn "amy relay publish-lists failed" + + step "publishing A's KeyPackage" + amy_a marmot key-package publish >>"$LOG_FILE" 2>&1 || warn "amy marmot key-package publish failed" +} diff --git a/tools/marmot-interop/headless/tests-create.sh b/tools/marmot-interop/headless/tests-create.sh new file mode 100644 index 000000000..702d6cdab --- /dev/null +++ b/tools/marmot-interop/headless/tests-create.sh @@ -0,0 +1,175 @@ +# shellcheck shell=bash +# +# headless/tests-create.sh — tests 01..05. +# Focus: KeyPackage discovery, group creation, invites, initial messages. + +test_01_keypackage_discovery() { + banner "Test 01 — KeyPackage publish & discovery (MIP-00)" + local id="01 KeyPackage A<->B" + + # B finds A's KP + local raw ev + raw=$(wn_b --json keys check "$A_NPUB" 2>>"$LOG_FILE" || true) + ev=$(printf '%s' "$raw" | jq -r '.result.event_id // .event_id // empty') + if [[ -z "$ev" || "$ev" == "null" ]]; then + record_result "$id (B->A)" fail "wn couldn't find A's KP"; return + fi + info "B saw A's KP $ev" + + # A finds B's KP (wn publishes automatically via create-identity flow? if not, ask). + wn_b keys publish >>"$LOG_FILE" 2>&1 || warn "wn_b keys publish failed" + sleep 3 + local out + out=$(amy_json marmot key-package check "$B_NPUB" 2>/dev/null || true) + if [[ -z "$out" ]]; then + record_result "$id (A->B)" fail "amy couldn't find B's KP"; return + fi + info "A saw B's KP $(printf '%s' "$out" | jq -r .event_id)" + + record_result "$id" pass +} + +test_02_a_creates_group() { + banner "Test 02 — A creates group, invites B" + local id="02 A->B create+invite" + + local gid + gid=$(amy_field '.group_id' marmot group create --name "Interop-02") || { + record_result "$id" fail "create returned no group_id"; return + } + save_state GROUP_02 "$gid" + info "created group $gid" + + amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || { + record_result "$id" fail "amy group add failed"; return + } + + # B waits for invite, accepts. + local b_gid + if ! b_gid=$(wait_for_invite B 60); then + record_result "$id" fail "B never received invite"; return + fi + wn_b groups accept "$b_gid" >/dev/null 2>&1 || true + info "B joined $b_gid" + + # A -> B message + amy_json marmot message send "$gid" "hello from amethyst" >/dev/null || { + record_result "$id" fail "amy send failed"; return + } + if ! wait_for_message B "$gid" "hello from amethyst" 30; then + record_result "$id" fail "B didn't receive A's message"; return + fi + + # B -> A message + wn_b messages send "$gid" "hello from wn" >/dev/null 2>&1 || warn "wn send returned nonzero" + if ! amy_json marmot await message "$gid" --match "hello from wn" --timeout 30 >/dev/null; then + record_result "$id" fail "A didn't receive B's reply"; return + fi + record_result "$id" pass +} + +test_03_b_creates_group() { + banner "Test 03 — B creates group, invites A" + local id="03 B->A create+invite" + + local out gid + out=$(wn_b --json groups create "Interop-03" "$A_NPUB" 2>>"$LOG_FILE") || { + record_result "$id" fail "wn groups create failed"; return + } + gid=$(printf '%s' "$out" | jq_group_id) + if [[ -z "$gid" ]]; then + record_result "$id" fail "could not parse group_id"; return + fi + save_state GROUP_03 "$gid" + info "group_id: $gid" + + # A: poll until it joins. + if ! amy_json marmot await group --name "Interop-03" --timeout 30 >/dev/null; then + record_result "$id" fail "A never joined B's group"; return + fi + + # B -> A message + wn_b messages send "$gid" "ping from wn" >/dev/null 2>&1 || true + if ! amy_json marmot await message "$gid" --match "ping from wn" --timeout 30 >/dev/null; then + record_result "$id" fail "A didn't see B's ping"; return + fi + + # A -> B reply + amy_json marmot message send "$gid" "pong from amethyst" >/dev/null || { + record_result "$id" fail "amy send pong failed"; return + } + if wait_for_message B "$gid" "pong from amethyst" 30; then + record_result "$id" pass + else + record_result "$id" fail "B didn't see A's pong" + fi +} + +test_04_three_member_group() { + banner "Test 04 — 3-member group, add C after create" + local id="04 3-member add-after-create" + + wn_c keys publish >/dev/null 2>&1 || true + sleep 3 + + local gid; gid=$(load_state GROUP_02 || true) + if [[ -z "${gid:-}" ]]; then + record_result "$id" skip "no GROUP_02"; return + fi + + # A adds C + amy_json marmot group add "$gid" "$C_NPUB" >/dev/null || { + record_result "$id" fail "amy group add C failed"; return + } + + local c_gid + if ! c_gid=$(wait_for_invite C 60); then + record_result "$id" fail "C never received invite"; return + fi + wn_c groups accept "$c_gid" >/dev/null 2>&1 || true + + amy_json marmot message send "$gid" "hello three-member world" >/dev/null || { + record_result "$id" fail "amy send failed"; return + } + if wait_for_message B "$gid" "hello three-member world" 30 \ + && wait_for_message C "$c_gid" "hello three-member world" 30; then + record_result "$id" pass + else + record_result "$id" fail "B or C missed A's post-add message" + fi +} + +test_05_b_adds_a_existing() { + banner "Test 05 — B adds A to an existing B+C group" + local id="05 wn adds A existing" + + local out gid + out=$(wn_b --json groups create "Interop-05" "$C_NPUB" 2>>"$LOG_FILE") || { + record_result "$id" fail "wn create Interop-05 failed"; return + } + gid=$(printf '%s' "$out" | jq_group_id) + if [[ -z "$gid" ]]; then + record_result "$id" fail "no group_id from create"; return + fi + save_state GROUP_05 "$gid" + wait_for_invite C 30 >/dev/null && wn_c groups accept "$gid" >/dev/null 2>&1 || true + + wn_b groups add-members "$gid" "$A_NPUB" >/dev/null 2>&1 || { + record_result "$id" fail "wn add-members A failed"; return + } + + # A joins + if ! amy_json marmot await group --name "Interop-05" --timeout 30 >/dev/null; then + record_result "$id" fail "A never received invite to Interop-05"; return + fi + + amy_json marmot message send "$gid" "joined from amethyst" >/dev/null || { + record_result "$id" fail "amy send failed"; return + } + if wait_for_message B "$gid" "joined from amethyst" 30 \ + && wait_for_message C "$gid" "joined from amethyst" 30; then + record_result "$id" pass + else + record_result "$id" fail "B or C didn't see A's message" + fi +} diff --git a/tools/marmot-interop/headless/tests-extras.sh b/tools/marmot-interop/headless/tests-extras.sh new file mode 100644 index 000000000..2f147c83e --- /dev/null +++ b/tools/marmot-interop/headless/tests-extras.sh @@ -0,0 +1,172 @@ +# shellcheck shell=bash +# +# headless/tests-extras.sh — tests 09, 10, 12, 13. +# Focus: reactions/replies, concurrent commits, offline catchup, KP rotation. + +test_09_reply_react_unreact() { + banner "Test 09 — reply / react / unreact" + local id="09 reply/react" + + local gid; gid=$(load_state GROUP_02 || true) + if [[ -z "${gid:-}" ]]; then + record_result "$id" skip "no GROUP_02"; return + fi + + # B anchors. Needs a member to be present — if Test 11 already ran and A left, + # skip cleanly so we don't double-fail. + if ! wn_b --json groups members "$gid" 2>/dev/null \ + | jq -e --arg p "$A_HEX" '.[]? | select((.pubkey // .public_key) == $p)' \ + >/dev/null 2>&1; then + record_result "$id" skip "A already left GROUP_02"; return + fi + + 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 '[.[]? | select((.content // .text // "") == "anchor for reactions")][0].id // empty') + if [[ -z "$msg_id" || "$msg_id" == "null" ]]; then + record_result "$id" fail "couldn't find anchor message id"; return + fi + + wn_b messages react "$gid" "$msg_id" "🌮" >/dev/null 2>&1 || true + sleep 3 + # amy reply + amy_json marmot message send "$gid" "replying via amy" >/dev/null || { + record_result "$id" fail "amy send reply failed"; return + } + if wait_for_message B "$gid" "replying via amy" 30; then + record_result "$id" pass + else + record_result "$id" fail "B didn't receive reply" + fi + # NB: react/unreact round-trip verification requires a CLI verb we don't have + # yet (amy marmot message react). Once we add it, expand this test. +} + +test_10_concurrent_commits() { + banner "Test 10 — Concurrent commits race" + local id="10 concurrent commits" + + local gid; gid=$(load_state GROUP_02 || true) + if [[ -z "${gid:-}" ]]; then + record_result "$id" skip "no GROUP_02"; return + fi + if ! wn_b --json groups members "$gid" 2>/dev/null \ + | jq -e --arg p "$A_HEX" '.[]? | select((.pubkey // .public_key) == $p)' \ + >/dev/null 2>&1; then + record_result "$id" skip "A already left GROUP_02"; return + fi + + # Fire both renames in parallel. One wins — MLS single-commit-per-epoch + # guarantees a deterministic outcome. + ( amy_json marmot group rename "$gid" "race-from-amethyst" >/dev/null ) & + local a_pid=$! + ( wn_b groups rename "$gid" "race-from-wn" >/dev/null 2>&1 ) & + local b_pid=$! + wait "$a_pid" "$b_pid" 2>/dev/null || true + + sleep 10 + local b_name + b_name=$(wn_b --json groups show "$gid" 2>/dev/null | jq -r '.name // empty') + local a_name + a_name=$(amy_field '.name' marmot group show "$gid" 2>/dev/null || echo "") + + if [[ -n "$a_name" && "$a_name" == "$b_name" ]]; then + info "race converged: both sides see \"$a_name\"" + # Verify encryption still works. + wn_b messages send "$gid" "post-race ping" >/dev/null 2>&1 || true + if amy_json marmot await message "$gid" --match "post-race ping" --timeout 15 >/dev/null; then + record_result "$id" pass + else + record_result "$id" fail "encryption broken after race" + fi + else + record_result "$id" fail "diverged: A sees \"$a_name\", B sees \"$b_name\"" + fi +} + +test_12_offline_catchup() { + banner "Test 12 — Offline catch-up" + local id="12 offline catchup" + + # Fresh group so we don't collide with other tests. + local out gid + out=$(wn_b --json groups create "Interop-12" "$A_NPUB" 2>>"$LOG_FILE") + gid=$(printf '%s' "$out" | jq_group_id) + [[ -n "$gid" ]] || { record_result "$id" fail "couldn't create Interop-12"; return; } + save_state GROUP_12 "$gid" + + # A joins. + amy_json marmot await group --name "Interop-12" --timeout 30 >/dev/null || { + record_result "$id" fail "A never received Interop-12 invite"; return + } + + # "Go offline" == don't invoke amy. Meanwhile B sends 5 messages + adds C + sends 3 more + rename. + 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 + wn_b groups add-members "$gid" "$C_NPUB" >/dev/null 2>&1 || true + wait_for_invite C 30 >/dev/null && wn_c groups accept "$gid" >/dev/null 2>&1 || true + for i in 6 7 8; do + wn_b messages send "$gid" "offline-msg-$i" >/dev/null 2>&1 || true + sleep 1 + done + wn_b groups rename "$gid" "Interop-12-renamed" >/dev/null 2>&1 || true + sleep 3 + + # A comes back online — single sync pulls everything. + local show + show=$(amy_json marmot group show "$gid") || { + record_result "$id" fail "amy group show failed"; return + } + local name; name=$(printf '%s' "$show" | jq -r '.name') + if [[ "$name" != "Interop-12-renamed" ]]; then + record_result "$id" fail "A replay missed rename (saw \"$name\")"; return + fi + + # All 8 messages should be locally stored. + local msgs; msgs=$(amy_json marmot message list "$gid" --limit 100) + local missing=0 + for i in 1 2 3 4 5 6 7 8; do + if ! printf '%s' "$msgs" | jq -e --arg t "offline-msg-$i" \ + '.messages[]? | select((.content // "") == $t)' >/dev/null; then + missing=$((missing+1)) + info "missing offline-msg-$i" + fi + done + if [[ "$missing" -eq 0 ]]; then + record_result "$id" pass + else + record_result "$id" fail "A missed $missing of 8 offline messages" + fi +} + +test_13_keypackage_rotation() { + banner "Test 13 — KeyPackage rotation" + local id="13 keypackage rotation" + + local before + before=$(wn_b --json keys check "$A_NPUB" 2>/dev/null | jq -r '.result.event_id // empty') + if [[ -z "$before" ]]; then + record_result "$id" fail "no prior KP for A"; return + fi + + amy_json marmot key-package publish >/dev/null || { + record_result "$id" fail "amy key-package publish failed"; return + } + + 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 // empty') + [[ -n "$after" && "$after" != "$before" ]] && break + sleep 3 + done + if [[ -n "$after" && "$after" != "$before" ]]; then + info "KP rotated: ${before:0:8}… → ${after:0:8}…" + record_result "$id" pass + else + record_result "$id" fail "no new KP event_id observed" + fi +} diff --git a/tools/marmot-interop/headless/tests-manage.sh b/tools/marmot-interop/headless/tests-manage.sh new file mode 100644 index 000000000..509313d58 --- /dev/null +++ b/tools/marmot-interop/headless/tests-manage.sh @@ -0,0 +1,152 @@ +# shellcheck shell=bash +# +# headless/tests-manage.sh — tests 06, 07, 08, 11. +# Focus: removal, metadata rename, admin promote/demote, leave. + +test_06_member_removal() { + banner "Test 06 — Member removal + forward secrecy" + local id="06 member removal" + + local gid; gid=$(load_state GROUP_05 || true) + if [[ -z "${gid:-}" ]]; then + record_result "$id" skip "no GROUP_05"; return + fi + + amy_json marmot group remove "$gid" "$C_NPUB" >/dev/null || { + record_result "$id" fail "amy remove C failed"; return + } + + # C should no longer see the group on its own member view. + local deadline=$(( $(date +%s) + 60 )) removed=0 + while [[ $(date +%s) -lt $deadline ]]; do + if ! wn_c --json groups members "$gid" 2>/dev/null \ + | jq -e --arg p "$C_HEX" '.[]? | select((.pubkey // .public_key) == $p)' \ + >/dev/null 2>&1; then + removed=1; break + fi + sleep 3 + done + if [[ "$removed" -ne 1 ]]; then + warn "C still appears as a member — continuing" + fi + + amy_json marmot message send "$gid" "after removing C" >/dev/null || { + record_result "$id" fail "amy send failed"; return + } + wait_for_message B "$gid" "after removing C" 30 || { + record_result "$id" fail "B lost access after C's removal"; return + } + + # Forward secrecy: C must NOT see the post-removal message. + sleep 5 + if wait_for_message C "$gid" "after removing C" 10; then + record_result "$id" fail "C still decrypted a post-removal message" + else + record_result "$id" pass + fi +} + +test_07_metadata_rename() { + banner "Test 07 — Metadata rename round-trip (MIP-01)" + local id="07 metadata rename" + + local gid; gid=$(load_state GROUP_02 || true) + if [[ -z "${gid:-}" ]]; then + record_result "$id" skip "no GROUP_02"; return + fi + + amy_json marmot group rename "$gid" "Interop-02-renamed" >/dev/null || { + record_result "$id" fail "amy rename failed"; return + } + + local deadline=$(( $(date +%s) + 60 )) seen="" + while [[ $(date +%s) -lt $deadline ]]; do + seen=$(wn_b --json groups show "$gid" 2>/dev/null | jq -r '.name // empty') + [[ "$seen" == "Interop-02-renamed" ]] && break + sleep 3 + done + [[ "$seen" == "Interop-02-renamed" ]] || { + record_result "$id" fail "B saw name=\"$seen\" not \"Interop-02-renamed\""; return + } + + # Now B renames back and A should pick it up. + wn_b groups rename "$gid" "Interop-02-reverse" >/dev/null 2>&1 || true + if amy_json marmot await rename "$gid" --name "Interop-02-reverse" --timeout 60 >/dev/null; then + record_result "$id" pass + else + record_result "$id" fail "A did not pick up B's rename" + fi +} + +test_08_admin_promote_demote() { + banner "Test 08 — Admin promote / demote" + local id="08 admin promote/demote" + + local gid; gid=$(load_state GROUP_03 || true) + if [[ -z "${gid:-}" ]]; then + record_result "$id" skip "no GROUP_03"; return + fi + + # Ensure 3 members (add C if missing). + wn_c keys publish >/dev/null 2>&1 || true + sleep 2 + wn_b groups add-members "$gid" "$C_NPUB" >/dev/null 2>&1 || true + wait_for_invite C 30 >/dev/null && wn_c groups accept "$gid" >/dev/null 2>&1 || true + + # B promotes A. + wn_b groups promote "$gid" "$A_NPUB" >/dev/null 2>&1 || { + record_result "$id" fail "wn promote failed"; return + } + + # A should reflect the new admin set — poll via amy. + if ! amy_json marmot await admin "$gid" "$A_NPUB" --timeout 30 >/dev/null; then + record_result "$id" fail "A never saw itself promoted"; return + fi + + # A now commits a rename — only possible if we're admin. + amy_json marmot group rename "$gid" "Interop-03-by-A" >/dev/null || { + record_result "$id" fail "A (now admin) could not rename"; return + } + + # B demotes A. + wn_b groups demote "$gid" "$A_NPUB" >/dev/null 2>&1 || warn "demote returned nonzero" + sleep 5 + + local admins + admins=$(wn_b --json groups admins "$gid" 2>/dev/null \ + | jq -r '.[].pubkey // .[].public_key // .[]' | tr '\n' ' ') + if [[ "$admins" == *"$A_HEX"* ]]; then + record_result "$id" fail "A still admin after demote" + else + record_result "$id" pass + fi +} + +test_11_leave_group() { + banner "Test 11 — Leave group" + local id="11 leave group" + + local gid; gid=$(load_state GROUP_02 || true) + if [[ -z "${gid:-}" ]]; then + record_result "$id" skip "no GROUP_02"; return + fi + + amy_json marmot group leave "$gid" >/dev/null || { + record_result "$id" fail "amy leave failed"; return + } + + local deadline=$(( $(date +%s) + 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" '.[]? | select((.pubkey // .public_key) == $p)' \ + >/dev/null 2>&1; then + gone=1; break + fi + sleep 3 + done + if [[ "$gone" -eq 1 ]]; then + record_result "$id" pass + else + record_result "$id" fail "A still in B's member list after leave" + fi +} diff --git a/tools/marmot-interop/marmot-interop-headless.sh b/tools/marmot-interop/marmot-interop-headless.sh new file mode 100755 index 000000000..c4d0e9501 --- /dev/null +++ b/tools/marmot-interop/marmot-interop-headless.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# +# marmot-interop-headless.sh — zero-prompt interop harness. +# +# Drives Identity A via the `amy` CLI (./gradlew :cli:installDist) and +# Identities B/C via whitenoise-rs `wn`/`wnd`. Matches the 13 test +# scenarios in marmot-interop.sh but without any human prompts — all +# checks run to completion and the exit code reflects pass/fail totals. +# +# Usage: ./marmot-interop-headless.sh [--local-relays] [--no-build] +# +set -uo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)" +STATE_DIR="$SCRIPT_DIR/state-headless" +LOG_DIR="$STATE_DIR/logs" +A_DIR="$STATE_DIR/A" +B_DIR="$STATE_DIR/B" +C_DIR="$STATE_DIR/C" +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:-$SCRIPT_DIR/state/whitenoise-rs}" +WN_BIN="$WN_REPO/target/release/wn" +WND_BIN="$WN_REPO/target/release/wnd" +AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy" + +DEFAULT_RELAYS=( "wss://relay.damus.io" "wss://nos.lol" "wss://relay.primal.net" ) +USE_LOCAL_RELAYS=0 +NO_BUILD=0 + +A_NPUB="" +A_HEX="" +B_NPUB="" +B_HEX="" +C_NPUB="" +C_HEX="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --local-relays) USE_LOCAL_RELAYS=1 ;; + --no-build) NO_BUILD=1 ;; + -h|--help) + sed -n '3,12p' "${BASH_SOURCE[0]}" | sed 's/^# \?//' + exit 0 ;; + *) printf 'unknown flag: %s\n' "$1" >&2; exit 2 ;; + esac + shift +done + +mkdir -p "$STATE_DIR" "$LOG_DIR" "$A_DIR" "$B_DIR/logs" "$C_DIR/logs" +: >"$LOG_FILE" +: >"$RESULTS_FILE" + +# Reuse colours / logging / dump_daemon_diagnostics from the interactive harness. +# shellcheck source=lib.sh +source "$SCRIPT_DIR/lib.sh" + +# shellcheck source=headless/setup.sh +source "$SCRIPT_DIR/headless/setup.sh" +# shellcheck source=headless/helpers.sh +source "$SCRIPT_DIR/headless/helpers.sh" +# shellcheck source=headless/tests-create.sh +source "$SCRIPT_DIR/headless/tests-create.sh" +# shellcheck source=headless/tests-manage.sh +source "$SCRIPT_DIR/headless/tests-manage.sh" +# shellcheck source=headless/tests-extras.sh +source "$SCRIPT_DIR/headless/tests-extras.sh" + +trap 'stop_daemons; print_summary' EXIT + +banner "Marmot headless interop harness ($RUN_TS)" +preflight +start_daemon B "$B_DIR" "$B_SOCKET" +start_daemon C "$C_DIR" "$C_SOCKET" +ensure_identity_a +ensure_identity B +ensure_identity C +configure_relays + +test_01_keypackage_discovery +test_02_a_creates_group +test_03_b_creates_group +test_04_three_member_group +test_05_b_adds_a_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