test(cli): amy↔amy NIP-17 DM interop harness

Adds `tools/marmot-interop/dm-interop-headless.sh` — a zero-prompt
end-to-end harness that runs two independent `amy` processes against
a loopback nostr-rs-relay and verifies the NIP-17 DM surface.

Six scenarios:

  dm-01  text round-trip (kind:14) in both directions
  dm-02  `dm list` surfaces prior exchange with `type:text` discriminator
  dm-03  strict kind:10050 refuses sends to an inboxless recipient
         (surfaces the `no_dm_relays` error JSON)
  dm-04  `--allow-fallback` opts into NIP-65 read / bootstrap chain
         (asserts a `relay_source: "bootstrap"` or `"nip65_read"` row)
  dm-05  file message reference mode (kind:15) — send-file with
         --key / --nonce, verify recipient decodes url + key + nonce + mime
  dm-06  no-flag `dm list` advances `state.giftWrapSince`; second call
         returns an empty `messages` array

Shape matches the existing Marmot harness: reuses `lib.sh` for logging
+ result tracking, reuses `setup.sh` for the nostr-rs-relay lifecycle
(`start_local_relay` / `stop_local_relay`), adds a slim `setup-dm.sh`
preflight (amy + relay only, no whitenoise-rs / Marmot patches), and
defines tests in `tests-dm.sh`.

No new CI job yet — the relay build needs Rust + ~3 minutes on a
cold cache, same constraint as the Marmot harness.

Upload-mode (`dm send-file --file PATH --server URL`) is not scripted
here: it needs a local Blossom server, which is out of scope for this
pass. The shared upload classes are unit-tested on desktop at
`desktopApp/src/jvmTest/kotlin/.../service/upload/`.
This commit is contained in:
Claude
2026-04-23 20:14:08 +00:00
parent c614400b23
commit 112801ba82
5 changed files with 464 additions and 4 deletions
+28 -3
View File
@@ -1,6 +1,6 @@
# Marmot Interop Test Harness
# Interop Test Harnesses
Two flavours, same scenarios:
Two flavours of the Marmot harness, same scenarios:
- **`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).
@@ -10,7 +10,15 @@ Two flavours, same scenarios:
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**
A third, slimmer harness covers the NIP-17 DM surface:
- **`dm-interop-headless.sh`** — two `amy` processes (Identity A and
Identity D) exchange NIP-17 DMs through the loopback nostr-rs-relay.
No whitenoise-rs required — only `amy` and the relay binary (which
is shared with the Marmot harness's checkout at
`state-headless/nostr-rs-relay/`).
Both Marmot 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
@@ -33,6 +41,23 @@ the end of the run.
| 11 | Leave group | |
| 12 | Offline catch-up / replay | |
| 13 | KeyPackage rotation | |
### DM (amy ↔ amy, NIP-17) — `dm-interop-headless.sh`
| # | Test |
|---|---|
| dm-01 | Text round-trip A↔D (kind:14) |
| dm-02 | `dm list` returns prior exchange with `type:text` discriminator |
| dm-03 | Strict kind:10050 refuses sends to an inboxless recipient |
| dm-04 | `--allow-fallback` opts into the NIP-65 read / bootstrap chain |
| dm-05 | File message reference mode round-trip (kind:15 with manual key/nonce) |
| dm-06 | No-flag `dm list` advances the gift-wrap cursor (second call empty) |
**Note:** dm-05 validates the kind:15 wire format via reference mode
(caller supplies the URL + AES-GCM key/nonce). The upload-mode variant
(`dm send-file --file PATH --server URL`) needs a local Blossom server
and isn't scripted here — the upload classes are unit-tested on desktop
at `desktopApp/src/jvmTest/kotlin/.../service/upload/`.
| 14 | Push notifications (MIP-05) | opt-in via `--transponder` |
## Prerequisites
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
#
# dm-interop-headless.sh — zero-prompt NIP-17 DM interop harness.
#
# Two `amy` processes (Identity A and Identity D) talk to each other
# through a local nostr-rs-relay on ws://127.0.0.1:$RELAY_PORT. No
# whitenoise-rs, no Marmot, no public internet traffic.
#
# Usage: ./dm-interop-headless.sh [--port N] [--no-build]
#
set -uo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)"
STATE_DIR="$SCRIPT_DIR/state-dm-headless"
LOG_DIR="$STATE_DIR/logs"
A_DIR="$STATE_DIR/A"
D_DIR="$STATE_DIR/D"
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"
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
# Share the nostr-rs-relay checkout with the Marmot harness to avoid
# rebuilding it twice. Override RELAY_REPO / RELAY_DATA if you want full
# isolation between runs.
RELAY_REPO="${RELAY_REPO:-$SCRIPT_DIR/state-headless/nostr-rs-relay}"
RELAY_BIN="$RELAY_REPO/target/release/nostr-rs-relay"
RELAY_DATA="$STATE_DIR/relay"
RELAY_PORT="${RELAY_PORT:-8090}"
RELAY_URL="ws://127.0.0.1:$RELAY_PORT"
NO_BUILD=0
A_NPUB=""
A_HEX=""
D_NPUB=""
D_HEX=""
while [[ $# -gt 0 ]]; do
case "$1" in
--port) RELAY_PORT="$2"; RELAY_URL="ws://127.0.0.1:$RELAY_PORT"; shift ;;
--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" "$D_DIR"
: >"$LOG_FILE"
: >"$RESULTS_FILE"
# Reuse the logging + result helpers from the Marmot harness. lib.sh
# also declares wn-specific helpers; they're harmless when unused.
# shellcheck source=lib.sh
source "$SCRIPT_DIR/lib.sh"
# Reuse start_local_relay / stop_local_relay from setup.sh. preflight()
# there also builds whitenoise-rs, which we don't need — setup-dm.sh
# defines a slimmer preflight_dm().
# shellcheck source=headless/setup.sh
source "$SCRIPT_DIR/headless/setup.sh"
# shellcheck source=headless/setup-dm.sh
source "$SCRIPT_DIR/headless/setup-dm.sh"
# shellcheck source=headless/helpers.sh
source "$SCRIPT_DIR/headless/helpers.sh"
# shellcheck source=headless/tests-dm.sh
source "$SCRIPT_DIR/headless/tests-dm.sh"
cleanup() {
local rc=$?
trap - EXIT INT TERM HUP
stop_local_relay
print_summary
exit "$rc"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
trap 'exit 129' HUP
banner "Amethyst NIP-17 DM headless interop ($RUN_TS)"
preflight_dm
start_local_relay
ensure_identity_for A "$A_DIR"
ensure_identity_for D "$D_DIR"
configure_relays_dm
test_01_dm_text_round_trip
test_02_dm_list_surfaces_history
test_03_dm_send_rejects_no_inbox
test_04_dm_send_allow_fallback
test_05_dm_file_reference_round_trip
test_06_dm_list_cursor_advance
+112
View File
@@ -0,0 +1,112 @@
# shellcheck shell=bash
#
# headless/setup-dm.sh — amy-only preflight + identity bootstrap for the
# NIP-17 DM interop harness. Much slimmer than the Marmot setup:
#
# - Builds `amy` (same retry-on-503 logic as setup.sh).
# - Builds nostr-rs-relay if missing.
# - Bootstraps two fresh amy identities (A and D), each with its own
# `--data-dir`, both pointed at the loopback relay.
# - Publishes kind:10050 (plus NIP-65) for both so NIP-17's strict
# recipient-inbox routing has something to resolve to.
#
# The heavy `start_local_relay` / `stop_local_relay` helpers live in
# headless/setup.sh and are sourced by the top-level harness.
# --- preflight (amy + relay only, no wn / Marmot patches) -------------------
preflight_dm() {
banner "Preflight (DM harness)"
for cmd in jq git 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
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
# Gradle + jitpack/dl.google.com occasionally return transient 503s;
# retry so a single bad roll doesn't tank the whole harness.
local attempt max=4
for attempt in $(seq 1 $max); do
step "building :cli:installDist (attempt $attempt/$max)"
if ( cd "$REPO_ROOT" && ./gradlew :cli:installDist ) 2>&1 | tee -a "$LOG_FILE" \
&& [[ -x "$AMY_BIN" ]]; then
break
fi
[[ "$attempt" -lt "$max" ]] && warn "gradle build failed — retrying"
done
fi
[[ -x "$AMY_BIN" ]] || { fail_msg "amy still missing after build"; exit 1; }
info "amy: $AMY_BIN"
# nostr-rs-relay (same build path as the Marmot harness).
if [[ ! -x "$RELAY_BIN" ]]; then
if [[ "$NO_BUILD" -eq 1 ]]; then
fail_msg "nostr-rs-relay not found at $RELAY_BIN and --no-build set"; exit 1
fi
if [[ ! -d "$RELAY_REPO/.git" ]]; then
step "cloning nostr-rs-relay into $RELAY_REPO"
git clone --depth 1 https://github.com/scsibug/nostr-rs-relay "$RELAY_REPO" \
2>&1 | tee -a "$LOG_FILE"
fi
local attempt max=4
for attempt in $(seq 1 $max); do
step "building nostr-rs-relay (attempt $attempt/$max, ~3 min first run)"
( cd "$RELAY_REPO" && cargo build --release --bin nostr-rs-relay ) \
2>&1 | tee -a "$LOG_FILE"
[[ -x "$RELAY_BIN" ]] && break
[[ "$attempt" -lt "$max" ]] && warn "nostr-rs-relay build failed — retrying"
done
[[ -x "$RELAY_BIN" ]] || {
fail_msg "nostr-rs-relay still missing after $max attempts"; exit 1
}
fi
info "relay bin: $RELAY_BIN"
}
# --- amy identity wrappers ---------------------------------------------------
# Two identities: A (sender) and D (recipient). We reuse A_DIR for parity
# with the existing harness files; D_DIR is new.
amy_a() { "$AMY_BIN" --data-dir "$A_DIR" "$@"; }
amy_d() { "$AMY_BIN" --data-dir "$D_DIR" "$@"; }
# --- identity bootstrap ------------------------------------------------------
ensure_identity_for() {
local who="$1" dir="$2"
step "initialising Identity $who (amy at $dir)"
local out
out=$("$AMY_BIN" --data-dir "$dir" init) || {
fail_msg "amy init failed for $who: $out"; exit 1
}
local npub hex
npub=$(printf '%s' "$out" | jq -r '.npub')
hex=$(printf '%s' "$out" | jq -r '.hex')
case "$who" in
A) A_NPUB="$npub"; A_HEX="$hex" ;;
D) D_NPUB="$npub"; D_HEX="$hex" ;;
esac
info "$who npub: $npub"
info "$who hex: $hex"
}
# --- relay wiring ------------------------------------------------------------
# Point both identities at the loopback relay (all three buckets:
# nip65 / inbox / key_package), then publish each identity's kind:10050
# so the DM strict-relay routing has something to resolve to.
configure_relays_dm() {
banner "Configuring relays → $RELAY_URL"
"$AMY_BIN" --data-dir "$A_DIR" relay add "$RELAY_URL" --type all >/dev/null
"$AMY_BIN" --data-dir "$D_DIR" relay add "$RELAY_URL" --type all >/dev/null
step "publishing A's NIP-65 + kind:10050 lists"
amy_a relay publish-lists >>"$LOG_FILE" 2>&1 \
|| warn "amy_a relay publish-lists failed"
step "publishing D's NIP-65 + kind:10050 lists"
amy_d relay publish-lists >>"$LOG_FILE" 2>&1 \
|| warn "amy_d relay publish-lists failed"
}
+220
View File
@@ -0,0 +1,220 @@
# shellcheck shell=bash
#
# headless/tests-dm.sh — NIP-17 DM interop tests for two `amy` clients.
#
# Identity A (sender) and Identity D (recipient) each live in their own
# --data-dir and share one loopback nostr-rs-relay. Tests cover:
#
# dm-01 text round-trip (both directions)
# dm-02 dm list surfaces prior exchange with type:text discriminator
# dm-03 strict kind:10050 routing refuses sends to inboxless recipient
# dm-04 --allow-fallback opts into the NIP-65 read / bootstrap chain
# dm-05 file message reference mode round-trip (kind:15)
# dm-06 cursor advance (subsequent no-flag `dm list` is empty)
# Wrap amy_json around either data-dir so the per-test code stays tight.
amy_json_for() {
local dir="$1"; shift
local out
if ! out=$("$AMY_BIN" --data-dir "$dir" "$@" 2>>"$LOG_FILE"); then
fail_msg "amy --data-dir $dir $*: exit $? (see $LOG_FILE)"
printf '%s\n' "$out" >>"$LOG_FILE"
return 1
fi
printf '%s' "$out"
}
amy_json_a() { amy_json_for "$A_DIR" "$@"; }
amy_json_d() { amy_json_for "$D_DIR" "$@"; }
test_01_dm_text_round_trip() {
banner "DM-01 — text round-trip A↔D (kind:14)"
local id="dm-01 text round-trip"
# A → D
local out_send
out_send=$(amy_json_a dm send "$D_NPUB" "hello from A") || {
record_result "$id" fail "A send failed"; return
}
local event_id recipients_ok
event_id=$(printf '%s' "$out_send" | jq -r '.event_id')
recipients_ok=$(printf '%s' "$out_send" \
| jq -r '[.recipients[] | select(.relay_source == "kind_10050")] | length')
info "A sent kind:14 event_id=$event_id (to ${recipients_ok} kind:10050 inboxes)"
if [[ -z "$event_id" || "$event_id" == "null" ]]; then
record_result "$id" fail "A send response missing event_id"; return
fi
# D awaits the text
if ! amy_json_d dm await --peer "$A_NPUB" --match "hello from A" --timeout 30 >/dev/null; then
record_result "$id" fail "D never received A's text"; return
fi
info "D received A's text"
# D → A
amy_json_d dm send "$A_NPUB" "reply from D" >/dev/null || {
record_result "$id" fail "D send failed"; return
}
if ! amy_json_a dm await --peer "$D_NPUB" --match "reply from D" --timeout 30 >/dev/null; then
record_result "$id" fail "A never received D's reply"; return
fi
info "A received D's reply"
record_result "$id" pass
}
test_02_dm_list_surfaces_history() {
banner "DM-02 — dm list returns text messages with type discriminator"
local id="dm-02 dm list text history"
local out
out=$(amy_json_a dm list --peer "$D_NPUB" --timeout 5) || {
record_result "$id" fail "amy dm list failed"; return
}
local text_count reply_present
text_count=$(printf '%s' "$out" \
| jq -r '[.messages[] | select(.type == "text")] | length')
reply_present=$(printf '%s' "$out" \
| jq -r '[.messages[] | select(.content == "reply from D")] | length')
info "A sees $text_count text message(s) with D; reply_present=$reply_present"
if [[ "$reply_present" == "1" ]]; then
record_result "$id" pass
else
record_result "$id" fail "D's reply missing from A's dm list"
fi
}
test_03_dm_send_rejects_no_inbox() {
banner "DM-03 — strict kind:10050 refuses sends to inboxless recipient"
local id="dm-03 strict no_dm_relays"
# Generate a throwaway identity but do NOT publish its kind:10050.
local tmpdir; tmpdir=$(mktemp -d "${STATE_DIR}/ghost.XXXXXX")
local ghost_out ghost_npub
ghost_out=$("$AMY_BIN" --data-dir "$tmpdir" init) || {
record_result "$id" fail "ghost init failed"; rm -rf "$tmpdir"; return
}
ghost_npub=$(printf '%s' "$ghost_out" | jq -r '.npub')
info "ghost: $ghost_npub (no relays advertised)"
# A sends without --allow-fallback; amy should refuse.
local raw rc
raw=$("$AMY_BIN" --data-dir "$A_DIR" dm send "$ghost_npub" "should be rejected" 2>&1)
rc=$?
rm -rf "$tmpdir"
if [[ "$rc" -ne 0 ]] && printf '%s' "$raw" | grep -q '"error":"no_dm_relays"'; then
info "amy refused with no_dm_relays as expected (exit $rc)"
record_result "$id" pass
else
fail_msg "expected no_dm_relays error; got rc=$rc raw=$raw"
record_result "$id" fail "send to inboxless recipient did not refuse"
fi
}
test_04_dm_send_allow_fallback() {
banner "DM-04 — --allow-fallback opts into NIP-65 read / bootstrap chain"
local id="dm-04 allow-fallback"
# Same ghost pattern as dm-03. With --allow-fallback, resolution should
# fall through kind:10050 → NIP-65 read → bootstrap. Our bootstrap set
# always includes the loopback relay via the shared RelayConfig, so the
# publish should succeed even though the ghost has no 10050.
local tmpdir; tmpdir=$(mktemp -d "${STATE_DIR}/ghost.XXXXXX")
local ghost_out ghost_npub
ghost_out=$("$AMY_BIN" --data-dir "$tmpdir" init) || {
record_result "$id" fail "ghost init failed"; rm -rf "$tmpdir"; return
}
ghost_npub=$(printf '%s' "$ghost_out" | jq -r '.npub')
rm -rf "$tmpdir"
local out source
out=$(amy_json_a dm send "$ghost_npub" "hi via fallback" --allow-fallback) || {
record_result "$id" fail "send with --allow-fallback failed"; return
}
# The ghost's own wrap will land in the recipients list; its source
# should be bootstrap (no 10050, no 10002) — which confirms the
# fallback actually fired.
source=$(printf '%s' "$out" \
| jq -r --arg pk "${ghost_npub}" \
'.recipients[] | select(.pubkey != $pk) | .relay_source' \
| head -1)
info "relay_source for ghost wrap: $source"
if printf '%s' "$out" | jq -e '[.recipients[] | .relay_source] | index("bootstrap")' >/dev/null \
|| printf '%s' "$out" | jq -e '[.recipients[] | .relay_source] | index("nip65_read")' >/dev/null; then
record_result "$id" pass
else
record_result "$id" fail "no recipient reported a fallback relay_source"
fi
}
test_05_dm_file_reference_round_trip() {
banner "DM-05 — file message reference mode (kind:15) round-trip"
local id="dm-05 file reference round-trip"
# 64 hex chars = 32-byte AES-GCM key; 32 hex chars = 16-byte nonce.
local key="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
local nonce="fedcba9876543210fedcba9876543210"
local url="https://example.test/blob/test-dm-05.bin"
local mime="application/octet-stream"
amy_json_a dm send-file "$D_NPUB" "$url" \
--key "$key" --nonce "$nonce" --mime-type "$mime" --size 1024 >/dev/null || {
record_result "$id" fail "A send-file failed"; return
}
info "A published kind:15 for $url"
# D awaits — `dm await --match` searches URL for kind:15.
if ! amy_json_d dm await --peer "$A_NPUB" --match "$url" --timeout 30 >/dev/null; then
record_result "$id" fail "D never received the file message"; return
fi
# Verify the recovered JSON carries all crypto material.
local raw
raw=$(amy_json_d dm list --peer "$A_NPUB" --timeout 5) || {
record_result "$id" fail "D dm list failed"; return
}
local fields
fields=$(printf '%s' "$raw" | jq -r \
'[.messages[] | select(.type == "file" and .url == "'"$url"'")][0]')
if [[ -z "$fields" || "$fields" == "null" ]]; then
record_result "$id" fail "D did not decode a kind:15 matching the URL"; return
fi
local got_key got_nonce got_mime
got_key=$(printf '%s' "$fields" | jq -r '.decryption_key')
got_nonce=$(printf '%s' "$fields" | jq -r '.decryption_nonce')
got_mime=$(printf '%s' "$fields" | jq -r '.mime_type')
info "D decoded key=${got_key:0:16}… nonce=${got_nonce:0:16}… mime=$got_mime"
assert_eq "$got_key" "$key" "$id" "decryption_key mismatch" || return
assert_eq "$got_nonce" "$nonce" "$id" "decryption_nonce mismatch" || return
assert_eq "$got_mime" "$mime" "$id" "mime_type mismatch" || return
record_result "$id" pass
}
test_06_dm_list_cursor_advance() {
banner "DM-06 — no-flag dm list advances the giftWrapSince cursor"
local id="dm-06 cursor advance"
# First no-flag list: drains everything and advances the cursor to now.
local first_out first_count
first_out=$(amy_json_d dm list --timeout 5) || {
record_result "$id" fail "first dm list failed"; return
}
first_count=$(printf '%s' "$first_out" | jq -r '.messages | length')
info "first dm list returned $first_count message(s)"
# Second no-flag list: nothing new should land.
local second_out second_count
second_out=$(amy_json_d dm list --timeout 5) || {
record_result "$id" fail "second dm list failed"; return
}
second_count=$(printf '%s' "$second_out" | jq -r '.messages | length')
info "second dm list returned $second_count message(s)"
if [[ "$first_count" -ge 1 && "$second_count" -eq 0 ]]; then
record_result "$id" pass
else
record_result "$id" fail "expected first>=1, second==0; got first=$first_count second=$second_count"
fi
}