# Testing ## Automated Tests ```bash just test-unit # unit tests — no infrastructure needed just test # unit + integration (starts Docker if needed) ``` `just test` runs unit tests plus integration tests against Postgres and Redis (started automatically if not already running). Neither task runs the E2E suites in `buzz-test-client` — those are marked `#[ignore]` and require a running relay: ```bash # Start a relay first (see below), then: cargo test -p buzz-test-client -- --ignored ``` ### Review-Proven Test Standards Mined from the last 25 PRs' review threads (see Review-Proven Rules in [AGENTS.md](AGENTS.md)); this is the test-quality rule reviewers litigated most: **Regression tests must bind the production seam and be falsifiable.** A guard whose removal doesn't fail any test protects nothing — mutations survived the full mobile suite twice (PRs #6996, #7013). Don't bind a regression test to a test-only helper instead of the production code path (PR #7013). Give pure predicates a table test over the full input combination space (PR #6807). Scope Playwright locators — unscoped `getByText` in a required smoke test is a strict-mode flake (PR #6980). (PRs #6807, #6980, #6996, #7013) ### Community-ban route coverage `crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv` classifies the relay's production Axum routes by listener, method, path, authentication plane, membership behavior, and community-restriction behavior. The inventory guard finds direct `.route(...)` calls in production functions named `router` or `*_router`, resolves literal or module-qualified `&str` constant paths, and applies `.nest(...)` prefixes from `router::build_router`. It fails when one of those registrations is missing from the table or a stale row remains, and on any shape it cannot classify: `.route_service`, `.nest_service`, `.fallback_service`, or `.fallback` (except allow-listed registrations, each pinned to its builder and exact argument and allowed once), `.nest` outside `build_router`, a nest target that does not resolve to exactly one router builder, or a builder nested more than once. `scripts/check-community-ban-route-inventory.test.py` pins each of those shapes with a fixture. Run both directly with: ```bash python3 scripts/check-community-ban-route-inventory.py python3 scripts/check-community-ban-route-inventory.test.py ``` `just test-unit` and `just test` run the guard; CI also runs the ignored HTTP and root-WebSocket behavior matrix in the Relay E2E lane: ```bash cargo test -p buzz-test-client --test community_ban_routes -- --ignored --nocapture ``` Use a fresh disposable local relay, PostgreSQL, Redis, and MinIO stack for that command, and set `DATABASE_URL` or `BUZZ_TEST_DATABASE_URL` to the disposable PostgreSQL database. Start the relay with `BUZZ_V1_ENABLED=true` so the accessory-route cases exercise their production handlers. The matrix creates unique tenant hosts and identities; GIF requests stop at malformed local input, and Git/workflow requests use absent resources. Do not point these tests at a shared, development, or hosted database or relay. The inventory keeps membership separate from restriction policy. Public NIP-11, NIP-05, policy documents, and health probes have no authenticated community principal. Anonymous policy-receipt acceptance is pre-membership and has no principal to restrict. Invite claim is also pre-membership, but it carries a verified NIP-98 principal and is classified as restriction-enforced in the inventory. Enforcement will land with BUZZ-268; coverage remains pending until that sibling lands and qualifies the updated v1/v2 lifecycle. After BUZZ-268 lands, the intended contract is to retain valid unexpired v1 codes and mint only v2, with a 72-hour default and 30-day maximum lifetime. Both formats will reject banned claimants and owners transactionally, and issuer bans will permanently invalidate v2. For v1, an unset operator `invalid_after` will use natural expiry; when set, the code will reject at or after that absolute boundary, while an earlier code expiry still takes effect. That operator boundary will not affect v2. Legacy v1 codes have no issuer identity, so issuer-specific revocation remains limited until a verified fleet drain or security approval. This sibling contract, including the operator cutoff, is not implemented by BUZZ-269. Relay-operator APIs, deployment-admin APIs, NIP-FI commands, the localhost Git hook, and the gated mesh test endpoint use separate auth planes; their exemption reason is recorded on each route row. Workflow webhooks use a secret-authenticated caller with no user principal. After BUZZ-272 lands, run admission will also check the saved workflow owner's community ban. That behavior is not implemented by BUZZ-269, and its coverage remains explicitly pending BUZZ-272; the pending row is not an exemption for banned owners. BUZZ-269 currently has two pending sibling cases: invite claim and workflow-owner webhook admission; final integration requires zero pending rows. Root and huddle admission, final-admission races, fail-closed reads, owner-to- agent restriction, timeout behavior, and tenant-scoped eviction point to their existing regression tests in `community-ban-regressions.tsv`. The local socket eviction tests establish pod-local closure. Delivery to sessions on other pods depends on the Redis pub/sub path and is not inferred from those local tests. The moderation-read matrix also checks a moderator-privileged agent against a fresh signed owner proof and a stored owner link; the bridge harness verifies that the ban denial uses the NIP-98 signed creation time, with a clear-owner control that reaches the queue. This scanner is a source-level guard, not a Rust router interpreter. It reads direct `.route(...)` calls inside production functions named `router` or ending in `_router`; a `.route(...)` elsewhere is a scanner error. It resolves a `.nest(...)` prefix only when the relay's `build_router` passes a literal or a resolved string constant and a direct call to another recognized router function. It does not expand macros, discover `.route_service(...)` or `.nest_service(...)`, resolve dynamic path/prefix expressions, or interpret inline nested routers and conditional/opaque router composition. It also does not derive auth or restriction policy from handler code, or prove that the named regression function contains the expected assertions or is selected by CI. If production routes use an unrecognized registration shape, extend the scanner and bind that shape to the inventory before treating the table as complete. The method column records explicitly declared methods. Axum's `get(...)` helper also serves `HEAD` through the GET handler unless an explicit `.head(...)` handler is attached; the scanner does not add that implicit `HEAD` behavior as a separate inventory row or exercise it separately. --- ## Live Local Relay The fastest way to exercise the relay end-to-end is to build the release binaries once, run `buzz-relay`, and drive it with the `buzz` CLI. The CLI signs every request with NIP-98, so you don't need `nak` or hand-rolled `curl`. ### 1. Setup ```bash . ./bin/activate-hermit # activate pinned toolchain just bootstrap # create .env and its stable relay key once just setup # start Docker services, run migrations ``` > **Already running Buzz Desktop?** Desktop uses the same Docker container > names (`buzz-postgres`, `buzz-redis`) and the same > default ports (`:5432`, `:6379`). `just setup` will reuse those > services, so **your test relay writes into Desktop's database**. That's > fine for read/write smoke tests, but: `just reset` wipes Desktop's data > along with yours. If you need isolation, stop Desktop first or run the > dev stack on a different Compose project > (`COMPOSE_PROJECT_NAME=buzz-dev docker compose …`). `just reset` wipes all local data and starts over — **including Buzz Desktop's data** if its services are sharing your dev stack (see callout above). > **Heads up — scrub stale env first.** If your shell inherits any of > `BUZZ_AUTH_TAG`, `BUZZ_RELAY_URL`, or `BUZZ_PRIVATE_KEY` from a > prior session (or a staging config), `unset` them before continuing. > A stale `BUZZ_AUTH_TAG` fails the **local dev relay** with > `auth_error: signature verification failed` on the first CLI write — > it is *not* tolerated. > ```bash > unset BUZZ_AUTH_TAG BUZZ_RELAY_URL BUZZ_PRIVATE_KEY > ``` ### 2. Build the binaries ```bash cargo build --release -p buzz-relay -p buzz-cli -p buzz-admin export PATH="$PWD/target/release:$PATH" ``` Rebuild after any code change — the steps below use the release binaries. ### 3. Start the relay In a separate terminal (it runs in the foreground): ```bash set -o allexport source .env # includes the key generated by just bootstrap set +o allexport buzz-relay # release binary from step 2, serves ws://localhost:3000 # alternatives: # cargo run --release -p buzz-relay # rebuild + run in release # just relay # DEBUG build — fast to launch on a hot cache, # # but mismatched if step 2 left you on release. # # Use `just relay-release` if you want the recipe. ``` Verify it's up (back in your working terminal): ```bash curl -s http://localhost:3000/health # → ok curl -s http://localhost:8080/_readiness # → {"status":"ready"} ``` > Health/readiness/liveness live on a **separate port** (default `8080`, > `BUZZ_HEALTH_PORT`) so K8s probes bypass auth middleware. The main app > port also exposes `/health` for convenience. The relay starts in dev mode (`BUZZ_REQUIRE_AUTH_TOKEN=false`) with the stable relay identity generated in `.env`. See the env vars table at the bottom if you need to lock it down. > **Already running Buzz Desktop (or another relay) on `:3000` / `:8080` / > `:9102`?** Buzz binds three ports — main, health, metrics — and any of > them can collide. Use a separate terminal per role and export the right > vars in each: > > **In the relay terminal** (before launching `buzz-relay`): > ```bash > export BUZZ_BIND_ADDR=0.0.0.0:3030 > export BUZZ_HEALTH_PORT=8088 > export BUZZ_METRICS_PORT=9202 > export RELAY_URL=ws://localhost:3030 # advertised in NIP-42 challenges > buzz-relay > ``` > > **In your working / CLI terminal** (for steps 4+ and the ACP harness): > ```bash > export BUZZ_RELAY_URL=http://localhost:3030 # CLI target > # verify the relay on the overridden ports: > curl -s http://localhost:3030/health # → ok > curl -s http://localhost:8088/_readiness # → {"status":"ready"} > ``` > > Every snippet later in this doc shows the defaults. When you see > `localhost:3000` / `:8080` in a code block, mentally substitute your > overrides — or the CLI will end up talking to Buzz Desktop's relay. > **Ignore `just setup`'s "Next steps" banner.** It still prints > `just relay` (a debug build). Use `buzz-relay` from step 2 here — > step 2 already built the release binary. When you're done, stop the relay (Ctrl-C in its terminal). If it's backgrounded or you lost the terminal: `pkill -f buzz-relay`. Leaving it running will collide with the next reviewer who follows this doc on the same machine. ### 4. Smoke test the CLI against the relay End-to-end: generate an identity, create a channel, post a message, read it back. This is the minimum sequence an agent needs to verify a local relay. ```bash # Generate a keypair GEN=$(buzz-admin generate-key) export BUZZ_PRIVATE_KEY=$(echo "$GEN" | awk '/Secret key:/ {print $3}') PUBKEY=$(echo "$GEN" | awk '/Public key:/ {print $3}') echo "pubkey: $PUBKEY" # Create a channel — the UUID is returned in the response CHANNEL=$(buzz channels create --name "smoke-$$" --type stream --visibility open | jq -r '.channel_id') echo "channel: $CHANNEL" # Send a message and read it back SEND=$(buzz messages send --channel "$CHANNEL" --content "hello from smoke test") EVENT_ID=$(echo "$SEND" | jq -r '.event_id') buzz messages get --channel "$CHANNEL" --limit 5 | jq . # Fetch the reply chain for a specific message (empty array on a leaf — that's fine) buzz messages thread --channel "$CHANNEL" --event "$EVENT_ID" | jq . ``` A successful run prints `{"event_id":"…","accepted":true,"message":""}` for the send, and the message body in the `get` output. `thread` returns `[]` for a leaf message — populated only after a reply comes in (see §6). ### 5. Verify a roster beyond 1,000 members Use the focused live-relay script when changing channel membership, discovery, or reconciliation. It proves the three boundaries that DB-only tests cannot: the relay-served kind 39002 includes a member at roster position 1,501, that identity can publish a channel message, and targeted reconciliation preserves its discoverability. Run this only against an isolated local database. The script inserts fixture members directly, then drives discovery and messaging through the release CLI and relay. Keep the release relay from step 3 running and use its configured relay key for authoritative replacement: ```bash export PATH="$PWD/target/release:$PATH" export DATABASE_URL="${BUZZ_TEST_DATABASE_URL:?set this to the disposable roster database}" export BUZZ_RELAY_URL="http://localhost:3030" # match the relay from step 3 export RELAY_URL="ws://localhost:3030" export BUZZ_RELAY_PRIVATE_KEY="" scripts/e2e-large-channel-roster.sh ``` Success is directly observable as four `PASS` lines. The first and fourth include a member count greater than 1,000 and the same late-member pubkey; the second includes the accepted kind 9 event ID, and the third proves targeted repair left kind 39000/39001 IDs and tags unchanged: ```text PASS discovery-before-republish channel= members=1502 late_pubkey= PASS late-member-action event_id= PASS targeted-repair-preserves-metadata-and-admin-events channel= PASS discovery-after-republish channel= members=1502 late_pubkey= ``` The script refuses debug binaries and refuses a `buzz` or `buzz-admin` resolved outside this checkout's `target/release`. It also requires the targeted admin operation to use `BUZZ_RELAY_PRIVATE_KEY`; never substitute an ephemeral signer for an authoritative replacement. ### 6. Going deeper For full coverage of every CLI command (54 subcommands across 12 groups), follow [`crates/buzz-cli/TESTING.md`](crates/buzz-cli/TESTING.md). The relay's HTTP bridge accepts three endpoints — useful if you're testing a client other than `buzz-cli`: | Endpoint | Purpose | |-----------------|------------------------------------| | `POST /events` | Submit a signed Nostr event | | `POST /query` | NIP-01 filter query (returns events) | | `POST /count` | NIP-45 count query | All three accept NIP-98 auth (recommended) or, in dev mode, an `X-Pubkey` header fallback. There is no REST API for fetching message threads — use `POST /query` with an `#e` filter, or `buzz messages thread`. --- ## ACP Harness (optional, end-to-end with a real agent) `buzz-acp` connects an ACP-speaking agent (goose, codex, claude code, buzz-agent) to the relay. The harness listens for events, drives the agent over stdio, and the agent replies through MCP tools. Minimum recipe — assumes the relay from step 3 is running and the channel `$CHANNEL` from step 4 still exists. The agent identity must be **different** from the sender identity (`BUZZ_ACP_RESPOND_TO=anyone` still skips events the agent signed itself). ```bash cargo build --release -p buzz-acp export PATH="$PWD/target/release:$PATH" # 1. Save your sender identity from step 4 — you'll need it to @mention the agent SENDER_SK="$BUZZ_PRIVATE_KEY" # 2. Mint a fresh agent identity and capture its pubkey AGENT_GEN=$(buzz-admin generate-key) AGENT_SK=$(echo "$AGENT_GEN" | awk '/Secret key:/ {print $3}') AGENT_PUBKEY=$(echo "$AGENT_GEN" | awk '/Public key:/ {print $3}') # 3. Add the agent as a member of $CHANNEL — still using the sender identity. # Skip this and the agent boots to "discovered 0 channel(s) → agent will # sit idle" and silently ignores every mention. buzz channels add-member --channel "$CHANNEL" --pubkey "$AGENT_PUBKEY" --role member # 4. Switch to the agent identity and start it. # buzz-acp wants ws:// (not http://). If you set BUZZ_RELAY_URL to an # http:// URL in step 3, set the ws:// equivalent here — same host/port. export BUZZ_PRIVATE_KEY="$AGENT_SK" export BUZZ_RELAY_URL=ws://localhost:3000 # match step 3 (e.g. ws://localhost:3030 if overridden) export BUZZ_ACP_RESPOND_TO=anyone # default is owner-only; opens the gate for testing # NIP-AE core-memory prompt injection is on by default; set BUZZ_ACP_NO_MEMORY=true to opt out. export GOOSE_MODE=auto # must be 'auto' or goose hangs on prompts buzz-acp # foreground; logs to stdout (run in a separate terminal) # Optional: turn on per-turn tracing if the default log is too quiet. # RUST_LOG=buzz_acp=debug buzz-acp ``` > **Using a different ACP agent?** The default recipe assumes `goose` is on > `$PATH` and configured (`goose --version` should print). For codex / claude > code / buzz-agent, set `BUZZ_ACP_AGENT_COMMAND` and `BUZZ_ACP_AGENT_ARGS` > accordingly — see `crates/buzz-acp/README.md`. Without these, buzz-acp > will fail to spawn the agent subprocess on startup. If you started the agent before adding it to the channel, just run the `add-member` afterwards — it picks up the membership notification live and subscribes without restart (`membership notification: subscribing to new channel …`). The justfile also ships `just goose key="$AGENT_NSEC"` (foreground) and `just goose-bg key="$AGENT_NSEC"` (background screen session) which set the same env. See `crates/buzz-acp/README.md` for parallel agents, heartbeats, respond-to gates, and forum subscriptions. To exercise deferred ACP startup, add `BUZZ_ACP_LAZY_POOL=true` before launching `buzz-acp`. The harness should connect, authenticate, subscribe, and publish online presence without starting the configured ACP child. The first accepted, flushable mention should start exactly one child and then dispatch the queued message. Automated coverage in `pool_lifecycle_state` pins single-wake, retry/backoff, and stale-result behavior; it does not replace this real relay/process smoke test. Send the agent a task — switch your shell back to the **sender** identity from step 4 and @mention the agent: ```bash export BUZZ_PRIVATE_KEY=$SENDER_SK # the key from step 4 buzz messages send --channel "$CHANNEL" \ --content "Hey agent, reply PONG only." # Wait 10–90s, then read the channel — the agent's reply is a kind:9 from # AGENT_PUBKEY. The current ACP build is quiet on stdout during a turn, so # `buzz messages get` is how you confirm it ran. buzz messages get --channel "$CHANNEL" --limit 5 | jq '.[] | {pubkey, content}' ``` Replies are kind:9 in the same channel; `buzz messages thread --channel --event ` fetches the reply chain for a specific mention. --- ## Configuration reference The relay reads all configuration from environment variables. Defaults work out of the box with `just setup` or `just relay`. Common overrides: | Variable | Default | Notes | |-----------------------------------|-----------------------------|-------| | `BUZZ_BIND_ADDR` | `0.0.0.0:3000` | Main app port | | `BUZZ_HEALTH_PORT` | `8080` | `/_liveness`, `/_readiness` | | `BUZZ_METRICS_PORT` | `9102` | Prometheus `/metrics` | | `RELAY_URL` | `ws://localhost:3000` | Advertised in NIP-11 / NIP-42 challenges. **Note: no `BUZZ_` prefix.** | | `DATABASE_URL` | local PostgreSQL URL from `.env.example` | | | `REDIS_URL` | `redis://localhost:6379` | | | `BUZZ_REQUIRE_AUTH_TOKEN` | `false` | When true, REST requires NIP-98 (no `X-Pubkey` fallback) | | `BUZZ_REQUIRE_RELAY_MEMBERSHIP` | `false` | When true, only pubkeys in `relay_members` can connect | | `BUZZ_DRAIN_JITTER_MS` | `0` (off) | Per-connection upper bound, in ms, for the random delay before each live WebSocket gets its `1012 Service Restart` close on graceful shutdown. `0` closes every socket at once (the previous behavior). A positive value spreads closes uniformly over `[1, value]` ms to avoid a reconnect thundering herd on rolling deploys. Values above `20000` are capped to `20000` (`MAX_DRAIN_JITTER_MS`) to leave close-frame delivery headroom under the relay's 30s hard-drain timeout. Empty or whitespace-only is treated as unset (off); a non-integer fails startup loudly. | | `BUZZ_AUDIT_ENABLED` | `true` | Tamper-evident event/media audit log. Set `false`/`0`/`off` to skip its DB pool and writes. Does not disable the separate moderation audit trail. | | `BUZZ_AUTO_MIGRATE` | `false` | Opt in with `true`/`1`/`yes`/`on` to run embedded SQLx migrations on relay startup | | `RELAY_OWNER_PUBKEY` | unset | Bootstrapped as `owner` in `relay_members` at first start | | `BUZZ_ALLOW_NIP_OA_AUTH` | `false` | Enable NIP-OA owner attestation for membership | | `BUZZ_WEB_DIR` | unset (source), `/srv/buzz/web` (container) | Directory containing the invite landing bundle; the production container enables it so `/invite/{code}` always works | | `BUZZ_SERVE_GIT_WEB_GUI` | `false` | Set to `true` or `1` to expose the bundled Git repository browser at `/` and `/repos/...`; invite routes do not depend on this flag | CLI-side, only two matter for testing: | Variable | Default | Notes | |-------------------------|--------------------------|-------| | `BUZZ_RELAY_URL` | `http://localhost:3000` | CLI relay base; accepts `ws(s)://` and normalises | | `BUZZ_PRIVATE_KEY` | — (**required**) | `nsec1…` or 64-char hex | | `BUZZ_AUTH_TAG` | unset | Optional NIP-OA owner attestation JSON | --- ## Troubleshooting | Symptom | Cause | Fix | |---------|-------|-----| | `relay error 500` or `400: restricted: not a channel member` after a code change | Stale binary | Rebuild and re-export `PATH`; or `cargo run` directly | | `Address already in use` on relay start (os error 48 on macOS, 98 on Linux) | Another relay (or stale process) holding `:3000` / `:8080` / `:9102` (or your override ports) | Metrics-listener failures emit a `metrics_bind` lifecycle terminal with reason `bind`. Check the configured ports with `lsof -iTCP:3000,8080,9102 -sTCP:LISTEN` (or your override equivalents). Kill the offender (`pkill -f buzz-relay`) or use the port-override block in step 3. If you already overrode and *still* collide, a prior reviewer left a relay running on the same alt ports — kill it or pick fresh ports | | `auth_error: BUZZ_PRIVATE_KEY is required` | Env not exported into the CLI's shell | `export BUZZ_PRIVATE_KEY=...` (or pass `--private-key`) | | `auth_error: BUZZ_AUTH_TAG verification failed … signature verification failed` | A stale `BUZZ_AUTH_TAG` inherited from a parent shell. The local dev relay rejects it. | `unset BUZZ_AUTH_TAG` (see the scrub block in step 1) | | `auth-required: verification failed` on a closed relay | NIP-OA attestation needed | Set `BUZZ_AUTH_TAG` to the owner-issued JSON, or relax `BUZZ_REQUIRE_RELAY_MEMBERSHIP` | | `channels list` empty after `channels create` | The CLI doesn't echo the channel UUID; use the filter shown in step 4 | Or `POST /query` with `{"kinds":[39002]}` | | ACP agent ignores all events | `BUZZ_ACP_RESPOND_TO=owner-only` (default) with no owner configured | Set `BUZZ_ACP_RESPOND_TO=anyone` for testing | | ACP logs `discovered 0 channel(s)` / `no channel subscriptions resolved` | Agent identity isn't a member of any channel | `buzz channels add-member --channel "$CHANNEL" --pubkey "$AGENT_PUBKEY" --role member` from another identity | | `GOOSE_MODE` warning, agent hangs | Not set | `export GOOSE_MODE=auto` | | Tests pass locally but CI fails | Forgot to run `just ci` | `just ci` runs the gate (fmt, clippy, unit tests, desktop/web builds) |