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:
@@ -1,2 +0,0 @@
|
||||
state/
|
||||
state-headless/
|
||||
@@ -1,216 +0,0 @@
|
||||
# Interop Test Harnesses
|
||||
|
||||
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).
|
||||
Use this for final UI verification.
|
||||
- **`marmot-interop-headless.sh`** — zero prompts. Drives A via the `amy` CLI
|
||||
(`./gradlew :cli:installDist`) and B/C via `wn`. Runs every scenario
|
||||
end-to-end and exits with a pass/fail summary. Use this for CI and for
|
||||
iterating on the Nostr/Marmot plumbing without needing to touch a phone.
|
||||
|
||||
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
|
||||
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 | – |
|
||||
|
||||
### 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
|
||||
|
||||
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.
|
||||
@@ -1,98 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,55 +0,0 @@
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# headless/helpers.sh — thin wrappers that keep the per-test code tight.
|
||||
|
||||
# --- amy wrapper -------------------------------------------------------------
|
||||
amy_a() { "$AMY_BIN" --data-dir "$A_DIR" "$@"; }
|
||||
|
||||
# Run amy, log stderr, surface JSON on stdout, remember last result.
|
||||
amy_json() {
|
||||
local out
|
||||
if ! out=$(amy_a "$@" 2>>"$LOG_FILE"); then
|
||||
fail_msg "amy $*: exit $? (see $LOG_FILE)"
|
||||
printf '%s\n' "$out" >>"$LOG_FILE"
|
||||
return 1
|
||||
fi
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
# Convenience extractors — the CLI emits one JSON object per success so we can
|
||||
# jq with impunity.
|
||||
amy_field() {
|
||||
# usage: amy_field '.group_id' init [args...]
|
||||
local path="$1"; shift
|
||||
amy_json "$@" | jq -r "$path"
|
||||
}
|
||||
|
||||
# --- assertion helpers -------------------------------------------------------
|
||||
# Assert a substring is present in a variable; append a failed result on miss
|
||||
# and return 1. Positive case just logs.
|
||||
assert_contains() {
|
||||
local haystack="$1" needle="$2" test_id="$3" note="${4:-}"
|
||||
if [[ "$haystack" == *"$needle"* ]]; then
|
||||
info "assertion hit: $test_id contains \"$needle\""
|
||||
return 0
|
||||
fi
|
||||
fail_msg "$test_id: missing \"$needle\" (${note:-no note})"
|
||||
info "actual: $haystack"
|
||||
record_result "$test_id" fail "${note:-missing \"$needle\"}"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Assert two strings are equal (leniently trimmed).
|
||||
assert_eq() {
|
||||
local actual="$1" expected="$2" test_id="$3" note="${4:-}"
|
||||
if [[ "${actual// /}" == "${expected// /}" ]]; then
|
||||
info "assertion hit: $test_id \"$actual\" == \"$expected\""
|
||||
return 0
|
||||
fi
|
||||
fail_msg "$test_id: expected \"$expected\", got \"$actual\" (${note:-})"
|
||||
record_result "$test_id" fail "${note:-mismatch}"
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- wn-side pollers (delegates to lib.sh) -----------------------------------
|
||||
# Both exist in lib.sh already; this file only adds headless-specific niceties.
|
||||
@@ -1,24 +0,0 @@
|
||||
--- 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 {
|
||||
@@ -1,23 +0,0 @@
|
||||
--- 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",
|
||||
@@ -1,18 +0,0 @@
|
||||
--- 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?;
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
--- 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!(
|
||||
@@ -1,112 +0,0 @@
|
||||
# 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"
|
||||
}
|
||||
@@ -1,370 +0,0 @@
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# headless/setup.sh — preflight + daemon lifecycle + identity bootstrap.
|
||||
# Sourced from marmot-interop-headless.sh.
|
||||
|
||||
# --- preflight ---------------------------------------------------------------
|
||||
preflight() {
|
||||
banner "Preflight"
|
||||
for cmd in jq git curl cargo 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/headless/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
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# headless/tests-create.sh — tests 01..05.
|
||||
# Focus: KeyPackage discovery, group creation, invites, initial messages.
|
||||
|
||||
test_01_keypackage_discovery() {
|
||||
banner "Test 01 — KeyPackage publish & discovery (MIP-00)"
|
||||
local id="01 KeyPackage A<->B"
|
||||
|
||||
# B finds A's KP
|
||||
local raw ev
|
||||
raw=$(wn_b --json keys check "$A_NPUB" 2>>"$LOG_FILE" || true)
|
||||
ev=$(printf '%s' "$raw" | jq -r '.result.event_id // .event_id // empty')
|
||||
if [[ -z "$ev" || "$ev" == "null" ]]; then
|
||||
record_result "$id (B->A)" fail "wn couldn't find A's KP"; return
|
||||
fi
|
||||
info "B saw A's KP $ev"
|
||||
|
||||
# A finds B's KP (wn publishes automatically via create-identity flow? if not, ask).
|
||||
wn_b keys publish >>"$LOG_FILE" 2>&1 || warn "wn_b keys publish failed"
|
||||
sleep 3
|
||||
local out
|
||||
out=$(amy_json marmot key-package check "$B_NPUB" 2>/dev/null || true)
|
||||
if [[ -z "$out" ]]; then
|
||||
record_result "$id (A->B)" fail "amy couldn't find B's KP"; return
|
||||
fi
|
||||
info "A saw B's KP $(printf '%s' "$out" | jq -r .event_id)"
|
||||
|
||||
record_result "$id" pass
|
||||
}
|
||||
|
||||
test_02_a_creates_group() {
|
||||
banner "Test 02 — A creates group, invites B"
|
||||
local id="02 A->B create+invite"
|
||||
|
||||
# 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
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
# 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
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# headless/tests-extras.sh — tests 09, 10, 12, 13.
|
||||
# Focus: reactions/replies, concurrent commits, offline catchup, KP rotation.
|
||||
|
||||
test_09_reply_react_unreact() {
|
||||
banner "Test 09 — reply / react / unreact"
|
||||
local id="09 reply/react"
|
||||
|
||||
local gid 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"
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# headless/tests-manage.sh — tests 06, 07, 08, 11.
|
||||
# Focus: removal, metadata rename, admin promote/demote, leave.
|
||||
|
||||
test_06_member_removal() {
|
||||
banner "Test 06 — Member removal + forward secrecy"
|
||||
local id="06 member removal"
|
||||
|
||||
# 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
|
||||
}
|
||||
@@ -1,585 +0,0 @@
|
||||
# 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 "$(printf '%s' "$ans" | tr '[:upper:]' '[:lower:]')" 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" ${B_NPUB:+--account "$B_NPUB"} "$@"; }
|
||||
wn_c() { "$WN_BIN" --socket "$C_SOCKET" ${C_NPUB:+--account "$C_NPUB"} "$@"; }
|
||||
|
||||
# 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
|
||||
}
|
||||
|
||||
# ------- JSON helpers --------------------------------------------------------
|
||||
|
||||
# Extract MLS group ID as lowercase hex from wn JSON output.
|
||||
# Handles both formats:
|
||||
# - plain hex string (from `groups list`)
|
||||
# - {"value":{"vec":[...]}} serde struct (from `groups create`)
|
||||
# - flat byte array [n, ...] (from some responses)
|
||||
# Plus the three wrapper shapes wn actually uses:
|
||||
# - {"result": {"mls_group_id": ...}} (groups create)
|
||||
# - {"group": {"mls_group_id": ...}, "membership": ...} (groups invites[0])
|
||||
# - {"mls_group_id": ...} (bare)
|
||||
# Input: JSON string via stdin; optional 2nd arg = field name (default: mls_group_id)
|
||||
jq_group_id() {
|
||||
local field="${1:-mls_group_id}"
|
||||
jq -r --arg f "$field" '
|
||||
def byte2hex:
|
||||
. as $n |
|
||||
[($n / 16 | floor), ($n % 16)] |
|
||||
map(if . < 10 then (48 + .) else (87 + .) end) |
|
||||
implode;
|
||||
(.group // .result // .) |
|
||||
(.group // .) |
|
||||
.[$f] |
|
||||
if type == "string" then .
|
||||
elif (type == "object" and (.value.vec != null)) then
|
||||
[.value.vec[] | byte2hex] | join("")
|
||||
elif type == "array" then
|
||||
[.[] | byte2hex] | join("")
|
||||
else empty end
|
||||
' 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ------- polling helpers -----------------------------------------------------
|
||||
|
||||
# Snapshot currently-pending invites on <B|C> as a comma-separated list of
|
||||
# gids. Use this BEFORE triggering a publish that should produce a new
|
||||
# welcome, then pass the result to `wait_for_invite` as the ignore list so
|
||||
# a stale welcome left over from a previous harness run (wnd persists
|
||||
# pending invites across restarts) doesn't register as the arrival we
|
||||
# just triggered.
|
||||
snapshot_invites() {
|
||||
local who="$1" wnfn
|
||||
if [[ "$who" == "B" ]]; then wnfn=wn_b
|
||||
else wnfn=wn_c; fi
|
||||
local raw
|
||||
raw=$("$wnfn" --json groups invites 2>/dev/null || true)
|
||||
# jq_group_id reads one invite at a time, so iterate the array in shell
|
||||
# rather than trying to handle all shapes in a single jq expression.
|
||||
local gids=()
|
||||
while IFS= read -r one; do
|
||||
[[ -z "$one" || "$one" == "null" ]] && continue
|
||||
local g
|
||||
g=$(printf '%s' "$one" | jq_group_id)
|
||||
[[ -n "$g" ]] && gids+=("$g")
|
||||
done < <(printf '%s' "$raw" | jq -c '(.result // .) | .[]?' 2>/dev/null)
|
||||
# bash 3.2 (stock macOS) treats "${gids[*]}" on an empty array as an
|
||||
# unbound reference under `set -u`, so guard the expansion.
|
||||
if (( ${#gids[@]} > 0 )); then
|
||||
local IFS=','
|
||||
printf '%s' "${gids[*]}"
|
||||
fi
|
||||
}
|
||||
|
||||
# wait_for_invite <B|C> <timeout-seconds> [ignore-csv]
|
||||
# Echoes the first pending group_id that isn't listed in <ignore-csv>
|
||||
# (comma-separated hex gids). Use the ignore list to skip welcomes left
|
||||
# pending by previous harness runs — otherwise `wait_for_invite` can
|
||||
# fire on a stale invite and the caller ends up driving a group the
|
||||
# publisher isn't a member of, which then looks like a kind:445 drop.
|
||||
# Returns 1 on timeout.
|
||||
#
|
||||
# Also prints a heartbeat every ~10s with:
|
||||
# - elapsed/remaining time
|
||||
# - current count of pending welcomes (total, pre-filter)
|
||||
# - last relevant line of wnd stderr (grep for welcome/giftwrap/subscribe/error)
|
||||
# so a stalled poll gives the operator something to forward.
|
||||
wait_for_invite() {
|
||||
local who="$1" timeout="${2:-60}" ignore_csv="${3:-}" deadline gid start last_hb
|
||||
local wnfn data_dir
|
||||
if [[ "$who" == "B" ]]; then wnfn=wn_b; data_dir="$B_DIR"
|
||||
else wnfn=wn_c; data_dir="$C_DIR"; fi
|
||||
start=$(date +%s)
|
||||
deadline=$(( start + timeout ))
|
||||
last_hb=$start
|
||||
while [[ $(date +%s) -lt $deadline ]]; do
|
||||
# Post-v0.2 `wn --json groups invites` returns `{"result": [...]}`
|
||||
# (older builds returned the bare array). Peel the wrapper when
|
||||
# present so a pending invite is actually detected.
|
||||
local raw
|
||||
raw=$("$wnfn" --json groups invites 2>/dev/null || true)
|
||||
# Walk every pending invite (not just .[0]) so we skip past stales.
|
||||
while IFS= read -r one; do
|
||||
[[ -z "$one" || "$one" == "null" ]] && continue
|
||||
gid=$(printf '%s' "$one" | jq_group_id)
|
||||
[[ -z "$gid" ]] && continue
|
||||
if [[ ",$ignore_csv," != *",$gid,"* ]]; then
|
||||
printf '%s\n' "$gid"
|
||||
return 0
|
||||
fi
|
||||
done < <(printf '%s' "$raw" | jq -c '(.result // .) | .[]?' 2>/dev/null)
|
||||
|
||||
# Heartbeat every ~10s.
|
||||
local now=$(date +%s)
|
||||
if (( now - last_hb >= 10 )); then
|
||||
local elapsed=$(( now - start )) remaining=$(( deadline - now ))
|
||||
local pending
|
||||
pending=$(printf '%s' "$raw" | jq '(.result // .) | length' 2>/dev/null || echo "?")
|
||||
local recent=""
|
||||
if [[ -f "$data_dir/logs/stderr.log" ]]; then
|
||||
recent=$(tail -n 200 "$data_dir/logs/stderr.log" 2>/dev/null \
|
||||
| grep -iE 'welcome|giftwrap|gift_wrap|mls|subscribe|1059|444|error|warn' \
|
||||
| tail -n 1 || true)
|
||||
fi
|
||||
info "$who wait_for_invite +${elapsed}s (remaining ${remaining}s) pending=$pending ignoring=${ignore_csv:-<none>} tail: ${recent:-<no relevant wnd log yet>}"
|
||||
last_hb=$now
|
||||
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" \
|
||||
'(.result // .) | .[]? | 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" \
|
||||
'(.result // .) | .[]? | select((.pubkey // .public_key // "") == $p)' \
|
||||
>/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ------- output parsing ------------------------------------------------------
|
||||
# `wn` prints either JSON (with --json) or a yaml-ish "key: value" pretty form.
|
||||
# This helper extracts the pubkey (npub) from whichever form we got.
|
||||
extract_pubkey() {
|
||||
local raw="$1" v=""
|
||||
# JSON: single object
|
||||
v=$(printf '%s' "$raw" | jq -r '.pubkey // .npub // .public_key // empty' 2>/dev/null || true)
|
||||
if [[ -n "$v" && "$v" != "null" ]]; then printf '%s' "$v"; return; fi
|
||||
# JSON: {"result": [ {"pubkey": …}, … ]} — post-v0.2 `wn --json whoami` shape
|
||||
v=$(printf '%s' "$raw" | jq -r '.result[0].pubkey // .result[0].npub // .result[0].public_key // empty' 2>/dev/null || true)
|
||||
if [[ -n "$v" && "$v" != "null" ]]; then printf '%s' "$v"; return; fi
|
||||
# JSON: array of accounts (whoami may return a list)
|
||||
v=$(printf '%s' "$raw" | jq -r '.[0].pubkey // .[0].npub // .[0].public_key // empty' 2>/dev/null || true)
|
||||
if [[ -n "$v" && "$v" != "null" ]]; then printf '%s' "$v"; return; fi
|
||||
# yaml-ish "pubkey: npub1..."
|
||||
v=$(printf '%s\n' "$raw" | sed -n 's/^[[:space:]]*pubkey[[:space:]]*:[[:space:]]*//p' | head -n1)
|
||||
if [[ -n "$v" ]]; then printf '%s' "$v"; return; fi
|
||||
# yaml-ish "npub: npub1..." (alternative key)
|
||||
v=$(printf '%s\n' "$raw" | sed -n 's/^[[:space:]]*npub[[:space:]]*:[[:space:]]*//p' | head -n1)
|
||||
if [[ -n "$v" ]]; then printf '%s' "$v"; return; fi
|
||||
}
|
||||
|
||||
# Pure-awk bech32 decoder for npub1... → 32-byte lowercase hex. Needed because
|
||||
# `wn users show` only resolves identities the local daemon already knows
|
||||
# about, so A's npub (the Amethyst account) can't be converted via wn — and a
|
||||
# failed conversion leaks the raw npub into hex-equality checks like the
|
||||
# member-list assertion in Test 02, which then fails with
|
||||
# "member list missing npub1…".
|
||||
bech32_decode_npub() {
|
||||
local npub="$1"
|
||||
[[ "$npub" == npub1* ]] || return 1
|
||||
local body="${npub#npub1}"
|
||||
# Need at least 6 chars of checksum + some data.
|
||||
(( ${#body} > 6 )) || return 1
|
||||
local hex
|
||||
hex=$(awk -v data="$body" 'BEGIN {
|
||||
charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
for (i = 0; i < 32; i++) map[substr(charset, i + 1, 1)] = i
|
||||
data = substr(data, 1, length(data) - 6)
|
||||
acc = 0; bits = 0; out = ""
|
||||
for (i = 1; i <= length(data); i++) {
|
||||
c = substr(data, i, 1)
|
||||
if (!(c in map)) exit 1
|
||||
acc = acc * 32 + map[c]
|
||||
bits += 5
|
||||
while (bits >= 8) {
|
||||
bits -= 8
|
||||
pow = 1
|
||||
for (j = 0; j < bits; j++) pow *= 2
|
||||
byte = int(acc / pow)
|
||||
acc -= byte * pow
|
||||
out = out sprintf("%02x", byte)
|
||||
}
|
||||
}
|
||||
print out
|
||||
}' 2>/dev/null) || return 1
|
||||
# 32-byte x-only pubkey = 64 hex chars.
|
||||
[[ ${#hex} -eq 64 ]] || return 1
|
||||
printf '%s' "$hex"
|
||||
}
|
||||
|
||||
# npub -> hex conversion. Tries `wn users show` first (handy when it works,
|
||||
# since it also validates the npub against the daemon's view), then falls
|
||||
# back to a local bech32 decode so unknown npubs still convert. Returns the
|
||||
# input unchanged only if both paths fail, which should never happen for a
|
||||
# well-formed npub.
|
||||
npub_to_hex() {
|
||||
local npub="$1" raw hex
|
||||
raw=$(wn_b --json users show "$npub" 2>/dev/null || true)
|
||||
hex=$(printf '%s' "$raw" \
|
||||
| jq -r '(.result // .) | (.pubkey // .public_key // .hex // .[0]?.pubkey // .[0]?.public_key // empty)' \
|
||||
2>/dev/null || true)
|
||||
if [[ -z "$hex" || "$hex" == "null" ]]; then
|
||||
# fallback to yaml-ish output
|
||||
raw=$(wn_b users show "$npub" 2>/dev/null || true)
|
||||
hex=$(printf '%s\n' "$raw" | sed -n 's/^[[:space:]]*\(public_key\|hex\)[[:space:]]*:[[:space:]]*//p' | head -n1)
|
||||
fi
|
||||
if [[ -n "$hex" && "$hex" != "null" ]]; then
|
||||
printf '%s' "$hex"
|
||||
return
|
||||
fi
|
||||
if hex=$(bech32_decode_npub "$npub"); then
|
||||
printf '%s' "$hex"
|
||||
return
|
||||
fi
|
||||
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-
|
||||
}
|
||||
|
||||
# ------- daemon diagnostics --------------------------------------------------
|
||||
# Dumps everything we know about a daemon's state. Called before polling for
|
||||
# invites (baseline) and on every invite-poll failure so the operator can
|
||||
# forward a single log file that explains what the daemon saw (or didn't).
|
||||
#
|
||||
# Output is tee'd to both stderr (for live viewing) and $LOG_FILE (for forward).
|
||||
dump_daemon_diagnostics() {
|
||||
local who="$1" tag="${2:-diagnostics}" data_dir socket wnfn npub hex
|
||||
if [[ "$who" == "B" ]]; then
|
||||
data_dir="$B_DIR"; socket="$B_SOCKET"; wnfn=wn_b
|
||||
npub="${B_NPUB:-?}"; hex="${B_HEX:-?}"
|
||||
else
|
||||
data_dir="$C_DIR"; socket="$C_SOCKET"; wnfn=wn_c
|
||||
npub="${C_NPUB:-?}"; hex="${C_HEX:-?}"
|
||||
fi
|
||||
|
||||
printf '\n%s%s---- [%s] %s daemon diagnostics ----%s\n' \
|
||||
"$C_BOLD" "$C_CYAN" "$tag" "$who" "$C_RESET" >&2
|
||||
printf '\n==== [%s] %s daemon diagnostics ====\n' "$tag" "$who" >>"$LOG_FILE"
|
||||
|
||||
info "$who identity npub=$npub"
|
||||
info "$who identity hex =$hex"
|
||||
info "$who socket $socket"
|
||||
|
||||
# Relay list grouped by type — this is the critical bit for invite
|
||||
# debugging. whitenoise-rs subscribes to kind:1059 on Inbox relays only,
|
||||
# so if Amethyst doesn't see $B_NPUB's kind:10050 list pointing at these
|
||||
# URLs, the gift wrap will never reach B.
|
||||
step "$who relays (per type):"
|
||||
for t in nip65 inbox key_package; do
|
||||
local raw urls
|
||||
raw=$("$wnfn" --json relays list --type "$t" 2>/dev/null || true)
|
||||
urls=$(printf '%s' "$raw" \
|
||||
| jq -r '(.result // .) | .[]? | (.url // .relay.url // empty)' 2>/dev/null \
|
||||
| paste -sd',' -)
|
||||
info " $t: ${urls:-<none>}"
|
||||
printf ' %s %s raw: %s\n' "$who" "$t" "$raw" >>"$LOG_FILE"
|
||||
done
|
||||
|
||||
# Connection status per relay — shows which sockets are actually live.
|
||||
step "$who relay connection status:"
|
||||
"$wnfn" --json relays list 2>/dev/null \
|
||||
| jq -r '(.result // .)[]? | " \(.url // .relay.url // "?") [\(.type // "?")] [\(.status // .connection_status // "?")]"' 2>/dev/null \
|
||||
| tee -a "$LOG_FILE" >&2 || true
|
||||
|
||||
# Daemon-level subscription health.
|
||||
step "$who wn debug health:"
|
||||
local health
|
||||
health=$("$wnfn" --json debug health 2>/dev/null || "$wnfn" debug health 2>/dev/null || true)
|
||||
printf ' %s\n' "${health:-<no output>}" | tee -a "$LOG_FILE" >&2
|
||||
|
||||
# Relay-control state (subscriptions, filters, planes). Truncated to keep
|
||||
# stderr readable; full copy still goes to $LOG_FILE.
|
||||
step "$who wn debug relay-control-state (truncated to stderr; full in $LOG_FILE):"
|
||||
local rcs
|
||||
rcs=$("$wnfn" --json debug relay-control-state 2>/dev/null || "$wnfn" debug relay-control-state 2>/dev/null || true)
|
||||
printf '%s\n' "${rcs:-<no output>}" | head -n 20 | sed 's/^/ /' >&2
|
||||
printf '%s wn debug relay-control-state FULL:\n%s\n' "$who" "${rcs:-<no output>}" >>"$LOG_FILE"
|
||||
|
||||
# Pending welcomes already decrypted by the daemon. Non-empty here while
|
||||
# wait_for_invite is polling would be a race worth noticing.
|
||||
step "$who pending invites:"
|
||||
local inv
|
||||
inv=$("$wnfn" --json groups invites 2>/dev/null || true)
|
||||
printf ' %s\n' "${inv:-<no output>}" | tee -a "$LOG_FILE" >&2
|
||||
|
||||
# Tail of daemon stderr — contains the live 1059/welcome processing logs.
|
||||
step "$who wnd stderr (last 80 lines from $data_dir/logs/stderr.log):"
|
||||
if [[ -f "$data_dir/logs/stderr.log" ]]; then
|
||||
tail -n 80 "$data_dir/logs/stderr.log" 2>/dev/null | sed 's/^/ /' | tee -a "$LOG_FILE" >&2 || true
|
||||
else
|
||||
info " (no stderr log at $data_dir/logs/stderr.log)"
|
||||
fi
|
||||
|
||||
printf '%s%s---- end %s diagnostics ----%s\n\n' \
|
||||
"$C_BOLD" "$C_CYAN" "$who" "$C_RESET" >&2
|
||||
printf '==== end [%s] %s diagnostics ====\n\n' "$tag" "$who" >>"$LOG_FILE"
|
||||
}
|
||||
|
||||
# Asks the operator to capture Amethyst's MarmotDbg logcat so the script log
|
||||
# ends up with both sides of the pipe in one place.
|
||||
prompt_amethyst_logcat() {
|
||||
local note="${1:-paste below}"
|
||||
prompt_human "On the host running adb, capture Amethyst's Marmot logs:
|
||||
|
||||
adb logcat -d -v time | grep -E 'MarmotDbg|Marmot |MlsWelcome|GiftWrap' \\
|
||||
| tail -n 200 > /tmp/amethyst-marmot.log
|
||||
|
||||
Then paste /tmp/amethyst-marmot.log contents here so the failure report has
|
||||
both the Amethyst-side publish log and the wn-side subscription log ($note)."
|
||||
}
|
||||
|
||||
# Dump everything we know about an outbound kind:445 from a wn daemon, so
|
||||
# when the operator reports "Amethyst didn't see it" we can tell them which
|
||||
# side of the wire to investigate:
|
||||
#
|
||||
# - `messages list` output (did the daemon persist the message locally,
|
||||
# i.e. is this a relay-reach issue, a membership issue, or a pure
|
||||
# Amethyst-receive issue?)
|
||||
# - `groups show` metadata (which MLS-extension relays is wn using for
|
||||
# this group — these are the SAME relays Amethyst subscribes to, so if
|
||||
# the lists don't match that's the bug)
|
||||
# - live relay connection status
|
||||
#
|
||||
# Output is tee'd to stderr (live) and $LOG_FILE (forward).
|
||||
dump_outbound_diagnostics() {
|
||||
local who="$1" gid="$2" needle="${3:-}" tag="${4:-post-send}"
|
||||
local wnfn
|
||||
if [[ "$who" == "B" ]]; then wnfn=wn_b
|
||||
else wnfn=wn_c; fi
|
||||
|
||||
printf '\n%s%s---- [%s] %s outbound diagnostics (group %.8s…) ----%s\n' \
|
||||
"$C_BOLD" "$C_CYAN" "$tag" "$who" "$gid" "$C_RESET" >&2
|
||||
printf '\n==== [%s] %s outbound diagnostics (group %s) ====\n' "$tag" "$who" "$gid" >>"$LOG_FILE"
|
||||
|
||||
step "$who local view of latest messages in group"
|
||||
local list_raw
|
||||
list_raw=$("$wnfn" --json messages list "$gid" --limit 5 2>/dev/null || true)
|
||||
printf ' %s\n' "${list_raw:-<no output>}" >>"$LOG_FILE"
|
||||
printf '%s' "$list_raw" \
|
||||
| jq -r '(.result // .) | .[]? | " \(.id // .event_id // "?") \((.content // .text // "") | tostring | .[0:80])"' 2>/dev/null \
|
||||
| tee -a "$LOG_FILE" >&2 || true
|
||||
if [[ -n "$needle" ]]; then
|
||||
if printf '%s' "$list_raw" | jq -e --arg n "$needle" \
|
||||
'(.result // .) | .[]? | select((.content // .text // "") | contains($n))' \
|
||||
>/dev/null 2>&1; then
|
||||
info "$who local store contains \"$needle\" — send reached wn's own DB (kind:445 went out)"
|
||||
else
|
||||
warn "$who local store does NOT contain \"$needle\" — `messages send` never persisted; check stderr.log"
|
||||
fi
|
||||
fi
|
||||
|
||||
step "$who MLS group metadata (relays here are what Amethyst subscribes on)"
|
||||
local meta_raw
|
||||
meta_raw=$("$wnfn" --json groups show "$gid" 2>/dev/null || true)
|
||||
printf ' %s\n' "${meta_raw:-<no output>}" >>"$LOG_FILE"
|
||||
local meta_relays
|
||||
meta_relays=$(printf '%s' "$meta_raw" \
|
||||
| jq -r '(.result // .) | (.relays // .group.relays // [])[]? | (. | tostring)' 2>/dev/null \
|
||||
| paste -sd',' -)
|
||||
info " group relays (MLS extension): ${meta_relays:-<none — Amethyst will fall back to homeRelays>}"
|
||||
local meta_name meta_epoch
|
||||
meta_name=$(printf '%s' "$meta_raw" | jq -r '(.result // .) | (.name // .group.name // "?")' 2>/dev/null)
|
||||
meta_epoch=$(printf '%s' "$meta_raw" | jq -r '(.result // .) | (.epoch // .group.epoch // "?")' 2>/dev/null)
|
||||
info " group name: $meta_name epoch: $meta_epoch"
|
||||
|
||||
step "$who relay connection status right now"
|
||||
"$wnfn" --json relays list 2>/dev/null \
|
||||
| jq -r '(.result // .)[]? | " \(.url // .relay.url // "?") [\(.type // "?")] [\(.status // .connection_status // "?")]"' 2>/dev/null \
|
||||
| tee -a "$LOG_FILE" >&2 || true
|
||||
|
||||
printf '%s%s---- end outbound diagnostics ----%s\n\n' \
|
||||
"$C_BOLD" "$C_CYAN" "$C_RESET" >&2
|
||||
printf '==== end [%s] %s outbound diagnostics ====\n\n' "$tag" "$who" >>"$LOG_FILE"
|
||||
}
|
||||
|
||||
# ------- 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
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
#!/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)"
|
||||
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 "$SCRIPT_DIR/lib.sh"
|
||||
|
||||
# shellcheck source=headless/setup.sh
|
||||
source "$SCRIPT_DIR/headless/setup.sh"
|
||||
# shellcheck source=headless/helpers.sh
|
||||
source "$SCRIPT_DIR/headless/helpers.sh"
|
||||
# shellcheck source=headless/tests-create.sh
|
||||
source "$SCRIPT_DIR/headless/tests-create.sh"
|
||||
# shellcheck source=headless/tests-manage.sh
|
||||
source "$SCRIPT_DIR/headless/tests-manage.sh"
|
||||
# shellcheck source=headless/tests-extras.sh
|
||||
source "$SCRIPT_DIR/headless/tests-extras.sh"
|
||||
|
||||
# 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
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user