Files
buzz/scripts/run-tests.sh
Will Pfleger 16839a0773 feat(nip-fi): admin disconnect/deny API with in-memory deny-until-TTL map (S4) (#7977)
This adds the NIP-FI S4 admin command surface. An authorized issuer
principal can deny an `(issuer, pubkey)` pair until a TTL expires. When
that happens, the relay records the entry in a bounded in-memory deny
map, closes matching live sockets on every pod, and refuses new
admissions while the entry is live.

Supersedes #7265

## Breaking

In `BUZZ_NIP_FI_MODE=enforce`, every entry in `BUZZ_NIP_FI_ISSUERS` now
has to carry `maximum_command_age_seconds` (1–60) and a non-empty
`authorized_principals` list, and startup fails if any issuer is missing
either one. That means an S3-only enforce config that boots on `main`
won't boot with this change. No deployment sets `BUZZ_NIP_FI_*` yet. The
per-issuer field table and a working example are in the
`nip_fi_config.rs` module docs.

## What changes

- **`buzz-auth`**:
- `nip_fi/command.rs` verifies signed admin commands. It checks issuer
policy, command age, the authorized principal (exact, case-sensitive
`sub` match), and a `jti` of at most 512 bytes.
- `nip_fi/deny_map.rs` is a per-issuer, capacity-bounded deny-until-TTL
map on top of `DashMap`. Expired entries and `jti` reservations are only
swept when a write would otherwise hit capacity. The sweep is O(n) and
holds the lock every admission check takes, so this keeps it off the
admission path. Reads compare against `now`, so an expired entry that
hasn't been swept yet never counts as live.
- **Cross-pod command replay fence**: `nip_fi/command_replay.rs` adds a
`CommandReplayGuard` trait, and `buzz-pubsub` implements it in Redis
(`RedisCommandReplayGuard`), following the NIP-98 replay guard. Once a
command is authenticated, the verifier takes an atomic `SET
buzz:nip-fi:command:{sha256(len(iss)‖iss‖len(jti)‖jti)} 1 NX EX <ttl>`
claim, with the TTL set to the command's remaining validity rounded up
to whole seconds plus skew, clamped like NIP-98, so the claim outlives
acceptance by a verifier whose clock trails by the full skew. Before the
claim, the verifier reserves a pending `jti` and deny-entry slot in this
pod's deny map under the issuer lock, so a full deny set returns `503
deny set full` without touching Redis and the same signed command can be
retried. The claim is taken only after the signature and claims verify,
so a forged token cannot burn a real `jti`. A `jti` already claimed on
any pod returns `403`. A Redis error fails closed with `503
authorization unavailable`: no deny entry, no session close, no publish.
A replayed claim, a Redis error, or a cancelled request drops the
reservation. After a successful claim the reservation commits to the
`jti` record and deny entry, which cannot fail on capacity. A claim can
still remain with no deny entry if Redis applied `SET` but the reply was
lost, or the pod stops between claim and commit; that fails closed, and
a retry gets `403` until the TTL expires.
- **`api/nip_fi.rs`**: the admin disconnect/deny endpoint. It verifies
the command, inserts the deny entry, disconnects locally, and publishes
over `buzz-pubsub` `conn_control` so every other pod runs the same
disconnect. A receiving pod clamps `until` to its own ceiling instead of
rejecting it. Otherwise a pod whose clock trails the origin would drop a
command the origin already answered with 200, and the target's sockets
on that pod would stay open. Malformed pubkeys, unknown issuers, and
unrepresentable timestamps are still rejected.
- **Admission and live enforcement**:
- WS upgrade (`router.rs`) and HTTP (`nip_fi_http.rs`) refuse keys that
have a live entry with `403 authorization_denied`.
- Root NIP-42 auth and audio auth register the proven identity (pubkey +
admitting issuer) first, and only then check the deny map. With that
order, a concurrent disconnect either finds the socket in its close scan
or the check finds the entry. `OK(true)` isn't sent if a concurrent
disconnect already cancelled the session.
- `ConnectionManager::disconnect_nip_fi` and
`CommunityConnectionRegistry::disconnect_nip_fi` are scoped to the
issuer.
- **One wire form for `authorization_denied`** (FI-TRACE-DENIAL-ORACLE):
every root and audio WebSocket `authorization_denied` denial now goes
through one first-writer-wins transition on
`CommunityConnectionControl`. That covers the S4 deny-set hit and admin
disconnect, and also the existing S3 key-mismatch, lease-expiry, and
membership refusals. The transition sets the `AuthorizationDenied`
reason only if nothing else has set a reason, and only the winner
enqueues the denial frame. So every one of these denials is the class's
denial frame followed by `Close(1008, "authorization denied")`, and a
concurrent registry scan can't queue a second one. Before this, S3 key
mismatch and expiry closed with `Close(None)`, which made them
distinguishable from a deny-set hit on the wire. `evidence_rejected` and
`authorization_unavailable` keep the bare close.
- **Config**: see Breaking. `deny_set_capacity` is optional and defaults
to 50000. Setting any command field without a command age fails closed.
- **Boot** (`main.rs`): installs the command verifier and deny map. It
subscribes to cross-pod disconnects before the Redis subscriber starts,
so disconnects relayed during boot are buffered instead of dropped.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-01 19:40:09 +00:00

359 lines
15 KiB
Bash
Executable File

#!/usr/bin/env bash
# =============================================================================
# run-tests.sh — Run Buzz test suite
# =============================================================================
# Usage:
# ./scripts/run-tests.sh # run all tests (default)
# ./scripts/run-tests.sh unit # unit tests only (no infra needed)
# ./scripts/run-tests.sh integration # integration tests only
# ./scripts/run-tests.sh all # explicit all
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
MODE="${1:-all}"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
log() { echo -e "${BLUE}[run-tests]${NC} $*"; }
success(){ echo -e "${GREEN}[run-tests]${NC} $*"; }
warn() { echo -e "${YELLOW}[run-tests]${NC} $*"; }
error() { echo -e "${RED}[run-tests]${NC} $*" >&2; }
section(){ echo -e "\n${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"; echo -e "${CYAN} $*${NC}"; echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"; }
cd "${REPO_ROOT}"
# ---- Load .env if present ---------------------------------------------------
if [[ -f ".env" ]]; then
log "Loading .env..."
set -o allexport
# shellcheck disable=SC1091
source .env
set +o allexport
else
# Use defaults matching docker-compose.yml
export DATABASE_URL="postgres://buzz:buzz_dev@localhost:5432/buzz" # sadscan:disable np.postgres.1
export PGHOST=localhost
export PGPORT=5432
export PGUSER=buzz
export PGPASSWORD=buzz_dev
export PGDATABASE=buzz
export REDIS_URL="redis://localhost:6379"
fi
# ---- Track results ----------------------------------------------------------
declare -a PASSED=()
declare -a FAILED=()
run_test_step() {
local name="$1"
shift
log "Running: ${name}"
if "$@"; then
success "${name} passed"
PASSED+=("${name}")
else
error "${name} FAILED"
FAILED+=("${name}")
fi
}
# ---- Check / start infra (for integration tests) ----------------------------
ensure_infra() {
"${REPO_ROOT}/bin/just" _ensure-migrations
}
# ---- Unit tests (no infra needed) -------------------------------------------
run_unit_tests() {
section "Unit Tests (no infra required)"
run_test_step "buzz-core tests" \
cargo test -p buzz-core --lib -- --nocapture
run_test_step "buzz-audit tests" \
cargo test -p buzz-audit --lib -- --nocapture
run_test_step "buzz-auth unit tests" \
cargo test -p buzz-auth --lib -- --nocapture
# S4 cross-pod NIP-FI disconnect payload tests (infra-free). Mirrors
# `just test-unit`.
run_test_step "buzz-pubsub conn_control NIP-FI tests" \
cargo test -p buzz-pubsub --lib conn_control::tests::nip_fi_disconnect_ -- --nocapture
run_test_step "buzz-voice tests" \
cargo test -p buzz-voice --lib -- --nocapture
run_test_step "buzz-cli tests" \
cargo test -p buzz-cli -- --nocapture
# buzz-sdk builder/validation unit tests: pure event-builder and input
# validation, no infra. Mirrors the nextest path in `just test-unit` — the
# two lists must stay in step. `--lib` matches the nextest invocation and
# avoids the full-package rustdoc dependency-resolution flake.
run_test_step "buzz-sdk unit tests" \
cargo test -p buzz-sdk --lib -- --nocapture
# Keep the relay-to-agent trust-boundary regressions in the fallback path
# when cargo-nextest is unavailable.
run_test_step "buzz-acp tests" \
cargo test -p buzz-acp -- --nocapture
# buzz-db migrator/lint unit tests (no infra): guard the embedded-migrator
# invariant (exactly the consolidated 0001; cutover/backfill stays an operator
# script, not startup state) and the tenant-scoping lints. The Postgres-backed
# buzz-db tests are #[ignore]d; nothing here (or in integration mode below,
# which runs `cargo test -p buzz-db` without --ignored) runs them — they need a
# separate isolated-DB gate, so --lib keeps this step infra-free.
run_test_step "buzz-db unit tests" \
cargo test -p buzz-db --lib -- --nocapture
run_test_step "buzz-media storage snapshot serialization test" \
cargo test -p buzz-media --lib bucket_index::tests::bucket_snapshot_json_round_trip_preserves_community_keys -- --exact --nocapture
run_test_step "buzz-admin storage snapshot tests" \
cargo test -p buzz-admin storage_snapshot -- --nocapture
# Multi-tenant conformance gate: independent replay checker + golden
# fixtures (buzz-conformance). Pure in-process trace replay, no infra.
run_test_step "buzz-conformance tests" \
cargo test -p buzz-conformance -- --nocapture
run_test_step "buzz-push-gateway tests" \
cargo test -p buzz-push-gateway -- --nocapture
run_test_step "buzz-push-gateway personal development tests" \
cargo test -p buzz-push-gateway --features personal-dev-app-attest -- --nocapture
# Kubernetes backend provider: pure decision layers driven by a fake
# substrate, no cluster. Mirrors the nextest path in `just test-unit` —
# the two lists must stay in step or the fallback silently covers less.
run_test_step "buzz-backend-kubernetes tests" \
cargo test -p buzz-backend-kubernetes -- --nocapture
# Keep fallback parity with `just test-unit`: one LaunchDarkly-feature run
# exercises both default and feature-gated buzz-feature-flags tests.
run_test_step "buzz-feature-flags tests (launchdarkly)" \
cargo test -p buzz-feature-flags --features launchdarkly -- --nocapture
# buzz-agent model-capabilities corpus: the Rust half of the cross-language
# drift guard. model_capabilities.rs embeds scripts/model-capabilities.json +
# scripts/normative-corpus.json via include_str! and replays the full locked
# corpus as pure in-process tests (no infra). Mirrors the nextest path in
# `just test-unit` — the two lists must stay in step.
run_test_step "buzz-agent unit tests" \
cargo test -p buzz-agent --lib -- --nocapture
# ACP author-gate and queue tests are pure unit tests. Keep this fallback in
# step with `just test-unit`; ignored lifecycle tests run elsewhere.
run_test_step "buzz-acp unit tests" \
cargo test -p buzz-acp --lib -- --nocapture
# Mirror the relay filters from `just test-unit`: the three handler modules,
# storage-snapshot helpers, readiness and router unit suites, and the single
# scoped admission regression in state::tests, plus the REQ lifecycle tests.
run_test_step "buzz-relay channel authorization tests" \
cargo test -p buzz-relay --lib handlers::channel_authz:: -- --nocapture
run_test_step "buzz-relay moderation authorization tests" \
cargo test -p buzz-relay --lib handlers::moderation_authz:: -- --nocapture
run_test_step "buzz-relay side-effects helper tests" \
cargo test -p buzz-relay --lib handlers::side_effects::tests:: -- --nocapture
run_test_step "buzz-relay storage snapshot tests" \
cargo test -p buzz-relay --lib storage_sweep::tests:: -- --nocapture
# Mirror the four audio/FI suites from `just test-unit`'s nextest expression.
# These are infra-free (no DB, no Redis); the `#[ignore]`-gated DB witnesses
# are excluded by cargo test's default filter. Keep in step with the Justfile
# `test-unit` relay expression.
run_test_step "buzz-relay audio join tests" \
cargo test -p buzz-relay --lib audio::join::tests:: -- --nocapture
run_test_step "buzz-relay audio handler tests" \
cargo test -p buzz-relay --lib audio::handler::tests:: -- --nocapture
run_test_step "buzz-relay NIP-FI gate tests" \
cargo test -p buzz-relay --lib nip_fi_gate::tests:: -- --nocapture
run_test_step "buzz-relay NIP-FI session tests" \
cargo test -p buzz-relay --lib nip_fi_session::tests:: -- --nocapture
# Mirror the NIP-FI (S3) stanza from `just test-unit`: module filters, then
# each exact name. Keep this list in step with that stanza's `test(=...)`s.
run_test_step "buzz-relay NIP-FI config tests" \
cargo test -p buzz-relay --lib nip_fi_config:: -- --nocapture
# Infra-free REQ subscription-lifecycle tests, by exact name: the rest of
# handlers::req needs a database.
run_test_step "buzz-relay REQ subscription lifecycle tests" \
cargo test -p buzz-relay --lib -- --exact --nocapture \
handlers::req::tests::timed_out_historical_read_deregisters_before_closed \
handlers::req::tests::superseded_timeout_leaves_replacement_intact \
handlers::req::tests::search_claim_retires_live_and_yields_to_replacement \
handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner \
handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim \
handlers::req::tests::revoke_then_replacement_keeps_replacement_whole \
handlers::req::tests::claims_after_connection_cleanup_are_refused \
handlers::req::tests::dropped_terminal_frame_cancels_connection \
handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection
run_test_step "buzz-relay readiness tests" \
cargo test -p buzz-relay --lib readiness::tests:: -- --nocapture
run_test_step "buzz-relay admission regression test" \
cargo test -p buzz-relay --lib state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket -- --nocapture
run_test_step "buzz-relay router tests" \
cargo test -p buzz-relay --lib router::tests:: -- --nocapture
run_test_step "buzz-relay NIP-FI HTTP ingress tests" \
cargo test -p buzz-relay --lib nip_fi_http::tests:: -- --nocapture
run_test_step "buzz-relay API query parsing tests" \
cargo test -p buzz-relay --lib api::parse_query_tests:: -- --nocapture
run_test_step "buzz-relay Git transport Off-mode precedence tests" \
cargo test -p buzz-relay --lib api::git::transport::off_mode_precedence_tests:: -- --nocapture
run_test_step "buzz-relay NIP-FI upgrade tests" \
cargo test -p buzz-relay --lib nip_fi_upgrade:: -- --nocapture
run_test_step "buzz-relay NIP-FI admin API tests" \
cargo test -p buzz-relay --lib api::nip_fi:: -- --nocapture
run_test_step "buzz-relay auth metrics contract tests" \
cargo test -p buzz-relay --lib metrics::contract_tests:: -- --nocapture
local nip_fi_exact_tests=(
audio::room::tests::roster_revisions_are_ordered_and_snapshot_is_authoritative
connection::tests::auth_lifecycle_reconciles_every_terminal_and_never_leaks_gauge
audio::room::tests::b1_pending_peer_removed_before_commit_emits_no_delta
audio::room::tests::b2_commit_peer_emits_exactly_one_joined_delta_and_marks_visible
audio::room::tests::b3_commit_peer_revision_is_monotone_between_concurrent_events
audio::room::tests::f7a_pending_peer_excluded_from_snapshot_until_committed
connection::tests::b2_cancelled_connection_event_frame_not_dispatched
connection::tests::b3_expiry_denial_precedes_close_through_send_loop
connection::tests::b3_root_pairing_denial_precedes_close_through_send_loop
connection::tests::cancellation_during_select_with_fi_denial_routes_through_bounded_path
connection::tests::cancelled_never_ready_sink_with_queued_fi_denial_exits_within_timeout
connection::tests::deadline_exp_is_earliest_selects_exp
connection::tests::deadline_max_connection_lifetime_is_earliest_selects_partition
connection::tests::deadline_no_lifetime_returns_upstream_only
connection::tests::expiry_notice_queued_on_ctrl_before_cancel
connection::tests::f3_root_outer_wrapper_delivers_denial_on_bootstrap_cancellation
connection::tests::f3_root_pre_built_expired_gate_terminates_connection
handlers::auth::tests::b2_pre_cancelled_connection_never_becomes_authenticated
handlers::auth::tests::fi_ban_check_error_emits_terminal_authorization_unavailable
handlers::auth::tests::fi_root_authorization_denied_rows_emit_identical_frames
handlers::auth::tests::fi_invalid_nip42_proof_emits_terminal_evidence_rejected
handlers::auth::tests::handle_auth_pairing_mismatch_runs_full_root_denial_path
handlers::auth::tests::nip42_denial_class_separates_internal_failure_from_bad_evidence
handlers::event::tests::p1b_agent_observer_event_barrier_expiry_blocks_fanout_and_ack
handlers::req::tests::p1a_huddle_liveness_req_barrier_expiry_blocks_query_and_emission
state::tests::f3_cancellation_during_check_terminates_socket_without_waiting_for_check
state::tests::on_not_run_runs_once_on_each_deny_arm_and_never_on_admit
state::tests::conn_manager_disconnect_nip_fi_ignores_unproven_connection
state::tests::conn_manager_disconnect_nip_fi_is_issuer_scoped
state::tests::conn_manager_disconnect_nip_fi_sets_authorization_denied_reason
state::tests::nip_fi_disconnect_audio_is_issuer_scoped
state::tests::nip_fi_disconnect_closes_proven_audio_socket_and_sends_policy_close_reason
state::tests::nip_fi_disconnect_closes_target_audio_only_and_preserves_collocated_peer
state::tests::nip_fi_disconnect_does_not_close_different_pubkey_audio_socket
state::tests::nip_fi_disconnect_does_not_close_unproven_audio_socket
state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason
state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame
state::tests::manager_disconnect_sets_reason_enqueues_frame_then_cancels
)
local name
for name in "${nip_fi_exact_tests[@]}"; do
run_test_step "buzz-relay ${name}" \
cargo test -p buzz-relay --lib "$name" -- --exact --nocapture
done
run_test_step "buzz-relay binary tests" \
cargo test -p buzz-relay --bin buzz-relay -- --nocapture
}
# ---- DB / integration tests (infra required) --------------------------------
run_integration_tests() {
section "Integration Tests (requires running services)"
ensure_infra
run_test_step "buzz-db tests" \
cargo test -p buzz-db -- --nocapture
if find crates/buzz-auth/tests -maxdepth 1 -name '*.rs' -print -quit 2>/dev/null | grep -q .; then
run_test_step "buzz-auth integration tests" \
cargo test -p buzz-auth --test '*' -- --nocapture
else
run_test_step "buzz-auth (no integration tests found)" true
fi
run_test_step "workspace integration tests" \
cargo test --test '*' -- --nocapture 2>/dev/null || \
run_test_step "workspace integration tests (none found)" true
}
# ---- Main -------------------------------------------------------------------
START_TIME=$(date +%s)
case "${MODE}" in
unit)
run_unit_tests
;;
integration)
run_integration_tests
;;
all|*)
run_unit_tests
run_integration_tests
;;
esac
END_TIME=$(date +%s)
ELAPSED=$((END_TIME - START_TIME))
# ---- Summary ----------------------------------------------------------------
section "Test Summary"
echo ""
echo -e " Duration: ${ELAPSED}s"
echo ""
if [[ ${#PASSED[@]} -gt 0 ]]; then
echo -e " ${GREEN}Passed (${#PASSED[@]}):${NC}"
for t in "${PASSED[@]}"; do
echo -e " ${GREEN}pass${NC} ${t}"
done
fi
if [[ ${#FAILED[@]} -gt 0 ]]; then
echo ""
echo -e " ${RED}Failed (${#FAILED[@]}):${NC}"
for t in "${FAILED[@]}"; do
echo -e " ${RED}fail${NC} ${t}"
done
echo ""
exit 1
fi
echo ""
success "All tests passed!"
exit 0