test(cli): move tests/ out of tools/, separate marmot vs dm
`tools/` is for libraries (arti-build); shell-based interop harnesses
that drive the `amy` binary belong with the CLI module. New layout:
cli/tests/
├── lib.sh # shared logging + result tracking
├── headless/helpers.sh # shared amy_a / amy_json / assertions
├── marmot/ # MLS group-messaging interop (vs whitenoise-rs)
│ ├── marmot-interop.sh
│ ├── marmot-interop-headless.sh
│ ├── setup.sh
│ ├── tests-create.sh
│ ├── tests-manage.sh
│ ├── tests-extras.sh
│ └── patches/
└── dm/ # NIP-17 DM interop (amy ↔ amy)
├── dm-interop-headless.sh
├── setup.sh
└── tests-dm.sh
Per-suite changes:
- Each suite owns its own setup.sh; previously the Marmot setup was at
headless/setup.sh and the DM setup at headless/setup-dm.sh, which
implied shared infra they don't actually share.
- `cli/tests/lib.sh` and `cli/tests/headless/helpers.sh` are the only
shared bits across suites.
- The DM harness still reuses Marmot's `start_local_relay` /
`stop_local_relay` (sourced via `../marmot/setup.sh`) — same relay
binary, no duplication.
- All sourcing paths updated; REPO_ROOT now climbs three levels (was
two when the harness lived under `tools/`); patches/ is now a sibling
of marmot/setup.sh rather than under marmot/headless/.
- `.gitignore` updated to cover both suites' state dirs.
cli/ROADMAP.md and cli/tests/README.md updated to point at the new paths.
No behavioural change — every test runs the same code; only the file
layout and source paths moved.
This commit is contained in:
Executable
+126
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# marmot-interop-headless.sh — zero-prompt, zero-internet interop harness.
|
||||
#
|
||||
# Drives Identity A via the `amy` CLI (./gradlew :cli:installDist) and
|
||||
# Identities B/C via whitenoise-rs `wn`/`wnd`. Spins up a local
|
||||
# nostr-rs-relay on ws://127.0.0.1:$RELAY_PORT so nothing ever leaves the
|
||||
# machine. 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 [--port N] [--no-build]
|
||||
#
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
|
||||
TESTS_DIR="$(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"
|
||||
|
||||
# Local relay wiring — cloned + built during preflight, started on
|
||||
# $RELAY_PORT. The harness never touches the public internet for test
|
||||
# traffic; wn/wnd/amy all point at this one loopback endpoint.
|
||||
RELAY_REPO="${RELAY_REPO:-$STATE_DIR/nostr-rs-relay}"
|
||||
RELAY_BIN="$RELAY_REPO/target/release/nostr-rs-relay"
|
||||
RELAY_DATA="$STATE_DIR/relay"
|
||||
RELAY_PORT="${RELAY_PORT:-8080}"
|
||||
RELAY_URL="ws://127.0.0.1:$RELAY_PORT"
|
||||
NO_BUILD=0
|
||||
|
||||
A_NPUB=""
|
||||
A_HEX=""
|
||||
B_NPUB=""
|
||||
B_HEX=""
|
||||
C_NPUB=""
|
||||
C_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,14p' "${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 "$TESTS_DIR/lib.sh"
|
||||
|
||||
# shellcheck source=setup.sh
|
||||
source "$SCRIPT_DIR/setup.sh"
|
||||
# shellcheck source=../headless/helpers.sh
|
||||
source "$TESTS_DIR/headless/helpers.sh"
|
||||
# shellcheck source=tests-create.sh
|
||||
source "$SCRIPT_DIR/tests-create.sh"
|
||||
# shellcheck source=tests-manage.sh
|
||||
source "$SCRIPT_DIR/tests-manage.sh"
|
||||
# shellcheck source=tests-extras.sh
|
||||
source "$SCRIPT_DIR/tests-extras.sh"
|
||||
|
||||
# Make sure Ctrl+C / SIGTERM / SIGHUP all run the full cleanup path —
|
||||
# otherwise wnd is nohup'd and keeps running after the script dies,
|
||||
# and the next run's `ss` check then complains the port is in use.
|
||||
# Trap INT/TERM/HUP forces `exit`, which triggers the EXIT handler once.
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
trap - EXIT INT TERM HUP
|
||||
stop_daemons
|
||||
stop_local_relay
|
||||
print_summary
|
||||
exit "$rc"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
trap 'exit 129' HUP
|
||||
|
||||
banner "Marmot headless interop harness ($RUN_TS)"
|
||||
preflight
|
||||
start_local_relay
|
||||
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
|
||||
test_14_wn_removes_a
|
||||
test_15_wn_member_leaves
|
||||
test_16_wn_keypackage_rotation
|
||||
Executable
+1326
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
--- a/src/whitenoise/relays.rs
|
||||
+++ b/src/whitenoise/relays.rs
|
||||
@@ -98,6 +98,21 @@
|
||||
}
|
||||
|
||||
pub(crate) fn defaults() -> Vec<Relay> {
|
||||
+ // marmot-interop-headless patch: honour $WHITENOISE_DISCOVERY_RELAYS
|
||||
+ // (comma-separated list) when present so newly created accounts only
|
||||
+ // ever get our loopback relay baked into their NIP-65 / inbox /
|
||||
+ // key-package lists. Without this override, `create-identity` stamps
|
||||
+ // the hard-coded public set into the account's relay lists, and every
|
||||
+ // later activate / publish burns connection budget on unreachable
|
||||
+ // sockets — enough to break inbox-plane activation and drop kind:1059.
|
||||
+ if let Ok(from_env) = std::env::var("WHITENOISE_DISCOVERY_RELAYS") {
|
||||
+ let parsed: Vec<Relay> = from_env
|
||||
+ .split(',').map(str::trim).filter(|s| !s.is_empty())
|
||||
+ .filter_map(|u| RelayUrl::parse(u).ok())
|
||||
+ .map(|url| Relay::new(&url))
|
||||
+ .collect();
|
||||
+ if !parsed.is_empty() { return parsed; }
|
||||
+ }
|
||||
let urls: &[&str] = if cfg!(debug_assertions) {
|
||||
&["ws://localhost:8080", "ws://localhost:7777"]
|
||||
} else {
|
||||
@@ -0,0 +1,23 @@
|
||||
--- a/src/relay_control/discovery.rs
|
||||
+++ b/src/relay_control/discovery.rs
|
||||
@@ -87,6 +87,20 @@
|
||||
|
||||
/// Initial curated relay set from the planning doc.
|
||||
pub(crate) fn curated_default_relays() -> Vec<RelayUrl> {
|
||||
+ // marmot-interop-headless patch: honour $WHITENOISE_DISCOVERY_RELAYS
|
||||
+ // (comma-separated list) when present, so the harness can force wnd
|
||||
+ // to use a loopback relay instead of the baked-in public set.
|
||||
+ if let Ok(from_env) = std::env::var("WHITENOISE_DISCOVERY_RELAYS") {
|
||||
+ let parsed: Vec<RelayUrl> = from_env
|
||||
+ .split(',')
|
||||
+ .map(str::trim)
|
||||
+ .filter(|s| !s.is_empty())
|
||||
+ .filter_map(|u| RelayUrl::parse(u).ok())
|
||||
+ .collect();
|
||||
+ if !parsed.is_empty() {
|
||||
+ return parsed;
|
||||
+ }
|
||||
+ }
|
||||
[
|
||||
"wss://index.hzrd149.com",
|
||||
"wss://indexer.coracle.social",
|
||||
@@ -0,0 +1,18 @@
|
||||
--- a/src/bin/wnd.rs
|
||||
+++ b/src/bin/wnd.rs
|
||||
@@ -22,6 +22,15 @@
|
||||
let args = Args::parse();
|
||||
let config = Config::resolve(args.data_dir.as_ref(), args.logs_dir.as_ref());
|
||||
|
||||
+ // marmot-interop-headless patch: allow sandboxes/CI without a real
|
||||
+ // kernel keyring to fall back to the integration-tests mock keyring
|
||||
+ // by setting $WHITENOISE_MOCK_KEYRING=1. Requires the daemon to be
|
||||
+ // built with --features cli,integration-tests. No effect otherwise.
|
||||
+ #[cfg(feature = "integration-tests")]
|
||||
+ if std::env::var("WHITENOISE_MOCK_KEYRING").is_ok() {
|
||||
+ Whitenoise::initialize_mock_keyring_store();
|
||||
+ }
|
||||
+
|
||||
let wn_config = WhitenoiseConfig::new(&config.data_dir, &config.logs_dir, KEYRING_SERVICE_ID);
|
||||
Whitenoise::initialize_whitenoise(wn_config).await?;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
--- a/src/whitenoise/event_processor/account_event_processor.rs
|
||||
+++ b/src/whitenoise/event_processor/account_event_processor.rs
|
||||
@@ -178,7 +178,22 @@
|
||||
}
|
||||
Err(e) => {
|
||||
// Handle retry logic for actual processing errors
|
||||
- if retry_info.should_retry() {
|
||||
+ // marmot-interop-headless patch: MLS errors that come from
|
||||
+ // mdk are ALREADY terminal — mdk doesn't retry internally, so
|
||||
+ // any Err it returns (Unprocessable, PreviouslyFailed, decrypt
|
||||
+ // failure, group-not-found, etc.) is provably permanent.
|
||||
+ // Retrying those 10 times with exponential backoff (total
|
||||
+ // ~17 min) just blocks later decryptable commits behind a
|
||||
+ // queue of doomed retries, so every later join / rename /
|
||||
+ // leave propagation races the test timeout. Treat them all
|
||||
+ // as one-shot: log once, move on.
|
||||
+ let is_terminal = matches!(
|
||||
+ e,
|
||||
+ WhitenoiseError::MlsMessageUnprocessable(_)
|
||||
+ | WhitenoiseError::MlsMessagePreviouslyFailed
|
||||
+ | WhitenoiseError::MdkCoreError(_),
|
||||
+ );
|
||||
+ if !is_terminal && retry_info.should_retry() {
|
||||
self.schedule_retry(event, source, retry_info, e);
|
||||
} else {
|
||||
tracing::error!(
|
||||
@@ -0,0 +1,370 @@
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# setup.sh — preflight + daemon lifecycle + identity bootstrap.
|
||||
# Sourced from marmot-interop-headless.sh.
|
||||
|
||||
# --- preflight ---------------------------------------------------------------
|
||||
preflight() {
|
||||
banner "Preflight"
|
||||
for cmd in jq git curl cargo protoc patch; do
|
||||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||
fail_msg "missing required tool: $cmd"
|
||||
case "$cmd" in
|
||||
protoc) info "hint: apt-get install protobuf-compiler (or brew install protobuf on macOS)" ;;
|
||||
patch) info "hint: apt-get install patch" ;;
|
||||
esac
|
||||
exit 1
|
||||
fi
|
||||
info "$cmd: $(command -v "$cmd")"
|
||||
done
|
||||
|
||||
# Build `amy` via gradle if missing.
|
||||
#
|
||||
# jitpack.io and dl.google.com both return transient 503s on a non-trivial
|
||||
# fraction of cold-cache fetches, and Gradle disables the entire repository
|
||||
# for the rest of the build the moment a single 503 lands — so one bad
|
||||
# roll aborts the whole harness. Retry a few times; each attempt resumes
|
||||
# from Gradle's cache so only the still-missing artifacts get re-fetched.
|
||||
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
|
||||
local attempt max_attempts=4
|
||||
for attempt in $(seq 1 $max_attempts); do
|
||||
step "building :cli:installDist (attempt $attempt/$max_attempts)"
|
||||
if ( cd "$REPO_ROOT" && ./gradlew :cli:installDist ) 2>&1 | tee -a "$LOG_FILE" \
|
||||
&& [[ -x "$AMY_BIN" ]]; then
|
||||
break
|
||||
fi
|
||||
[[ "$attempt" -lt "$max_attempts" ]] && warn "gradle build failed (likely transient jitpack/Google 503) — retrying"
|
||||
done
|
||||
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 [[ ! -d "$WN_REPO/.git" ]]; then
|
||||
if [[ "$NO_BUILD" -eq 1 ]]; then
|
||||
fail_msg "whitenoise-rs checkout missing at $WN_REPO and --no-build set"; exit 1
|
||||
fi
|
||||
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
|
||||
|
||||
# Four harness-only patches to wnd so it runs fully offline / in
|
||||
# sandboxes that block outbound + kernel keyring:
|
||||
# 1. discovery-env: honour $WHITENOISE_DISCOVERY_RELAYS so we can
|
||||
# point wnd at our loopback relay instead of the baked-in public
|
||||
# set. Without it wnd exits with NoRelayConnections.
|
||||
# 2. mock-keyring: honour $WHITENOISE_MOCK_KEYRING so wnd uses the
|
||||
# integration-tests mock keyring store when the kernel keyutils
|
||||
# syscalls are blocked (common in containers / CI).
|
||||
# 3. defaults-env: reuse the same env var so `Relay::defaults()`
|
||||
# (what `create-identity` stamps into the new account's NIP-65 /
|
||||
# inbox / key-package lists) points at the loopback relay too.
|
||||
# Without it every account wnd creates carries damus.io /
|
||||
# primal.net / nos.lol, and every later activate / publish burns
|
||||
# connection budget on unreachable sockets — enough to break the
|
||||
# account-inbox subscription plane and drop kind:1059 delivery.
|
||||
# 4. skip-unprocessable-retry: when mdk-core returns
|
||||
# `MlsMessageUnprocessable` (pre-membership commit, too-old epoch)
|
||||
# the message is provably undecryptable — retrying it ten times
|
||||
# with exponential backoff (total ~17 min) just blocks later
|
||||
# decryptable commits behind a queue of doomed retries, which in
|
||||
# the harness manifests as "A already left" / "name unchanged"
|
||||
# timeouts. The patch treats that error as terminal.
|
||||
local -a patches=(
|
||||
"whitenoise-discovery-env.patch"
|
||||
"whitenoise-mock-keyring.patch"
|
||||
"whitenoise-defaults-env.patch"
|
||||
"whitenoise-skip-unprocessable-retry.patch"
|
||||
)
|
||||
# Apply each patch with a real exit-code check. The previous version
|
||||
# swallowed patch's exit status via `| tee`, which meant a miscounted
|
||||
# hunk header silently left the marker touched and the binary unpatched
|
||||
# — the resulting wn retried provably-doomed MLS messages for ~17min
|
||||
# and every later test flapped or timed out. Fail fast instead.
|
||||
for name in "${patches[@]}"; do
|
||||
local marker="$WN_REPO/.headless-patched-${name%.patch}"
|
||||
if [[ ! -f "$marker" ]]; then
|
||||
step "patching whitenoise-rs: $name"
|
||||
if ( cd "$WN_REPO" && patch -p1 --forward --reject-file=- \
|
||||
<"$SCRIPT_DIR/patches/$name" >>"$LOG_FILE" 2>&1 ); then
|
||||
touch "$marker"
|
||||
# Invalidate the previous build so the patched source is picked up.
|
||||
rm -f "$WN_BIN" "$WND_BIN"
|
||||
else
|
||||
fail_msg "patch $name failed — see $LOG_FILE"
|
||||
tail -n 30 "$LOG_FILE" | sed 's/^/ /' >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# cargo's transitive deps (rustup, crates.io) both return 503 on cold
|
||||
# caches often enough that a single attempt fails ~30% of the time.
|
||||
# Retry each cargo build until the binary actually exists or we've
|
||||
# exhausted the budget — the build is incremental so retries are cheap.
|
||||
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
|
||||
local attempt max=4
|
||||
for attempt in $(seq 1 $max); do
|
||||
step "building wn + wnd (attempt $attempt/$max, ~5 min first run)"
|
||||
( cd "$WN_REPO" && \
|
||||
cargo build --release --features cli,integration-tests --bin wn --bin wnd ) \
|
||||
2>&1 | tee -a "$LOG_FILE"
|
||||
[[ -x "$WN_BIN" && -x "$WND_BIN" ]] && break
|
||||
[[ "$attempt" -lt "$max" ]] && warn "wn/wnd build failed (likely transient 503 from rustup or crates.io) — retrying"
|
||||
done
|
||||
[[ -x "$WN_BIN" && -x "$WND_BIN" ]] || {
|
||||
fail_msg "wn/wnd still missing after $max build attempts"; exit 1
|
||||
}
|
||||
fi
|
||||
info "wn: $WN_BIN"
|
||||
info "wnd: $WND_BIN"
|
||||
|
||||
# Clone/build nostr-rs-relay — the harness's single loopback relay.
|
||||
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 (likely transient 503 from crates.io) — retrying"
|
||||
done
|
||||
[[ -x "$RELAY_BIN" ]] || {
|
||||
fail_msg "nostr-rs-relay still missing after $max build attempts"; exit 1
|
||||
}
|
||||
fi
|
||||
info "relay bin: $RELAY_BIN"
|
||||
}
|
||||
|
||||
# --- local relay -------------------------------------------------------------
|
||||
# Start nostr-rs-relay on $RELAY_PORT with a minimal config. Every test
|
||||
# runs against this one loopback endpoint — no external network traffic.
|
||||
start_local_relay() {
|
||||
banner "Starting local nostr-rs-relay on $RELAY_URL"
|
||||
mkdir -p "$RELAY_DATA" "$RELAY_DATA/logs"
|
||||
|
||||
# Render a minimal config file each run so port/limits come from the
|
||||
# harness rather than whatever was left on disk from a previous session.
|
||||
cat >"$RELAY_DATA/config.toml" <<EOF
|
||||
[info]
|
||||
relay_url = "$RELAY_URL"
|
||||
name = "amethyst-headless-harness"
|
||||
description = "Loopback relay for marmot-interop-headless.sh — do not use for anything real."
|
||||
|
||||
[database]
|
||||
data_directory = "$RELAY_DATA"
|
||||
|
||||
[network]
|
||||
address = "127.0.0.1"
|
||||
port = $RELAY_PORT
|
||||
|
||||
[options]
|
||||
reject_future_seconds = 3600
|
||||
|
||||
[limits]
|
||||
# Keep kind:444 / 445 / 1059 / 30443 wide open — the whole point is
|
||||
# exercising Marmot traffic the public relays reject.
|
||||
max_event_bytes = 524288
|
||||
max_ws_message_bytes = 1048576
|
||||
max_ws_frame_bytes = 1048576
|
||||
EOF
|
||||
|
||||
# Abort early if something else is already bound to the port — failing
|
||||
# with a clear error beats a mysterious-looking daemon stall later.
|
||||
if ss -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]$RELAY_PORT\$"; then
|
||||
fail_msg "port $RELAY_PORT already in use — pass --port N or free it"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
nohup "$RELAY_BIN" --db "$RELAY_DATA" --config "$RELAY_DATA/config.toml" \
|
||||
>"$RELAY_DATA/logs/stdout.log" 2>"$RELAY_DATA/logs/stderr.log" &
|
||||
echo "$!" > "$RELAY_DATA/pid"
|
||||
step "relay pid $(cat "$RELAY_DATA/pid"); waiting for $RELAY_URL …"
|
||||
|
||||
local deadline=$(( $(date +%s) + 20 ))
|
||||
while [[ $(date +%s) -lt $deadline ]]; do
|
||||
if curl -sSf -m 1 "http://127.0.0.1:$RELAY_PORT/" >/dev/null 2>&1; then
|
||||
info "relay up"
|
||||
return 0
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
fail_msg "relay never came up (see $RELAY_DATA/logs/stderr.log)"
|
||||
tail -n 40 "$RELAY_DATA/logs/stderr.log" 2>/dev/null | sed 's/^/ /' >&2 || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
stop_local_relay() {
|
||||
local pid_file="$RELAY_DATA/pid"
|
||||
[[ -f "$pid_file" ]] || return 0
|
||||
local pid; pid=$(cat "$pid_file" 2>/dev/null || echo "")
|
||||
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
|
||||
info "stopping relay pid $pid"
|
||||
kill "$pid" 2>/dev/null || true
|
||||
sleep 1
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$pid_file"
|
||||
}
|
||||
|
||||
# --- 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"
|
||||
# The mock keyring (WHITENOISE_MOCK_KEYRING=1) is in-memory only and
|
||||
# resets to empty on every wnd restart, but the SQLite databases that
|
||||
# wnd writes under $data_dir persist across runs and reference keys that
|
||||
# no longer exist — wnd then bails with KeyringEntryMissingForExistingDatabase
|
||||
# before it can even open a socket. Wipe the keyring-dependent state on
|
||||
# each start so the daemon always comes up cold and consistent. Logs
|
||||
# and the pid file are preserved for post-mortem.
|
||||
if [[ -d "$data_dir" ]]; then
|
||||
find "$data_dir" -mindepth 1 -maxdepth 1 \
|
||||
! -name 'logs' ! -name 'pid' \
|
||||
-exec rm -rf {} + 2>/dev/null || true
|
||||
fi
|
||||
mkdir -p "$data_dir/logs" "$data_dir/release"
|
||||
# Env vars consumed by the two harness-only wnd patches applied in
|
||||
# preflight:
|
||||
# WHITENOISE_DISCOVERY_RELAYS — forces the discovery plane at our
|
||||
# loopback relay (kills the "can't reach nos.lol" exit path).
|
||||
# WHITENOISE_MOCK_KEYRING — swaps in the integration-tests mock
|
||||
# secret store so wnd doesn't fall over when the kernel blocks
|
||||
# keyutils syscalls.
|
||||
# Both are harmless on a real host with connectivity + a real keyring.
|
||||
WHITENOISE_DISCOVERY_RELAYS="$RELAY_URL" \
|
||||
WHITENOISE_MOCK_KEYRING=1 \
|
||||
nohup "$WND_BIN" --data-dir "$data_dir" --logs-dir "$data_dir/logs" \
|
||||
>"$data_dir/logs/stdout.log" 2>"$data_dir/logs/stderr.log" &
|
||||
local pid=$!
|
||||
echo "$pid" > "$data_dir/pid"
|
||||
info "$name pid $pid; waiting for socket at $socket …"
|
||||
local deadline=$(( $(date +%s) + 30 ))
|
||||
while [[ $(date +%s) -lt $deadline ]]; do
|
||||
if [[ -S "$socket" ]] && "$WN_BIN" --socket "$socket" whoami >/dev/null 2>&1; then
|
||||
info "$name ready"; return 0
|
||||
fi
|
||||
# Exit early if wnd already crashed — no point waiting the full 30s.
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
# Dump the actual failure so the operator doesn't have to chase a path.
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
fail_msg "$name daemon still running (pid $pid) but socket $socket never appeared within 30s"
|
||||
kill "$pid" 2>/dev/null || true
|
||||
else
|
||||
fail_msg "$name daemon (pid $pid) exited before creating socket $socket"
|
||||
fi
|
||||
if [[ -s "$data_dir/logs/stderr.log" ]]; then
|
||||
printf ' --- last 40 lines of %s ---\n' "$data_dir/logs/stderr.log" >&2
|
||||
tail -n 40 "$data_dir/logs/stderr.log" | sed 's/^/ /' >&2
|
||||
printf ' --- end stderr ---\n' >&2
|
||||
else
|
||||
info "stderr log is empty at $data_dir/logs/stderr.log"
|
||||
fi
|
||||
if [[ -s "$data_dir/logs/stdout.log" ]]; then
|
||||
printf ' --- last 20 lines of %s ---\n' "$data_dir/logs/stdout.log" >&2
|
||||
tail -n 20 "$data_dir/logs/stdout.log" | sed 's/^/ /' >&2
|
||||
printf ' --- end stdout ---\n' >&2
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
stop_daemons() {
|
||||
for d in "$B_DIR" "$C_DIR"; do
|
||||
if [[ -f "$d/pid" ]]; then
|
||||
local pid; pid=$(cat "$d/pid" 2>/dev/null || echo "")
|
||||
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
|
||||
info "stopping daemon pid $pid"
|
||||
kill "$pid" 2>/dev/null || true
|
||||
sleep 1
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$d/pid"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# --- identities --------------------------------------------------------------
|
||||
ensure_identity_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
|
||||
# create-identity sometimes exits non-zero (e.g. transient "failed to
|
||||
# connect to any relays") even though the account was created. Probe
|
||||
# --json whoami afterwards before giving up.
|
||||
"$cmd" create-identity 2>&1 | tee -a "$LOG_FILE" || true
|
||||
raw=$("$cmd" --json whoami 2>/dev/null || true)
|
||||
npub=$(extract_pubkey "$raw")
|
||||
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 ------------------------------------------------------------------
|
||||
# Point all three identities at the loopback relay. We never publish any
|
||||
# test traffic off-box — public relays reject kind:445 anyway and the
|
||||
# goal here is tight, deterministic iteration.
|
||||
configure_relays() {
|
||||
banner "Configuring relays → $RELAY_URL"
|
||||
amy_a relay add "$RELAY_URL" --type all >/dev/null
|
||||
for t in nip65 inbox key_package; do
|
||||
wn_b relays add --type "$t" "$RELAY_URL" 2>/dev/null || true
|
||||
wn_c relays add --type "$t" "$RELAY_URL" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# A advertises its NIP-65 + DM inbox lists so B/C can discover where to
|
||||
# deliver gift wraps. With a single shared relay the lookup is trivial
|
||||
# but we still publish 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"
|
||||
# Give nostr-rs-relay a breath to fsync the kind:10002 / 10050 / 30443
|
||||
# writes and push them out on the discovery subscription so that the
|
||||
# first `wn keys check` that follows actually sees them instead of
|
||||
# racing the relay's WAL flush.
|
||||
sleep 2
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# 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"
|
||||
|
||||
# amy keys groups by the MIP-01 nostr_group_id (`group_id`), wn (via
|
||||
# mdk-core) keys by the MLS GroupContext groupId (`mls_group_id`). Both
|
||||
# are 32 random bytes and they are NOT the same. We need both: amy calls
|
||||
# use `gid`, wn calls use `mls_gid`.
|
||||
local out gid mls_gid
|
||||
out=$(amy_json marmot group create --name "Interop-02") || {
|
||||
record_result "$id" fail "create returned no group_id"; return
|
||||
}
|
||||
gid=$(printf '%s' "$out" | jq -r '.group_id')
|
||||
mls_gid=$(printf '%s' "$out" | jq -r '.mls_group_id')
|
||||
save_state GROUP_02 "$gid"
|
||||
save_state GROUP_02_MLS "$mls_gid"
|
||||
info "created group nostr=$gid mls=$mls_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 takes the nostr id; wait_for_message calls
|
||||
# `wn messages list` which needs the MLS id.
|
||||
amy_json marmot message send "$gid" "hello from amethyst" >/dev/null || {
|
||||
record_result "$id" fail "amy send failed"; return
|
||||
}
|
||||
if ! wait_for_message B "$mls_gid" "hello from amethyst" 90; then
|
||||
record_result "$id" fail "B didn't receive A's message"; return
|
||||
fi
|
||||
|
||||
# B -> A message
|
||||
wn_b messages send "$mls_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"
|
||||
|
||||
# `wn groups create` returns the MLS group id (what wn's messages API
|
||||
# expects). amy indexes by the MIP-01 nostr_group_id, which we only learn
|
||||
# once A has processed the welcome and we can ask `amy await group` for
|
||||
# it. Keep them distinct so each CLI gets the id it understands.
|
||||
local out mls_gid
|
||||
out=$(wn_b --json groups create "Interop-03" "$A_NPUB" 2>>"$LOG_FILE") || {
|
||||
record_result "$id" fail "wn groups create failed"; return
|
||||
}
|
||||
mls_gid=$(printf '%s' "$out" | jq_group_id)
|
||||
if [[ -z "$mls_gid" ]]; then
|
||||
record_result "$id" fail "could not parse group_id"; return
|
||||
fi
|
||||
save_state GROUP_03_MLS "$mls_gid"
|
||||
info "mls_group_id: $mls_gid"
|
||||
|
||||
# A: poll until it joins, and capture its nostr_group_id.
|
||||
local a_out a_gid
|
||||
a_out=$(amy_json marmot await group --name "Interop-03" --timeout 30) || {
|
||||
record_result "$id" fail "A never joined B's group"; return
|
||||
}
|
||||
a_gid=$(printf '%s' "$a_out" | jq -r '.group_id')
|
||||
save_state GROUP_03 "$a_gid"
|
||||
info "A joined as nostr_group_id=$a_gid"
|
||||
|
||||
# B -> A message
|
||||
wn_b messages send "$mls_gid" "ping from wn" >/dev/null 2>&1 || true
|
||||
if ! amy_json marmot await message "$a_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 "$a_gid" "pong from amethyst" >/dev/null || {
|
||||
record_result "$id" fail "amy send pong failed"; return
|
||||
}
|
||||
if wait_for_message B "$mls_gid" "pong from amethyst" 90; 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 mls_gid
|
||||
gid=$(load_state GROUP_02 || true)
|
||||
mls_gid=$(load_state GROUP_02_MLS || 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
|
||||
}
|
||||
# wn's event_processor retries undecryptable pre-membership commits with
|
||||
# exponential backoff (2+4+8+16=~30s), and kind:445 processing is serial
|
||||
# per-account; a fresh joiner routinely needs ~60s to burn through that
|
||||
# retry queue before the add-C commit is applied and "hello three-member
|
||||
# world" decrypts. The 30s we used for the inline A<->B send/recv
|
||||
# wouldn't clear that.
|
||||
if wait_for_message B "$mls_gid" "hello three-member world" 90 \
|
||||
&& wait_for_message C "$c_gid" "hello three-member world" 90; 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" 90 \
|
||||
&& wait_for_message C "$gid" "joined from amethyst" 90; then
|
||||
record_result "$id" pass
|
||||
else
|
||||
record_result "$id" fail "B or C didn't see A's message"
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# 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 mls_gid
|
||||
gid=$(load_state GROUP_02 || true)
|
||||
mls_gid=$(load_state GROUP_02_MLS || 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 "$mls_gid" 2>/dev/null \
|
||||
| jq -e --arg p "$A_HEX" '(.result // .) | .[]? | 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 "$mls_gid" "anchor for reactions" >/dev/null 2>&1 || true
|
||||
sleep 3
|
||||
local msg_id
|
||||
msg_id=$(wn_b --json messages list "$mls_gid" --limit 10 2>/dev/null \
|
||||
| jq -r '[(.result // .) | .[]? | 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 "$mls_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 "$mls_gid" "replying via amy" 90; 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 mls_gid
|
||||
gid=$(load_state GROUP_02 || true)
|
||||
mls_gid=$(load_state GROUP_02_MLS || true)
|
||||
if [[ -z "${gid:-}" ]]; then
|
||||
record_result "$id" skip "no GROUP_02"; return
|
||||
fi
|
||||
if ! wn_b --json groups members "$mls_gid" 2>/dev/null \
|
||||
| jq -e --arg p "$A_HEX" '(.result // .) | .[]? | 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 "$mls_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 "$mls_gid" 2>/dev/null | jq -r '(.result // .) | .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 "$mls_gid" "post-race ping" >/dev/null 2>&1 || true
|
||||
if amy_json marmot await message "$gid" --match "post-race ping" --timeout 90 >/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"
|
||||
|
||||
# wn-side keys groups by mls_group_id; amy-side by nostr_group_id. Learn
|
||||
# the amy side from `amy await group` so later amy calls target the right
|
||||
# group.
|
||||
local out mls_gid a_out a_gid
|
||||
out=$(wn_b --json groups create "Interop-12" "$A_NPUB" 2>>"$LOG_FILE")
|
||||
mls_gid=$(printf '%s' "$out" | jq_group_id)
|
||||
[[ -n "$mls_gid" ]] || { record_result "$id" fail "couldn't create Interop-12"; return; }
|
||||
save_state GROUP_12_MLS "$mls_gid"
|
||||
|
||||
# A joins.
|
||||
a_out=$(amy_json marmot await group --name "Interop-12" --timeout 30) || {
|
||||
record_result "$id" fail "A never received Interop-12 invite"; return
|
||||
}
|
||||
a_gid=$(printf '%s' "$a_out" | jq -r '.group_id')
|
||||
save_state GROUP_12 "$a_gid"
|
||||
|
||||
# "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 "$mls_gid" "offline-msg-$i" >/dev/null 2>&1 || true
|
||||
sleep 1
|
||||
done
|
||||
wn_b groups add-members "$mls_gid" "$C_NPUB" >/dev/null 2>&1 || true
|
||||
wait_for_invite C 30 >/dev/null && wn_c groups accept "$mls_gid" >/dev/null 2>&1 || true
|
||||
for i in 6 7 8; do
|
||||
wn_b messages send "$mls_gid" "offline-msg-$i" >/dev/null 2>&1 || true
|
||||
sleep 1
|
||||
done
|
||||
wn_b groups rename "$mls_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 "$a_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 "$a_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
|
||||
}
|
||||
|
||||
# -- Inverted-role tests ----------------------------------------------------
|
||||
# Tests 01–13 mostly exercise amethyst as the committer and wn as the
|
||||
# receiver. The scenarios below are complementary: wn drives the state
|
||||
# change and amy is the receiver that must accept, verify and apply it.
|
||||
# This widens coverage for the post-fix inbound authenticity checks
|
||||
# (membership_tag, FramedContentTBS signature, LeafNode lifetime,
|
||||
# confirmation_tag) that now run on every commit amy processes.
|
||||
|
||||
test_14_wn_removes_a() {
|
||||
banner "Test 14 — wn (admin) removes A; amy processes Remove"
|
||||
local id="14 wn removes amy"
|
||||
|
||||
# Known gap: wn (mdk-core/openmls) emits the filtered direct-path
|
||||
# form of UpdatePath on Remove commits per RFC 9420 §7.7 — when the
|
||||
# copath of a direct-path node has an empty resolution (every leaf
|
||||
# under it is blank) the corresponding UpdatePathNode is omitted.
|
||||
# Quartz's RatchetTree.applyUpdatePath currently requires the
|
||||
# unfiltered node count (`pathNodes.size == directPath.size`) so
|
||||
# every wn->amy Remove triggers
|
||||
# "UpdatePath node count (N) doesn't match direct path length (N+k)".
|
||||
# That's a pre-existing quartz conformance bug, out of scope for
|
||||
# this branch; the harness carries the test so it starts passing
|
||||
# the moment the filtered-path path is wired up.
|
||||
record_result "$id" skip "pending filtered_direct_path support in applyUpdatePath"
|
||||
}
|
||||
|
||||
test_15_wn_member_leaves() {
|
||||
banner "Test 15 — wn_c leaves; amy + wn_b process SelfRemove"
|
||||
local id="15 wn_c leaves"
|
||||
|
||||
# Same filtered_direct_path gap as test 14: when wn_c leaves a
|
||||
# 3-member group, wn_b folds the SelfRemove into a commit whose
|
||||
# UpdatePath uses RFC 9420 §7.7 filtering, and amy's strict
|
||||
# applyUpdatePath rejects it. Skip until quartz handles the
|
||||
# filtered form on inbound.
|
||||
record_result "$id" skip "pending filtered_direct_path support in applyUpdatePath"
|
||||
}
|
||||
|
||||
test_16_wn_keypackage_rotation() {
|
||||
banner "Test 16 — wn rotates KeyPackage; amy discovers new KP"
|
||||
local id="16 wn keypackage rotation"
|
||||
|
||||
# amy's KeyPackageFetcher.fetchKeyPackage calls client.fetchFirst,
|
||||
# which returns the first matching event a relay sends — nostr-rs-relay
|
||||
# typically serves kind:443 events in storage order, not created_at
|
||||
# order, so after a rotation amy may keep seeing the older event_id
|
||||
# depending on which arrives first. Making this test deterministic
|
||||
# requires a "fetch latest by created_at" KeyPackage fetcher; until
|
||||
# then the check flaps. The inverse direction (test 13, amy rotates
|
||||
# and wn sees via `wn keys check` which is an addressable index) is
|
||||
# the reliable one.
|
||||
record_result "$id" skip "pending createdAt-sorted KeyPackage fetch path"
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# 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"
|
||||
|
||||
# MIP-03 only admins may commit Remove proposals. In GROUP_05 (wn-created
|
||||
# by B, A joined later) A is not an admin, so the test used to fail with
|
||||
# `IllegalStateException: non-admin members may only commit...`. Test on
|
||||
# GROUP_02 instead, where amy is the creator and therefore sole admin,
|
||||
# and where test 04 has already added C. amy calls use the nostr id,
|
||||
# wn calls use the MLS id.
|
||||
local gid mls_gid
|
||||
gid=$(load_state GROUP_02 || true)
|
||||
mls_gid=$(load_state GROUP_02_MLS || true)
|
||||
if [[ -z "${gid:-}" || -z "${mls_gid:-}" ]]; then
|
||||
record_result "$id" skip "no GROUP_02"; 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) + 120 )) removed=0
|
||||
while [[ $(date +%s) -lt $deadline ]]; do
|
||||
if ! wn_c --json groups members "$mls_gid" 2>/dev/null \
|
||||
| jq -e --arg p "$C_HEX" '(.result // .) | .[]? | 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 "$mls_gid" "after removing C" 90 || {
|
||||
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 "$mls_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"
|
||||
|
||||
# amy was the creator of GROUP_02 so its own nostr_group_id is saved as
|
||||
# GROUP_02; wn keys its copy by the MLS group id saved as GROUP_02_MLS.
|
||||
# Pass each CLI the id it understands.
|
||||
local gid mls_gid
|
||||
gid=$(load_state GROUP_02 || true)
|
||||
mls_gid=$(load_state GROUP_02_MLS || true)
|
||||
if [[ -z "${gid:-}" || -z "${mls_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) + 120 )) seen=""
|
||||
while [[ $(date +%s) -lt $deadline ]]; do
|
||||
seen=$(wn_b --json groups show "$mls_gid" 2>/dev/null | jq -r '(.result // .) | .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
|
||||
}
|
||||
|
||||
# MIP-01: only admins may rename. GROUP_02 was created by amy (sole
|
||||
# admin), so for B's rename to be accepted by wn's MIP-01 check amy
|
||||
# must first promote B. amy adds B to admin_pubkeys via GCE, which
|
||||
# the harness consumes via `marmot group promote` — equivalent to
|
||||
# `wn groups promote` but issued by the quartz side. Without this
|
||||
# step B's own wn silently refuses the rename on its MIP-01
|
||||
# `ensure_account_is_group_admin` check, and the kind:445 is never
|
||||
# published.
|
||||
amy_json marmot group promote "$gid" "$B_NPUB" >/dev/null 2>&1 || {
|
||||
record_result "$id" fail "amy promote-B failed"; return
|
||||
}
|
||||
# Let wn apply the promote commit before issuing the rename.
|
||||
sleep 3
|
||||
|
||||
# Now B renames back and A should pick it up.
|
||||
wn_b groups rename "$mls_gid" "Interop-02-reverse" >/dev/null 2>&1 || true
|
||||
if amy_json marmot await rename "$gid" --name "Interop-02-reverse" --timeout 120 >/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"
|
||||
|
||||
# GROUP_03 was created by wn so both sides need different ids:
|
||||
# GROUP_03 → amy's nostr_group_id (captured in test 03 after
|
||||
# `amy await group` returned `.group_id`)
|
||||
# GROUP_03_MLS → wn's mls_group_id (wn's `groups create` output)
|
||||
local a_gid mls_gid
|
||||
a_gid=$(load_state GROUP_03 || true)
|
||||
mls_gid=$(load_state GROUP_03_MLS || true)
|
||||
if [[ -z "${mls_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 "$mls_gid" "$C_NPUB" >/dev/null 2>&1 || true
|
||||
wait_for_invite C 30 >/dev/null && wn_c groups accept "$mls_gid" >/dev/null 2>&1 || true
|
||||
|
||||
# B promotes A.
|
||||
wn_b groups promote "$mls_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 "$a_gid" "$A_NPUB" --timeout 90 >/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 "$a_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 "$mls_gid" "$A_NPUB" >/dev/null 2>&1 || warn "demote returned nonzero"
|
||||
sleep 5
|
||||
|
||||
local admins
|
||||
admins=$(wn_b --json groups admins "$mls_gid" 2>/dev/null \
|
||||
| jq -r '(.result // .) | .[]?.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 mls_gid
|
||||
gid=$(load_state GROUP_02 || true)
|
||||
mls_gid=$(load_state GROUP_02_MLS || 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) + 120 )) gone=0
|
||||
while [[ $(date +%s) -lt $deadline ]]; do
|
||||
if ! wn_b --json groups members "$mls_gid" 2>/dev/null \
|
||||
| jq -e --arg p "$A_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' \
|
||||
>/dev/null 2>&1; then
|
||||
gone=1; break
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
if [[ "$gone" -eq 1 ]]; then
|
||||
record_result "$id" pass
|
||||
else
|
||||
record_result "$id" fail "A still in B's member list after leave"
|
||||
fi
|
||||
}
|
||||
Reference in New Issue
Block a user