# 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) --- ## 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. ### Invitation compatibility and revocation New invitations are durable v2 bearers. A community ban permanently revokes outstanding v2 invitations issued by that principal or its recorded agents. Unban does not restore them; an unrestricted issuer can mint new invitations. Existing durable rows default to legacy admission. Until the separately reviewed historical repair processes them, any retained `community_bans` row for a canonical issuer or its currently recorded owner permanently revokes a legacy row, including expired, lifted, and timeout-only restrictions. The repair clears that legacy marker after deciding the row's historical state. Afterward, only an active ban permanently revokes it; timeouts and mutes block claims only while active. New mints explicitly mark their rows as protocol native. Audit and action records alone do not establish enforcement. An issuer or verified owner timeout also blocks claims on existing invitations while it lasts, without durable revocation. Claims resume after timeout expiry. Claim credentials are verified without user writes. Successful claims commit verified ownership with membership, policy evidence, and v2 consumption. Rejected claims create no users or owner links. Minting with a verified owner that differs from an agent's recorded owner returns HTTP 403 `invite_owner_mismatch`; a storage failure while establishing that link remains HTTP 503. Web, desktop, and mobile claim errors explain `invite_restricted` as a restriction on the claiming account, or on the account that owns it, in this community: contact a community administrator, or retry after a temporary restriction ends. An issuer restriction returns `invite_invalid`, so a claim never reveals the issuer's moderation state. `invite_owner_mismatch` asks for the correct account or owner identity. Neither error suggests a fresh link as a workaround, and neither automatically retries. Claim and mint restriction reads use the writer inside a transaction serialized with community restriction and agent-owner writes. Admission committed first precedes the ban; a ban committed first prevents subsequent admission. Restriction lookup failures deny admission, and onboarding does not require prior membership. Legacy v1 bearers contain no issuer identity. Unset `BUZZ_INVITE_V1_INVALID_AFTER` preserves their natural expiry and claimant restriction checks, but cannot revoke an individual legacy issuer's bearers. Drain for at most 30 days from the last possible v1 issuance across **all serving and rollback versions**; do not use merge time as the start. Full issuer-revocation coverage requires completing that drain or an explicitly approved compatibility scope decision. Operators may set `BUZZ_INVITE_V1_INVALID_AFTER` to an absolute UTC RFC3339 whole-second timestamp (for example `2026-12-01T00:00:00Z`). v1 verification rejects at and after that instant, never extends bearer expiry, and leaves v2 unchanged. Invalid or empty values fail configuration. Apply the same setting to all serving pods and retain it on compatible rollbacks; rolling back to a version without this setting's enforcement restores legacy acceptance until natural expiry. This change does not choose or deploy a cutoff. The historical maintenance companion must be separately reviewed and approved **before deploying this core change**. Both steps belong to the same controlled rollout: leaving unrepaired rows serving indefinitely would extend historical restriction rejection until their repair. Migration 0074 is schema-only: it adds `revoked_at` and `legacy_admission` without rewriting existing invite rows. Reconcile any previously applied invitation migration version with the schema and migration history before applying it. There is no invitation-maintenance feature switch. Operators must prevent invitation mint/claim, ban lifts, and **new timeout/mute restrictions** throughout the drain/repair interval, including signed events, HTTP, administrative tools, background jobs and direct writers. If this cannot be enforced for every writer with existing operational controls, use a maintenance window: remove public traffic, stop all relay instances and other writers, drain in-flight work and verify no old writer remains. Keep them stopped while applying schema 0074 and running the separately approved repair with its isolated operator connection. Prepare only new-code instances; do not reopen traffic or restart other writers until repair validation succeeds. Readers that may run before repair must use new code, which rejects historical restrictions lazily on unrepaired legacy rows. The repair must report zero unrepaired invites in active communities and an idempotent second run before reopening. Review every skipped/fenced community; keep it closed until it is active and repaired, then recheck zero before admitting traffic. After repair, timeouts/mutes only block temporarily; active bans still revoke permanently. A rollback to code that ignores `revoked_at` requires invitation admission paused, using the same traffic/writer controls if needed. Legacy owner attribution uses the agent's currently recorded `agent_owner_pubkey`; deleting an owner user row out of band clears that link through its foreign key, so a later repair cannot recover the former relationship. Reconcile before any external user-row cleanup that could erase this provenance. ### 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="${DATABASE_URL:?set an isolated local PostgreSQL URL}" 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 database `buzz` (development default) | | | `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) |