test: add Marmot interop harness driving whitenoise-rs CLI
Interactive bash script under tools/marmot-interop/ that validates Amethyst's Marmot/MLS implementation against the whitenoise-rs wn/wnd CLI. Builds wn/wnd from source on first run, launches two daemons for Identities B and C, configures public (or --local-relays) Nostr relays shared with Amethyst, then walks a human operator through 13 scenarios covering MIP-00 KeyPackages, MIP-01 metadata, MIP-02 Welcome, MIP-03 group messages, admin changes, reactions, concurrent-commit race, leave, offline catch-up, and KeyPackage rotation. Push-notification (MIP-05) test opt-in via --transponder. No Amethyst code is modified — black-box testing only. State (daemon sockets, logs, whitenoise-rs checkout, results TSVs) is kept under tools/marmot-interop/state/ and gitignored. https://claude.ai/code/session_01PacmbE1KKWMSRmDw3bnCNo
This commit is contained in:
@@ -0,0 +1 @@
|
||||
state/
|
||||
@@ -0,0 +1,184 @@
|
||||
# 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.
|
||||
|
||||
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.
|
||||
|
||||
## What gets tested
|
||||
|
||||
| # | Test | Needs 3rd identity |
|
||||
|---|---|---|
|
||||
| 01 | KeyPackage publish & discovery (MIP-00) | – |
|
||||
| 02 | Amethyst creates group, invites wn | – |
|
||||
| 03 | wn creates group, invites Amethyst | – |
|
||||
| 04 | 3-member group, add-after-create | yes |
|
||||
| 05 | wn adds Amethyst to existing group | yes |
|
||||
| 06 | Member removal + forward secrecy | yes |
|
||||
| 07 | Group metadata rename round-trip (MIP-01) | – |
|
||||
| 08 | Admin promote / demote | yes |
|
||||
| 09 | Reply / react / unreact (inner event kinds 9, 7) | – |
|
||||
| 10 | Concurrent commits race | – |
|
||||
| 11 | Leave group | – |
|
||||
| 12 | Offline catch-up / replay | – |
|
||||
| 13 | KeyPackage rotation | – |
|
||||
| 14 | Push notifications (MIP-05) | opt-in via `--transponder` |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
On the machine that runs the harness:
|
||||
|
||||
- **Rust 1.90+** — install via https://rustup.rs
|
||||
- **git**, **curl**, **jq** — package manager
|
||||
- **~5 GB disk** for the first-run build of `wn` + `wnd`
|
||||
- Public internet access (for the default relay set and fetching crates)
|
||||
|
||||
On the Android side:
|
||||
|
||||
- Amethyst installed on an **emulator** or a **physical device**
|
||||
- The device must reach the same relays the harness uses (see below)
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd tools/marmot-interop
|
||||
./marmot-interop.sh
|
||||
```
|
||||
|
||||
The script will, in order:
|
||||
|
||||
1. Verify `jq`, `git`, `cargo` etc. are present.
|
||||
2. Clone `whitenoise-rs` into `state/whitenoise-rs/` and build `wn`/`wnd`
|
||||
(release, `--features cli`). First build takes ~5 minutes; subsequent runs
|
||||
reuse the binaries.
|
||||
3. Launch two `wnd` daemons (one for Identity B, one for Identity C).
|
||||
4. Create Nostr identities for B and C, persist their npubs in `state/run.env`.
|
||||
5. Ask you to paste **your Amethyst account npub** (Identity A). This is
|
||||
cached for subsequent runs.
|
||||
6. Add the default public relays to both daemons and run a sanity check
|
||||
(publish a KP from B, fetch it from C).
|
||||
7. Print an **Amethyst setup checklist** — add the same relays to Amethyst,
|
||||
publish a KP, verify you are logged in with A.
|
||||
8. Run all 13 tests sequentially. Each test either:
|
||||
- runs `wn` commands fully automatically and asserts on JSON output, **or**
|
||||
- prints a "DO THIS IN AMETHYST" prompt and waits for you to press `<Enter>`,
|
||||
then verifies the Amethyst action via `wn`.
|
||||
9. Stop the daemons and print a results table.
|
||||
|
||||
## Command-line flags
|
||||
|
||||
```
|
||||
--local-relays Use ws://localhost:8080 instead of the default public relays.
|
||||
Required if the public relays reject kinds 444/445/30443.
|
||||
Run 'just docker-up' inside whitenoise-rs first.
|
||||
--transponder Run Test 14 (push notifications via the transponder service).
|
||||
--no-build Fail instead of rebuilding wn/wnd. Useful when iterating.
|
||||
-h, --help Show help.
|
||||
```
|
||||
|
||||
Environment overrides:
|
||||
|
||||
```
|
||||
WN_REPO=/some/path/whitenoise-rs # use an existing checkout
|
||||
```
|
||||
|
||||
## Default relays
|
||||
|
||||
```
|
||||
wss://relay.damus.io
|
||||
wss://nos.lol
|
||||
wss://relay.primal.net
|
||||
```
|
||||
|
||||
These are known to accept kind 1059 (gift wraps) and kind 30000+ (addressable
|
||||
events). If the **sanity check fails** — meaning C cannot read the KeyPackage
|
||||
that B just published — the harness warns you and continues. In that case
|
||||
re-run with `--local-relays` after starting the Docker stack:
|
||||
|
||||
```bash
|
||||
cd state/whitenoise-rs
|
||||
just docker-up
|
||||
cd ../..
|
||||
./marmot-interop.sh --local-relays
|
||||
```
|
||||
|
||||
For Amethyst with `--local-relays`:
|
||||
|
||||
- **Android emulator:** add `ws://10.0.2.2:8080` to Settings → Relays and
|
||||
Settings → Key Package Relays.
|
||||
- **Physical device on same Wi-Fi:** add `ws://<laptop-LAN-ip>:8080`.
|
||||
|
||||
## How human interaction works
|
||||
|
||||
When the script needs you to do something in Amethyst, it prints a yellow
|
||||
block like this:
|
||||
|
||||
```
|
||||
---- DO THIS IN AMETHYST ----
|
||||
In Amethyst:
|
||||
1. Tap + -> Create Group
|
||||
2. Name: Interop-02
|
||||
3. Add member: npub1abc...
|
||||
4. Tap Create / Send Invite
|
||||
-----------------------------
|
||||
[Press Enter to continue]
|
||||
```
|
||||
|
||||
After you press Enter the script resumes. For UI-only verifications (e.g.
|
||||
"does Amethyst show reaction 🌮?"), the script asks:
|
||||
|
||||
```
|
||||
? Does Amethyst show the 🌮 reaction? [p]ass / [f]ail / [s]kip:
|
||||
```
|
||||
|
||||
Pick `p`, `f`, or `s`.
|
||||
|
||||
## Output
|
||||
|
||||
- **`state/logs/run-<timestamp>.log`** — every step, assertion, and human
|
||||
prompt, with the exact `wn` stdout that was parsed.
|
||||
- **`state/results-<timestamp>.tsv`** — tab-separated `test_id \t status \t
|
||||
note` lines. Easy to grep.
|
||||
- **`state/run.env`** — persistent key/value state (npubs, group ids) so you
|
||||
can kill and resume the harness mid-run without losing context.
|
||||
- **Final summary table on stdout** — colored per-test status and totals.
|
||||
|
||||
## State cleanup
|
||||
|
||||
The harness leaves `state/` on disk so daemons and identities survive across
|
||||
runs. To start completely fresh:
|
||||
|
||||
```bash
|
||||
# stops daemons if still running; removes identities, groups, logs
|
||||
./marmot-interop.sh --no-build # Ctrl-C when it waits for input, then:
|
||||
rm -rf state/
|
||||
```
|
||||
|
||||
Published KeyPackages on public relays will remain until they expire naturally
|
||||
or are deleted via `wn keys delete-all --confirm`. Use a throwaway identity
|
||||
for B/C if this matters to you.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- **Test 10 (concurrent commits)** is inherently human-timing sensitive. It's
|
||||
a best-effort race; expect occasional flakes.
|
||||
- **Test 14 (push)** only exercises the harness side — full end-to-end
|
||||
verification requires a running `transponder` instance and platform
|
||||
registrations that are out of scope here.
|
||||
- The harness assumes Amethyst exposes UI affordances for add-member,
|
||||
remove-member, rename, promote/demote, and leave. If any of those is missing
|
||||
from the current build, the corresponding test will fail with a clear note
|
||||
rather than crash.
|
||||
|
||||
## Files
|
||||
|
||||
- `marmot-interop.sh` — main entry point; orchestrates preflight, daemons,
|
||||
identities, relays, and runs the 13 tests in sequence.
|
||||
- `lib.sh` — helpers (logging, prompts, polling, jq wrappers, result table).
|
||||
- `state/` — runtime directory, gitignored. Contains `whitenoise-rs/` source
|
||||
checkout, per-daemon data/log dirs, the session `run.env`, logs, and
|
||||
results TSVs.
|
||||
@@ -0,0 +1,256 @@
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# lib.sh — helpers for marmot-interop.sh
|
||||
#
|
||||
# All functions assume the parent script has set:
|
||||
# STATE_DIR absolute path to state/ directory
|
||||
# LOG_FILE absolute path to current run's log file
|
||||
# RESULTS_FILE absolute path to results table (tab-separated)
|
||||
# WN_BIN, WND_BIN absolute paths to wn / wnd binaries
|
||||
# B_SOCKET, C_SOCKET socket paths for the two daemons
|
||||
|
||||
set -u
|
||||
|
||||
# ------- colors --------------------------------------------------------------
|
||||
if [[ -t 1 ]]; then
|
||||
C_RESET=$'\033[0m'
|
||||
C_BOLD=$'\033[1m'
|
||||
C_RED=$'\033[31m'
|
||||
C_GREEN=$'\033[32m'
|
||||
C_YELLOW=$'\033[33m'
|
||||
C_BLUE=$'\033[34m'
|
||||
C_CYAN=$'\033[36m'
|
||||
C_GREY=$'\033[90m'
|
||||
else
|
||||
C_RESET=""; C_BOLD=""; C_RED=""; C_GREEN=""
|
||||
C_YELLOW=""; C_BLUE=""; C_CYAN=""; C_GREY=""
|
||||
fi
|
||||
|
||||
# ------- logging primitives --------------------------------------------------
|
||||
log() { printf '%s\n' "$*" | tee -a "$LOG_FILE" >&2; }
|
||||
|
||||
banner() {
|
||||
printf '\n%s%s================================================================%s\n' \
|
||||
"$C_BOLD" "$C_BLUE" "$C_RESET" >&2
|
||||
printf '%s%s %s%s\n' "$C_BOLD" "$C_BLUE" "$1" "$C_RESET" >&2
|
||||
printf '%s%s================================================================%s\n\n' \
|
||||
"$C_BOLD" "$C_BLUE" "$C_RESET" >&2
|
||||
printf '\n=== %s ===\n' "$1" >>"$LOG_FILE"
|
||||
}
|
||||
|
||||
step() { printf '%s>%s %s\n' "$C_CYAN" "$C_RESET" "$1" >&2; printf '> %s\n' "$1" >>"$LOG_FILE"; }
|
||||
info() { printf '%s %s%s\n' "$C_GREY" "$1" "$C_RESET" >&2; printf ' %s\n' "$1" >>"$LOG_FILE"; }
|
||||
warn() { printf '%s!%s %s\n' "$C_YELLOW" "$C_RESET" "$1" >&2; printf '! %s\n' "$1" >>"$LOG_FILE"; }
|
||||
pass_msg() { printf '%s\u2713 PASS%s %s\n' "$C_GREEN" "$C_RESET" "$1" >&2; printf 'PASS %s\n' "$1" >>"$LOG_FILE"; }
|
||||
fail_msg() { printf '%s\u2717 FAIL%s %s\n' "$C_RED" "$C_RESET" "$1" >&2; printf 'FAIL %s\n' "$1" >>"$LOG_FILE"; }
|
||||
skip_msg() { printf '%s- SKIP%s %s\n' "$C_YELLOW" "$C_RESET" "$1" >&2; printf 'SKIP %s\n' "$1" >>"$LOG_FILE"; }
|
||||
|
||||
# ------- result tracking ------------------------------------------------------
|
||||
# Each test calls record_result <test-id> <pass|fail|skip> [note]
|
||||
record_result() {
|
||||
local test_id="$1" status="$2" note="${3:-}"
|
||||
printf '%s\t%s\t%s\n' "$test_id" "$status" "$note" >>"$RESULTS_FILE"
|
||||
}
|
||||
|
||||
print_summary() {
|
||||
local total=0 passed=0 failed=0 skipped=0
|
||||
printf '\n%s%s================================================================%s\n' \
|
||||
"$C_BOLD" "$C_BLUE" "$C_RESET"
|
||||
printf '%s%s RESULTS%s\n' "$C_BOLD" "$C_BLUE" "$C_RESET"
|
||||
printf '%s%s================================================================%s\n\n' \
|
||||
"$C_BOLD" "$C_BLUE" "$C_RESET"
|
||||
|
||||
if [[ ! -s "$RESULTS_FILE" ]]; then
|
||||
printf ' (no tests ran)\n\n'
|
||||
return
|
||||
fi
|
||||
|
||||
while IFS=$'\t' read -r tid status note; do
|
||||
total=$((total+1))
|
||||
case "$status" in
|
||||
pass) passed=$((passed+1)); col="$C_GREEN"; sym="\u2713" ;;
|
||||
fail) failed=$((failed+1)); col="$C_RED"; sym="\u2717" ;;
|
||||
skip) skipped=$((skipped+1)); col="$C_YELLOW"; sym="-" ;;
|
||||
*) col="$C_GREY"; sym="?" ;;
|
||||
esac
|
||||
printf ' %b %s%-44s%s %s\n' "$sym" "$col" "$tid" "$C_RESET" "$note"
|
||||
done <"$RESULTS_FILE"
|
||||
|
||||
printf '\n %s%d passed%s, %s%d failed%s, %s%d skipped%s (of %d)\n\n' \
|
||||
"$C_GREEN" "$passed" "$C_RESET" \
|
||||
"$C_RED" "$failed" "$C_RESET" \
|
||||
"$C_YELLOW" "$skipped" "$C_RESET" \
|
||||
"$total"
|
||||
printf ' full log: %s\n\n' "$LOG_FILE"
|
||||
}
|
||||
|
||||
# ------- human interaction ---------------------------------------------------
|
||||
|
||||
# Pretty-print multi-line instructions and wait for <Enter>.
|
||||
prompt_human() {
|
||||
printf '\n%s%s---- DO THIS IN AMETHYST ----%s\n' "$C_BOLD" "$C_YELLOW" "$C_RESET" >&2
|
||||
printf '%s%s%s\n' "$C_YELLOW" "$1" "$C_RESET" >&2
|
||||
printf '%s%s-----------------------------%s\n' "$C_BOLD" "$C_YELLOW" "$C_RESET" >&2
|
||||
printf '%s[Press Enter to continue]%s ' "$C_CYAN" "$C_RESET" >&2
|
||||
read -r _
|
||||
printf '\n' >>"$LOG_FILE"
|
||||
printf 'HUMAN-PROMPT: %s\n' "$1" >>"$LOG_FILE"
|
||||
}
|
||||
|
||||
# Ask the operator to confirm a UI rendering result.
|
||||
# Returns 0 on pass, 1 on fail, 2 on skip.
|
||||
confirm() {
|
||||
local q="$1" ans
|
||||
while true; do
|
||||
printf '%s? %s%s [p]ass / [f]ail / [s]kip: ' "$C_CYAN" "$q" "$C_RESET" >&2
|
||||
read -r ans
|
||||
case "${ans,,}" in
|
||||
p|pass) printf 'CONFIRM PASS: %s\n' "$q" >>"$LOG_FILE"; return 0 ;;
|
||||
f|fail) printf 'CONFIRM FAIL: %s\n' "$q" >>"$LOG_FILE"; return 1 ;;
|
||||
s|skip) printf 'CONFIRM SKIP: %s\n' "$q" >>"$LOG_FILE"; return 2 ;;
|
||||
*) printf 'enter p, f, or s\n' >&2 ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# ------- wn wrappers ---------------------------------------------------------
|
||||
wn_b() { "$WN_BIN" --socket "$B_SOCKET" "$@"; }
|
||||
wn_c() { "$WN_BIN" --socket "$C_SOCKET" "$@"; }
|
||||
|
||||
# Run wn with --json and pipe through jq; aborts the test on jq parse failure.
|
||||
wn_b_json() { wn_b --json "$@"; }
|
||||
wn_c_json() { wn_c --json "$@"; }
|
||||
|
||||
# ------- assertions ----------------------------------------------------------
|
||||
expect_eq() {
|
||||
local actual="$1" expected="$2" what="${3:-value}"
|
||||
if [[ "$actual" == "$expected" ]]; then
|
||||
info "$what == \"$expected\""
|
||||
return 0
|
||||
fi
|
||||
fail_msg "$what mismatch: expected \"$expected\", got \"$actual\""
|
||||
return 1
|
||||
}
|
||||
|
||||
expect_contains() {
|
||||
local haystack="$1" needle="$2" what="${3:-output}"
|
||||
if [[ "$haystack" == *"$needle"* ]]; then
|
||||
info "$what contains \"$needle\""
|
||||
return 0
|
||||
fi
|
||||
fail_msg "$what missing \"$needle\""
|
||||
info "actual: $haystack"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ------- polling helpers -----------------------------------------------------
|
||||
|
||||
# wait_for_invite <B|C> <timeout-seconds>
|
||||
# Echoes the first new group_id that appears in `wn groups invites --json`.
|
||||
# Returns 1 on timeout.
|
||||
wait_for_invite() {
|
||||
local who="$1" timeout="${2:-60}" deadline gid
|
||||
deadline=$(( $(date +%s) + timeout ))
|
||||
while [[ $(date +%s) -lt $deadline ]]; do
|
||||
if [[ "$who" == "B" ]]; then
|
||||
gid=$(wn_b_json groups invites 2>/dev/null \
|
||||
| jq -r '.[0].group_id // .[0].mls_group_id // empty' 2>/dev/null || true)
|
||||
else
|
||||
gid=$(wn_c_json groups invites 2>/dev/null \
|
||||
| jq -r '.[0].group_id // .[0].mls_group_id // empty' 2>/dev/null || true)
|
||||
fi
|
||||
if [[ -n "${gid:-}" ]]; then
|
||||
printf '%s\n' "$gid"
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# wait_for_message <B|C> <group_id> <substring> <timeout>
|
||||
# Returns 0 if a message containing the substring shows up via wn messages list.
|
||||
wait_for_message() {
|
||||
local who="$1" gid="$2" needle="$3" timeout="${4:-30}"
|
||||
local deadline payload
|
||||
deadline=$(( $(date +%s) + timeout ))
|
||||
while [[ $(date +%s) -lt $deadline ]]; do
|
||||
if [[ "$who" == "B" ]]; then
|
||||
payload=$(wn_b_json messages list "$gid" --limit 20 2>/dev/null || true)
|
||||
else
|
||||
payload=$(wn_c_json messages list "$gid" --limit 20 2>/dev/null || true)
|
||||
fi
|
||||
if [[ -n "${payload:-}" ]] && \
|
||||
printf '%s' "$payload" | jq -e --arg n "$needle" \
|
||||
'.[]? | select((.content // .text // "") | contains($n))' \
|
||||
>/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# wait_for_member <B|C> <group_id> <pubkey-hex> <timeout>
|
||||
wait_for_member() {
|
||||
local who="$1" gid="$2" pubkey="$3" timeout="${4:-30}"
|
||||
local deadline payload
|
||||
deadline=$(( $(date +%s) + timeout ))
|
||||
while [[ $(date +%s) -lt $deadline ]]; do
|
||||
if [[ "$who" == "B" ]]; then
|
||||
payload=$(wn_b_json groups members "$gid" 2>/dev/null || true)
|
||||
else
|
||||
payload=$(wn_c_json groups members "$gid" 2>/dev/null || true)
|
||||
fi
|
||||
if printf '%s' "${payload:-}" | jq -e --arg p "$pubkey" \
|
||||
'.[]? | select((.pubkey // .public_key // "") == $p)' \
|
||||
>/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ------- npub <-> hex conversion helpers -------------------------------------
|
||||
# whitenoise's `wn` accepts both, but we use hex internally for JSON comparisons.
|
||||
# We rely on `wn debug` (or fall back to a python one-liner) — but to keep zero
|
||||
# extra deps we do the conversion via wn itself by round-tripping through
|
||||
# `wn users show <npub>` which prints both forms.
|
||||
npub_to_hex() {
|
||||
local npub="$1"
|
||||
wn_b --json users show "$npub" 2>/dev/null \
|
||||
| jq -r '.pubkey // .public_key // empty' 2>/dev/null \
|
||||
|| printf '%s' "$npub"
|
||||
}
|
||||
|
||||
# ------- state file ----------------------------------------------------------
|
||||
# Simple key=value store at $STATE_DIR/run.env
|
||||
STATE_FILE_RUNTIME="${STATE_DIR}/run.env"
|
||||
|
||||
save_state() {
|
||||
local key="$1" value="$2"
|
||||
: >"${STATE_FILE_RUNTIME}.tmp"
|
||||
if [[ -f "$STATE_FILE_RUNTIME" ]]; then
|
||||
grep -v "^${key}=" "$STATE_FILE_RUNTIME" >>"${STATE_FILE_RUNTIME}.tmp" || true
|
||||
fi
|
||||
printf '%s=%s\n' "$key" "$value" >>"${STATE_FILE_RUNTIME}.tmp"
|
||||
mv "${STATE_FILE_RUNTIME}.tmp" "$STATE_FILE_RUNTIME"
|
||||
}
|
||||
|
||||
load_state() {
|
||||
local key="$1"
|
||||
[[ -f "$STATE_FILE_RUNTIME" ]] || return 1
|
||||
grep "^${key}=" "$STATE_FILE_RUNTIME" 2>/dev/null | tail -n1 | cut -d= -f2-
|
||||
}
|
||||
|
||||
# ------- group cleanup -------------------------------------------------------
|
||||
cleanup_group() {
|
||||
local gid="$1" who="${2:-B}"
|
||||
[[ -z "$gid" ]] && return 0
|
||||
if [[ "$who" == "B" ]]; then
|
||||
wn_b groups leave "$gid" >/dev/null 2>&1 || true
|
||||
else
|
||||
wn_c groups leave "$gid" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
Executable
+809
@@ -0,0 +1,809 @@
|
||||
#!/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)"
|
||||
STATE_DIR="$SCRIPT_DIR/state"
|
||||
LOG_DIR="$STATE_DIR/logs"
|
||||
B_DIR="$STATE_DIR/B"
|
||||
C_DIR="$STATE_DIR/C"
|
||||
B_SOCKET="$B_DIR/wnd.sock"
|
||||
C_SOCKET="$C_DIR/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=""
|
||||
|
||||
DEFAULT_RELAYS=( "wss://relay.damus.io" "wss://nos.lol" "wss://relay.primal.net" )
|
||||
USE_LOCAL_RELAYS=0
|
||||
ENABLE_TRANSPONDER=0
|
||||
NO_BUILD=0
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
marmot-interop.sh — Amethyst <-> 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 "$SCRIPT_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
|
||||
info "$cmd: $(command -v "$cmd")"
|
||||
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
|
||||
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"
|
||||
nohup "$WND_BIN" --data-dir "$data_dir" --logs-dir "$data_dir/logs" --socket "$socket" \
|
||||
>"$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..."
|
||||
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() {
|
||||
local who="$1" cmd
|
||||
if [[ "$who" == "B" ]]; then cmd=wn_b; else cmd=wn_c; fi
|
||||
step "ensuring Identity $who"
|
||||
local npub
|
||||
npub=$("$cmd" --json whoami 2>/dev/null | jq -r '.pubkey // .npub // empty' 2>/dev/null || true)
|
||||
if [[ -z "${npub:-}" || "$npub" == "null" ]]; then
|
||||
info "creating new identity for $who"
|
||||
"$cmd" create-identity 2>&1 | tee -a "$LOG_FILE" >&2
|
||||
npub=$("$cmd" --json whoami 2>/dev/null | jq -r '.pubkey // .npub // empty' || true)
|
||||
fi
|
||||
if [[ -z "${npub:-}" ]]; then
|
||||
fail_msg "could not determine npub for $who"; exit 1
|
||||
fi
|
||||
local hex
|
||||
hex=$("$cmd" --json users show "$npub" 2>/dev/null | jq -r '.pubkey // .public_key // .hex // empty' 2>/dev/null || true)
|
||||
[[ -z "$hex" ]] && 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"
|
||||
}
|
||||
|
||||
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=$(wn_b --json users show "$A_NPUB" 2>/dev/null \
|
||||
| jq -r '.pubkey // .public_key // .hex // empty' 2>/dev/null || true)
|
||||
[[ -z "$A_HEX" ]] && A_HEX="$A_NPUB"
|
||||
save_state "A_HEX" "$A_HEX"
|
||||
info "A hex: $A_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 both daemons"
|
||||
wn_b relays add "$r" >/dev/null 2>&1 || warn "wn_b relays add $r failed (may already exist)"
|
||||
wn_c relays add "$r" >/dev/null 2>&1 || warn "wn_c relays add $r failed (may already exist)"
|
||||
done
|
||||
|
||||
step "sanity-check: B publishes KP, C fetches it"
|
||||
wn_b keys publish >/dev/null 2>&1 || warn "wn_b keys publish returned nonzero"
|
||||
sleep 4
|
||||
if wn_c --json keys check "$B_NPUB" 2>/dev/null | jq -e '.event_id // empty' >/dev/null 2>&1; then
|
||||
info "sanity OK — relay round-trip works"
|
||||
else
|
||||
warn "sanity-check failed — public relays may be rejecting kind 30443."
|
||||
warn "Consider rerunning with --local-relays (requires 'just docker-up' in whitenoise-rs)."
|
||||
fi
|
||||
}
|
||||
|
||||
instruct_amethyst_setup() {
|
||||
local list
|
||||
if [[ "$USE_LOCAL_RELAYS" -eq 1 ]]; then
|
||||
list=" ws://10.0.2.2:8080 (Android emulator)
|
||||
ws://<your-LAN-ip>:8080 (physical device on same Wi-Fi)"
|
||||
else
|
||||
list=$(printf ' %s\n' "${DEFAULT_RELAYS[@]}")
|
||||
fi
|
||||
prompt_human "Configure Amethyst to match this harness:
|
||||
1. Settings -> Relays: add the following as READ+WRITE
|
||||
$list
|
||||
2. Settings -> Key Package Relays: add the SAME URLs
|
||||
3. Trigger key-package publish (toggle KP relay on/off if needed)
|
||||
4. Confirm your Amethyst account is logged in with npub: $A_NPUB"
|
||||
}
|
||||
|
||||
# ==== tests ==================================================================
|
||||
|
||||
test_01_keypackage_discovery() {
|
||||
banner "Test 01 — KeyPackage publish & discovery (MIP-00)"
|
||||
|
||||
step "B fetches A's KeyPackage from relays"
|
||||
if wn_b --json keys check "$A_NPUB" 2>/dev/null | jq -e '.event_id // empty' >/dev/null 2>&1; then
|
||||
pass_msg "B discovered A's KeyPackage"
|
||||
record_result "01a KeyPackage A->B" pass
|
||||
else
|
||||
fail_msg "B cannot find A's KeyPackage. Did Amethyst publish to the same relays?"
|
||||
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"
|
||||
|
||||
prompt_human "In Amethyst:
|
||||
1. Tap '+' -> Create Group
|
||||
2. Name: Interop-02
|
||||
3. Add member: $B_NPUB
|
||||
4. Tap Create / Send Invite"
|
||||
|
||||
step "polling B's daemon for invite (60s)"
|
||||
local gid
|
||||
if ! gid=$(wait_for_invite B 60); then
|
||||
fail_msg "no invite arrived at B"
|
||||
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 '.[].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"
|
||||
wn_b messages send "$gid" "hello from wn" >/dev/null 2>&1 || warn "send returned nonzero"
|
||||
|
||||
if confirm "Does Amethyst now show 'hello from wn' in the same group?"; then
|
||||
record_result "02 Amethyst->B create+invite" pass
|
||||
else
|
||||
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>&1 | tee -a "$LOG_FILE")
|
||||
gid=$(printf '%s' "$out" | jq -r '.group_id // .mls_group_id // empty' 2>/dev/null || true)
|
||||
if [[ -z "$gid" ]]; then
|
||||
fail_msg "could not parse group_id from 'wn groups create' output"
|
||||
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>&1 | tee -a "$LOG_FILE")
|
||||
gid=$(printf '%s' "$out" | jq -r '.group_id // .mls_group_id // empty')
|
||||
save_state GROUP_02 "$gid"
|
||||
prompt_human "In Amethyst, accept the new group invite 'Interop-04-bootstrap'."
|
||||
fi
|
||||
info "using group $gid"
|
||||
|
||||
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)"
|
||||
local c_gid
|
||||
if ! c_gid=$(wait_for_invite C 60); then
|
||||
fail_msg "C never received invite"
|
||||
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 '.[].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
|
||||
out=$(wn_b --json groups create "Interop-05" "$C_NPUB" 2>&1 | tee -a "$LOG_FILE")
|
||||
gid=$(printf '%s' "$out" | jq -r '.group_id // .mls_group_id // empty')
|
||||
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"
|
||||
if wait_for_invite C 30 >/dev/null; then
|
||||
wn_c groups accept "$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
|
||||
|
||||
prompt_human "In Amethyst, open group 'Interop-05' -> Group Info -> Members.
|
||||
Tap C ($C_NPUB) -> Remove.
|
||||
Confirm the removal."
|
||||
|
||||
step "verifying C is removed (60s poll)"
|
||||
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 to be a member; proceeding anyway"
|
||||
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)"
|
||||
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
|
||||
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)"
|
||||
|
||||
local gid
|
||||
gid=$(load_state GROUP_02 || true)
|
||||
if [[ -z "${gid:-}" ]]; then
|
||||
skip_msg "no GROUP_02"; record_result "07 metadata rename" skip "no group state"; return
|
||||
fi
|
||||
|
||||
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=$(( $(date +%s) + 60 )) name=""
|
||||
while [[ $(date +%s) -lt $deadline ]]; do
|
||||
name=$(wn_b --json groups show "$gid" 2>/dev/null | jq -r '.name // empty')
|
||||
[[ "$name" == "Interop-02-renamed" ]] && break
|
||||
sleep 3
|
||||
done
|
||||
if [[ "$name" == "Interop-02-renamed" ]]; then
|
||||
pass_msg "B sees renamed group"
|
||||
else
|
||||
fail_msg "B still sees name: $name"
|
||||
record_result "07 metadata rename" fail "rename did not propagate to B"; return
|
||||
fi
|
||||
|
||||
step "B renames back"
|
||||
wn_b groups rename "$gid" "Interop-02-reverse" >/dev/null 2>&1 || true
|
||||
|
||||
if confirm "Does Amethyst now show the group name as 'Interop-02-reverse'?"; then
|
||||
record_result "07 metadata rename" pass
|
||||
else
|
||||
record_result "07 metadata rename" 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
|
||||
wn_b groups add-members "$gid" "$C_NPUB" >/dev/null 2>&1 || warn "add-members C returned nonzero"
|
||||
if wait_for_invite C 30 >/dev/null; then
|
||||
wn_c groups accept "$gid" >/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 '.[].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 '.[].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 '[.[]? | 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"
|
||||
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 <Enter> 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
|
||||
b_view=$(wn_b --json groups show "$gid" 2>/dev/null | jq '{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" '.[]? | 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>&1 | tee -a "$LOG_FILE")
|
||||
gid=$(printf '%s' "$out" | jq -r '.group_id // .mls_group_id // empty')
|
||||
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 <Enter> 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"
|
||||
wn_b groups add-members "$gid" "$C_NPUB" >/dev/null 2>&1 || true
|
||||
if wait_for_invite C 30 >/dev/null; then
|
||||
wn_c groups accept "$gid" >/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"
|
||||
|
||||
step "B records A's current KP event id"
|
||||
local before
|
||||
before=$(wn_b --json keys check "$A_NPUB" 2>/dev/null | jq -r '.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
|
||||
|
||||
prompt_human "In Amethyst, trigger a KeyPackage rotation:
|
||||
Option 1: Settings -> Key Package Relays -> toggle OFF all KP relays, then toggle ON.
|
||||
Option 2: Restart the Amethyst app (KeyPackageRotationManager runs at startup).
|
||||
Press <Enter> when Amethyst has published a new KP."
|
||||
|
||||
step "waiting for a new 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 '.event_id // empty')
|
||||
[[ -n "$after" && "$after" != "$before" ]] && break
|
||||
sleep 3
|
||||
done
|
||||
if [[ -n "$after" && "$after" != "$before" ]]; then
|
||||
pass_msg "A's KP rotated: $before -> $after"
|
||||
record_result "13 keypackage rotation" pass
|
||||
else
|
||||
record_result "13 keypackage rotation" fail "no new KP event_id observed"
|
||||
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() {
|
||||
trap 'stop_daemons; print_summary' EXIT
|
||||
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
|
||||
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 "$@"
|
||||
Reference in New Issue
Block a user