diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index b6cab48aaf1..28fb80f9c54 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -347,6 +347,8 @@ jobs: ON CONFLICT (lower(host)) DO NOTHING ;" - name: Start relay + env: + BUZZ_V1_ENABLED: "true" run: | chmod +x ./target/ci/buzz-relay nohup env \ @@ -545,6 +547,8 @@ jobs: --archive-file target/ci/backend-integration-tests.tar.zst \ -E 'package(buzz-relay) and test(/workflow_sink/)' - name: Start relay + env: + BUZZ_V1_ENABLED: "true" run: | chmod +x ./target/ci/buzz-relay nohup env \ @@ -614,17 +618,25 @@ jobs: merge-multiple: true path: target/ci - name: Start relay + env: + BUZZ_V1_ENABLED: "true" run: | chmod +x ./target/ci/buzz-relay ./target/ci/git-credential-nostr ./scripts/start-relay-for-tests.sh --no-build - name: Relay E2E tests run: | cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop --test e2e_thread_roots --test e2e_project -- --ignored --nocapture + cargo test -p buzz-test-client --test community_ban_routes -- --ignored --nocapture + cargo test -p buzz-test-client --test regression_relay_admin_ban_gate -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay nip29_departure_wire -- --ignored --nocapture env: RELAY_URL: ws://localhost:3000 + RELAY_HTTP_URL: http://localhost:3000 + REPRO_RELAY_HTTP: http://localhost:3000 + REPRO_HOST: localhost:3000 + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz GIT_CREDENTIAL_NOSTR_BIN: ${{ github.workspace }}/target/ci/git-credential-nostr - name: Media read-auth e2e # Reads require kind:24242 `t=get` auth, so these binaries are the only diff --git a/Justfile b/Justfile index 04c6e46e700..2ff716a36bd 100644 --- a/Justfile +++ b/Justfile @@ -397,6 +397,7 @@ test-unit: #!/usr/bin/env bash set -euo pipefail ./scripts/test-ensure-local-relay-key.sh + python3 scripts/check-community-ban-route-inventory.py if command -v cargo-nextest &>/dev/null; then cargo nextest run -p buzz-core -p buzz-auth --lib cargo nextest run -p buzz-audit --lib @@ -591,6 +592,8 @@ test-unit: + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=state::tests::manager_disconnect_sets_reason_enqueues_frame_then_cancels) + test(/^api::nip_fi::/)' + # Retain partially consumed mesh receives across housekeeping ticks. + cargo nextest run -p buzz-relay --lib -E 'test(=mesh_boot::tests::demo_echo_retains_pending_receive_across_housekeeping_ticks)' # boot_lifecycle spawns the real relay binary and asserts its startup # lifecycle, including that buzz_startup_phase_* reaches /metrics. Its # non-ignored tests need no Postgres or Redis; the Postgres cases are diff --git a/TESTING.md b/TESTING.md index d939265c414..2fe91c58df8 100644 --- a/TESTING.md +++ b/TESTING.md @@ -31,6 +31,90 @@ 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 declared `&str` paths, and +applies direct literal `.nest(...)` prefixes from the relay router. It fails +when one of those registrations is missing from the table or a stale row +remains. Run it directly with: + +```bash +python3 scripts/check-community-ban-route-inventory.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 @@ -190,7 +274,7 @@ relay key for authoritative replacement: ```bash export PATH="$PWD/target/release:$PATH" -export DATABASE_URL="postgres://buzz:buzz_dev@localhost:5432/buzz_roster_e2e" +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="" @@ -331,7 +415,7 @@ out of the box with `just setup` or `just relay`. Common overrides: | `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` | `postgres://buzz:buzz_dev@localhost:5432/buzz` | | +| `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 | diff --git a/crates/buzz-db/src/store/event_follow_up_postgres_tests.rs b/crates/buzz-db/src/store/event_follow_up_postgres_tests.rs index b32b8bfbd44..107bbb38d5a 100644 --- a/crates/buzz-db/src/store/event_follow_up_postgres_tests.rs +++ b/crates/buzz-db/src/store/event_follow_up_postgres_tests.rs @@ -1018,13 +1018,30 @@ async fn assert_async_isolation(db: &Db, community: CommunityId, keys: &Keys) { endpoint_grant, max_class, subscriptions, expires_at, updated_at) \ SELECT $1, author, installation_id, source_event_id, source_created_at, generation, \ active, endpoint_enabled, endpoint_hash, endpoint_grant, max_class, \ - subscriptions, expires_at, clock_timestamp() FROM push_leases WHERE community_id=$2", + subscriptions, expires_at, \ + (SELECT received_at + interval '1 second' FROM events WHERE community_id=$1 AND id=$3) \ + FROM push_leases WHERE community_id=$2", ) .bind(later.as_uuid()) .bind(community.as_uuid()) + .bind(before_enrollment.id.as_bytes().as_slice()) .execute(&mut *activation) .await .unwrap(); + let leases_strictly_newer: bool = sqlx::query_scalar( + "SELECT count(*) > 0 AND bool_and(l.updated_at > e.received_at) \ + FROM push_leases l JOIN events e ON e.community_id=l.community_id \ + WHERE l.community_id=$1 AND e.id=$2", + ) + .bind(later.as_uuid()) + .bind(before_enrollment.id.as_bytes().as_slice()) + .fetch_one(&mut *activation) + .await + .unwrap(); + assert!( + leases_strictly_newer, + "fixture leases must follow recorded receipt" + ); activation.commit().await.unwrap(); assert_eq!(match_count(&isolated, later, &before_enrollment).await, 0); assert_eq!( diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index d631df75003..e8df73ba967 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -2736,11 +2736,11 @@ async fn authorize_moderation_read( None, state.config.require_auth_token || nip_fi_active, ) - .map(|auth| auth.proof(auth.event_id_bytes)) + .map(|auth| auth.proof((auth.event_id_bytes, auth.signed_created_at))) .map_err(|e| e.into_response()) })?; let pubkey = *admission.proven_pubkey(); - let event_id_bytes = admission.into_extra(); + let (event_id_bytes, signed_created_at) = admission.into_extra(); check_nip98_replay(state, &tenant, event_id_bytes) .await @@ -2753,7 +2753,7 @@ async fn authorize_moderation_read( tenant.community(), &pubkey_bytes, super::relay_members::extract_auth_tag_header(headers), - None, + signed_created_at, ) .await .map_err(|e| e.into_response())?; @@ -2953,7 +2953,7 @@ mod typing_postgres_tests; #[cfg(test)] pub(crate) mod postgres_tests { use super::*; - use nostr::{Alphabet, EventBuilder, Keys, Kind, SingleLetterTag, Tag}; + use nostr::{Alphabet, EventBuilder, Keys, Kind, SingleLetterTag, Tag, Timestamp}; use std::sync::Mutex; fn redis_pool() -> deadpool_redis::Pool { @@ -5329,6 +5329,16 @@ pub(crate) mod postgres_tests { url: &str, method: &str, body: &[u8], + ) -> axum::http::HeaderMap { + make_nip98_headers_with_created_at(keys, url, method, body, Timestamp::now().as_secs()) + } + + fn make_nip98_headers_with_created_at( + keys: &Keys, + url: &str, + method: &str, + body: &[u8], + created_at: u64, ) -> axum::http::HeaderMap { use base64::engine::general_purpose::STANDARD as BASE64; use sha2::{Digest, Sha256}; @@ -5340,6 +5350,7 @@ pub(crate) mod postgres_tests { ]; let event = EventBuilder::new(Kind::HttpAuth, "") .tags(tags) + .custom_created_at(Timestamp::from(created_at)) .sign_with_keys(keys) .expect("sign NIP-98 event"); let event_json = serde_json::to_string(&event).expect("serialize NIP-98 event"); @@ -6469,6 +6480,170 @@ pub(crate) mod postgres_tests { ); } + /// A moderator-privileged agent remains subject to its verified NIP-OA + /// owner's ban on moderation reads. The first request has no stored owner + /// link and uses a time-bounded auth tag; the second uses the durable owner + /// link. A clear fresh-owner control reaches the queue. + /// Mutation: discard `signed_created_at` in `authorize_moderation_read` → + /// the fresh-owner ban is missed and the moderator agent receives 200 → RED. + #[test] + #[ignore = "requires Postgres and disposable Redis via REDIS_URL"] + fn moderation_read_honors_fresh_and_stored_banned_agent_owners() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(mut state) = rt.block_on(nip_fi_off_test_state()) else { + panic!("local Postgres and Redis must be available"); + }; + let mut config = state.config.as_ref().clone(); + config.require_relay_membership = true; + Arc::get_mut(&mut state) + .expect("test state has one owner") + .config = Arc::new(config); + + rt.block_on(async { + let issuer = Keys::generate(); + for scenario in ["fresh-banned-owner", "stored-banned-owner", "fresh-clear-owner"] { + let host = format!( + "moderation-owner-{scenario}-{}.local", + uuid::Uuid::new_v4().simple() + ); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community") + .id; + let owner = Keys::generate(); + let agent = Keys::generate(); + for keys in [&owner, &agent] { + state + .db + .ensure_user(community, keys.public_key().as_bytes()) + .await + .expect("ensure owner and moderator-agent users"); + } + state + .db + .add_relay_member( + community, + &owner.public_key().to_hex(), + "member", + None, + ) + .await + .expect("seed owner as a community member"); + state + .db + .add_relay_member( + community, + &agent.public_key().to_hex(), + "admin", + None, + ) + .await + .expect("seed agent's own moderator role"); + + let stored_owner = scenario == "stored-banned-owner"; + if stored_owner { + assert!(state + .db + .set_agent_owner_for_authorization( + community, + agent.public_key().as_bytes(), + owner.public_key().as_bytes(), + ) + .await + .expect("store agent owner link")); + } + let linked_owner = state + .db + .get_agent_channel_policy(community, agent.public_key().as_bytes()) + .await + .expect("read agent owner link") + .and_then(|(_, owner)| owner); + assert_eq!( + linked_owner, + stored_owner.then(|| owner.public_key().to_bytes().to_vec()), + "fresh and stored owner variants must be distinct" + ); + + let banned_owner = scenario != "fresh-clear-owner"; + if banned_owner { + state + .db + .ban_community_member( + community, + owner.public_key().as_bytes(), + issuer.public_key().as_bytes(), + None, + None, + ) + .await + .expect("seed owner ban"); + } + + // The signed event is older than the NIP-OA upper bound while + // remaining fresh for NIP-98. Passing `Utc::now()` or dropping + // the signed time would therefore fail to resolve this owner. + let auth_created_at = Timestamp::now().as_secs().saturating_sub(5); + let owner_tag = buzz_sdk::nip_oa::compute_auth_tag( + &owner, + &agent.public_key(), + &format!("created_at<{}", auth_created_at + 1), + ) + .expect("sign time-bounded owner proof"); + let url = format!("https://{host}/moderation/reports"); + let mut headers = make_nip98_headers_with_created_at( + &agent, + &url, + "GET", + b"", + auth_created_at, + ); + if !stored_owner { + headers.insert( + "x-auth-tag", + owner_tag.parse().expect("valid NIP-OA auth header"), + ); + } + + let (status, _, body) = oneshot_request_full( + Arc::clone(&state), + "GET", + "/moderation/reports", + &host, + headers, + b"", + ) + .await; + if banned_owner { + assert_eq!( + status, + axum::http::StatusCode::FORBIDDEN, + "{scenario}: an agent with its own admin role must still inherit its owner's ban; body: {body:?}" + ); + let body: serde_json::Value = + serde_json::from_slice(&body).expect("ban response JSON"); + assert_eq!( + body["error"], + "blocked: you are banned from this community", + "{scenario}: denial must come from the ban gate, not the moderator role gate" + ); + } else { + assert_eq!( + status, + axum::http::StatusCode::OK, + "a moderator agent with an unbanned owner must reach the queue" + ); + assert_eq!(body.as_ref(), b"[]", "fresh clear-owner control"); + } + } + }); + } + // ── T2-seam: admitted malformed query through real handler → 400 ───────── // // Thufir's required seam test: one admitted malformed-query request through diff --git a/crates/buzz-relay/src/api/mesh_demo.rs b/crates/buzz-relay/src/api/mesh_demo.rs index 8649b97671a..77dc9aaed84 100644 --- a/crates/buzz-relay/src/api/mesh_demo.rs +++ b/crates/buzz-relay/src/api/mesh_demo.rs @@ -336,8 +336,9 @@ mod tests { }, ) .await; - assert_eq!(resp.status(), StatusCode::OK); + let status = resp.status(); let body = body_json(resp).await; + assert_eq!(status, StatusCode::OK, "forwarded response: {body}"); assert_eq!(body["outcome"], "forwarded"); assert_eq!(body["echoed_payload"], "mesh echo evidence"); owner_task.abort(); diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 8115ed9bbd4..6db0d32955f 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -3797,7 +3797,7 @@ mod tests { // Fix 5: use Config::for_test() which holds NIP_FI_ENV_LOCK internally. [FI-TRACE-ENV-RACE] let mut config = crate::config::Config::for_test(); config.require_relay_membership = require_relay_membership; - config.database_url = "postgres://buzz:buzz_dev@127.0.0.1:1/buzz".to_string(); + config.database_url = "postgres://buzz:buzz_dev@127.0.0.1:1/buzz".to_string(); // sadscan:disable np.postgres.1 -- unreachable local-only connection for DB-free tests config.redis_url = "redis://127.0.0.1:1".to_string(); let mut pool_options = sqlx::postgres::PgPoolOptions::new(); if let Some(timeout) = acquire_timeout { @@ -13845,6 +13845,134 @@ mod tests { ); } + /// A relay and channel administrator is still subject to a community + /// ban at huddle admission. The member control proves the fixture has + /// working channel admission before the banned administrator is tried. + /// Mutation: bypass the huddle ban gate for admins → RED. + #[tokio::test] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn audio_join_rejects_banned_admin() { + let state = audio_test_state_real_db().await.expect( + "PostgreSQL must be available — set BUZZ_TEST_DATABASE_URL or start local postgres", + ); + let (tenant, channel_id, admin) = seed_audio_fixture(state.db.pool()).await; + sqlx::query( + "UPDATE channel_members SET role = 'admin' \ + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3", + ) + .bind(tenant.community().as_uuid()) + .bind(channel_id) + .bind(admin.public_key().to_bytes().to_vec()) + .execute(state.db.pool()) + .await + .expect("promote channel administrator"); + state + .db + .add_relay_member( + tenant.community(), + &admin.public_key().to_hex(), + "admin", + None, + ) + .await + .expect("seed relay administrator"); + + let control = nostr::Keys::generate(); + sqlx::query( + "INSERT INTO channel_members (community_id, channel_id, pubkey, role) \ + VALUES ($1, $2, $3, 'member')", + ) + .bind(tenant.community().as_uuid()) + .bind(channel_id) + .bind(control.public_key().to_bytes().to_vec()) + .execute(state.db.pool()) + .await + .expect("seed unbanned huddle control"); + let (mut control_socket, control_server) = + open_admitted_audio_socket(&state, tenant.clone(), channel_id, &control, None) + .await; + control_socket + .close(None) + .await + .expect("close allowed huddle control"); + control_server.abort(); + + state + .db + .ban_community_member( + tenant.community(), + &admin.public_key().to_bytes(), + &nostr::Keys::generate().public_key().to_bytes(), + None, + None, + ) + .await + .expect("seed administrator ban"); + + let (frames, _) = + run_audio_auth_in(state, tenant, channel_id, None, &admin, true).await; + + assert_eq!( + frames, + vec![serde_json::json!({ + "type": "error", + "message": "blocked: you are banned from this community" + }) + .to_string()] + ); + } + + /// A delegated agent is refused at huddle admission when its proven + /// NIP-OA owner is community-banned, even though the agent has no + /// direct ban row. The owner is also a channel member so the denial + /// reaches the ban gate rather than failing channel authorization. + /// Mutation: omit the proven owner from the huddle restriction lookup + /// → the agent is admitted → RED. + #[tokio::test] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn audio_join_rejects_banned_owner_agent() { + let state = audio_test_state_real_db().await.expect( + "PostgreSQL must be available — set BUZZ_TEST_DATABASE_URL or start local postgres", + ); + let (tenant, channel_id, owner) = seed_audio_fixture(state.db.pool()).await; + let agent = nostr::Keys::generate(); + state + .db + .ban_community_member( + tenant.community(), + &owner.public_key().to_bytes(), + &nostr::Keys::generate().public_key().to_bytes(), + None, + None, + ) + .await + .expect("seed owner ban"); + let auth_tag = buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "") + .expect("sign owner proof"); + let auth_tag: Vec = + serde_json::from_str(&auth_tag).expect("decode auth tag JSON"); + + let (frames, _) = run_audio_auth_tagged( + state, + tenant, + channel_id, + None, + &agent, + true, + Some(auth_tag), + ) + .await; + + assert_eq!( + frames, + vec![serde_json::json!({ + "type": "error", + "message": "blocked: you are banned from this community" + }) + .to_string()] + ); + } + /// A ban closes a live audio socket: admission binds the socket to its /// pubkey, and the ban's pod-local disconnect /// (`AppState::disconnect_pubkey_local`) closes it with a policy close. diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 49b906ee3d8..e26bd31e3a5 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -2578,10 +2578,10 @@ async fn ingest_event_inner( // Community moderation commands (9040–9044) are direct, community-global // mutations. They are never stored or fanned out as ordinary events; the // handler writes the durable audit/enforcement rows after its own capability - // authorization. These commands are intentionally routed before the - // timeout/write-block gate below so a timed-out admin can lift a timeout. - // The handler independently checks the durable ban state before executing - // any command, which also covers NIP-98 and missed live disconnects. + // authorization. The write-restriction gate above permits only unban and + // untimeout commands during an active timeout. The handler independently + // checks durable ban state before executing any command, which also covers + // NIP-98 and missed live disconnects. if buzz_core::kind::is_moderation_command_kind(kind_u32) { super::moderation_commands::handle_moderation_command(tenant, state, &event) .await @@ -2757,9 +2757,8 @@ async fn ingest_event_inner( } // Handled directly — these mutate relay_members and do NOT get stored. - // The handler enforces the durable community ban itself: the write-path - // gate above exempts relay-admin kinds so timed-out admins keep their - // administrative capability, which leaves bans to the handler. + // The write-restriction gate above rejects banned and timed-out actors + // before this handler; the handler also checks durable community-ban state. if is_relay_admin_kind(event.kind.as_u16() as u32) { crate::handlers::relay_admin::handle_relay_admin_event(tenant, state, &event) .await diff --git a/crates/buzz-relay/src/handlers/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs index ea8fc901b8a..b0c9dbc3a43 100644 --- a/crates/buzz-relay/src/handlers/relay_admin.rs +++ b/crates/buzz-relay/src/handlers/relay_admin.rs @@ -147,12 +147,12 @@ pub(super) enum RelayAdminError { Internal(String), } -/// Decide whether a durable restriction state admits a relay-admin command. +/// Apply the relay-admin handler's local ban check. /// -/// Ban only, deliberately: a timeout is a write-block on *content*, and -/// `ingest_event` exempts relay-admin kinds from its durable write-path gate -/// precisely so restricted-but-not-banned admins retain their administrative -/// capability. Mirrors `moderation_commands::ensure_actor_not_banned`. +/// `ingest_event` runs the shared write-restriction gate before relay-admin +/// dispatch, where active timeouts are rejected. This check independently +/// preserves the handler's durable-ban invariant; passing it does not mean a +/// timed-out command passed the earlier gate. /// /// Split out as a pure function so the admission rule itself is unit-testable /// without a live relay: the end-to-end HTTP test proves the transport, this @@ -178,17 +178,14 @@ pub(super) async fn handle_relay_admin_event( state: &Arc, event: &Event, ) -> Result<(), RelayAdminError> { - // A ban is an admission boundary, not only a WebSocket-auth check. HTTP - // NIP-98 requests and already-authenticated sockets reach this handler - // without passing through a fresh NIP-42 challenge, and `ingest_event` - // exempts relay-admin kinds from its durable write-path gate so a *timed - // out* admin can still administer the roster. That exemption is ban-blind, - // so the ban must be enforced here or not at all: a banned admin otherwise - // keeps mutating `relay_members` — the very table `moderation_authz` - // derives moderator capability from — until someone manually deletes the - // row. Mirrors `moderation_commands.rs`, which defends the same boundary - // for 9040–9044, and holds that file's stated invariant that a direct - // command handler rejects a banned actor on every transport. + // HTTP NIP-98 requests and already-authenticated sockets reach this handler + // without a fresh NIP-42 challenge. The shared write-restriction gate in + // `ingest_event` rejects bans and active timeouts before kind dispatch; this + // handler repeats the durable-ban check as defense in depth before any + // roster mutation. A banned admin must not change `relay_members` — the + // table `moderation_authz` derives moderator capability from. This mirrors + // `moderation_commands.rs`, which applies the same handler-level ban check + // for kinds 9040–9044. // // This gate wraps execution rather than opening it so no future early // return inside the command body can precede it. @@ -505,11 +502,10 @@ mod postgres_tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; - /// The vulnerability this file's ban gate closes: `ingest_event` exempts - /// relay-admin kinds 9030–9033 from its durable write-path restriction - /// gate, so a **banned** admin could add/remove relay members and change - /// the workspace icon over signed NIP-98 `POST /events`. Deleting the - /// admission check must fail here, in the default (non-ignored) suite. + /// The handler-level ban check protects relay-admin commands that reach it + /// after authenticated admission. The shared `ingest_event` restriction + /// gate rejects active timeouts before dispatch; this default (non-ignored) + /// unit test pins the handler's independent durable-ban check. #[test] fn banned_actor_is_not_admitted_to_a_relay_admin_command() { let banned = buzz_db::moderation::RestrictionState { @@ -523,18 +519,17 @@ mod postgres_tests { ); } - /// The counter-invariant, and the reason the ingest exemption exists at - /// all: a timeout restricts *content* writes, not administrative - /// capability. Widening this gate to timeouts would silently change policy. + /// A timeout is not a ban at this handler-local check. The shared ingest + /// gate rejects timed-out relay-admin requests before this check runs. #[test] - fn timed_out_actor_is_still_admitted() { + fn timed_out_actor_passes_local_ban_check() { let timed_out = buzz_db::moderation::RestrictionState { banned: false, muted_until: Some(chrono::Utc::now() + chrono::Duration::minutes(5)), }; assert!( admits_relay_admin_command(&timed_out).is_ok(), - "a timed-out admin must still administer the roster" + "the local ban check must not classify a timeout as a ban" ); } diff --git a/crates/buzz-relay/src/mesh_boot.rs b/crates/buzz-relay/src/mesh_boot.rs index 63a346258f5..d18d7ff8ca0 100644 --- a/crates/buzz-relay/src/mesh_boot.rs +++ b/crates/buzz-relay/src/mesh_boot.rs @@ -421,24 +421,39 @@ pub(crate) async fn run_demo_echo( tracing::info!(%session_id, %peer, "mesh demo echo: session open"); let mut drain_tick = tokio::time::interval(std::time::Duration::from_millis(100)); loop { - let frame = tokio::select! { - _ = drain_tick.tick() => { - if shutting_down.load(Ordering::Relaxed) { - if let Some(community_id) = stream.community_id() { - if let Err(e) = stream.send_goodbye(community_id, GoodbyeReason::Draining).await { - tracing::warn!(%session_id, "mesh demo echo: draining goodbye failed: {e}"); - } else { - tracing::info!(%session_id, "mesh demo echo: sent draining goodbye"); + let frame = { + // A receive can consume bytes before waiting on Redis validation. + // Keep it alive across housekeeping ticks; only shutdown may cancel it. + let recv = stream.recv_validated(&directory); + tokio::pin!(recv); + loop { + tokio::select! { + _ = drain_tick.tick() => { + if shutting_down.load(Ordering::Relaxed) { + break None; } - } else { - let _ = stream.finish(); - tracing::info!(%session_id, "mesh demo echo: drain before community latch — closing"); } - return; + frame = &mut recv => break Some(frame), } - continue; } - frame = stream.recv_validated(&directory) => frame, + }; + // The pending receive and its mutable stream borrow have ended before + // the drain response or next echo uses the stream. + let Some(frame) = frame else { + if let Some(community_id) = stream.community_id() { + if let Err(e) = stream + .send_goodbye(community_id, GoodbyeReason::Draining) + .await + { + tracing::warn!(%session_id, "mesh demo echo: draining goodbye failed: {e}"); + } else { + tracing::info!(%session_id, "mesh demo echo: sent draining goodbye"); + } + } else { + let _ = stream.finish(); + tracing::info!(%session_id, "mesh demo echo: drain before community latch — closing"); + } + return; }; match frame { Ok(Some(ReliableFrame::Data(payload))) => { @@ -696,6 +711,72 @@ mod tests { MeshStream::new(Box::new(StubSend), Box::new(StubRecv)) } + struct PausedRecv { + started: Option>, + resume: Option>, + calls: Arc, + } + + impl StreamRecvHalf for PausedRecv { + fn recv_frame(&mut self) -> BoxFuture<'_, Result, MeshError>> { + self.calls.fetch_add(1, Ordering::Relaxed); + let started = self.started.take(); + let resume = self.resume.take(); + Box::pin(async move { + if let Some(started) = started { + let _ = started.send(()); + } + if let Some(resume) = resume { + resume.await.expect("test resumes the in-flight receive"); + } + Ok(None) + }) + } + } + + /// Housekeeping must retain a receive that already consumed transport state. + /// Returning to recv_frame after a tick loses that state and closes the stream. + #[tokio::test(start_paused = true)] + async fn demo_echo_retains_pending_receive_across_housekeeping_ticks() { + let pool = deadpool_redis::Config::from_url("redis://127.0.0.1:1") + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("test pool"); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (resume_tx, resume_rx) = tokio::sync::oneshot::channel(); + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let fenced = FencedHeader { + session_id: uuid::Uuid::new_v4(), + generation: 1, + owner_runtime_id: rid(1), + }; + let stream = MeshStream::new( + Box::new(StubSend), + Box::new(PausedRecv { + started: Some(started_tx), + resume: Some(resume_rx), + calls: Arc::clone(&calls), + }), + ); + let worker = tokio::spawn(run_demo_echo( + SessionDirectory::new(pool), + ReliableInbound { + fenced, + from: rid(2), + stream: crate::tunnel::reliable::ReliableMeshStream::new_inbound(fenced, stream), + }, + Arc::new(AtomicBool::new(false)), + )); + started_rx + .await + .expect("production consumer started receiving"); + tokio::time::advance(std::time::Duration::from_millis(300)).await; + tokio::task::yield_now().await; + assert_eq!(calls.load(Ordering::Relaxed), 1, "tick restarted a receive"); + assert!(!worker.is_finished(), "tick abandoned the pending receive"); + resume_tx.send(()).expect("receive remains alive"); + worker.await.expect("consumer completed after stream EOF"); + } + fn rid(byte: u8) -> RuntimeId { RuntimeId([byte; 32]) } diff --git a/crates/buzz-relay/src/tunnel/reliable.rs b/crates/buzz-relay/src/tunnel/reliable.rs index 5153e104df6..8238d410830 100644 --- a/crates/buzz-relay/src/tunnel/reliable.rs +++ b/crates/buzz-relay/src/tunnel/reliable.rs @@ -329,6 +329,9 @@ impl ReliableMeshStream { /// pinned fenced tuple and the Redis directory. This is the reliable-stream /// equivalent of Dawn's hot-path media floor, but authoritative: stale or /// mismatched frames fail the session rather than being dropped silently. + /// This receive is not cancellation-safe: it can consume frame bytes before + /// awaiting directory validation. Preserve an in-flight receive across + /// housekeeping ticks; cancellation is only safe when terminating the stream. pub async fn recv_validated( &mut self, directory: &SessionDirectory, diff --git a/crates/buzz-relay/tests/boot_lifecycle.rs b/crates/buzz-relay/tests/boot_lifecycle.rs index 027fc4d1c26..ad6f20f17bd 100644 --- a/crates/buzz-relay/tests/boot_lifecycle.rs +++ b/crates/buzz-relay/tests/boot_lifecycle.rs @@ -168,20 +168,38 @@ fn wait_for_relay_metrics(process: &mut RelayProcess, port: u16) -> String { /// [`METRICS_SCRAPE_DEADLINE`]. A relay that exits first is a failure, not a /// timeout, so the panic names the real cause. fn wait_for_scraped_metric(process: &mut RelayProcess, port: u16, needle: &str) -> String { + wait_for_scraped_metrics(process, port, &[needle]) +} + +/// Waits for one scrape to contain every requested sample, bounded by +/// [`METRICS_SCRAPE_DEADLINE`]. A scrape can overlap the sampler's sequential +/// metric writes, so related series may appear in different responses. +fn wait_for_scraped_metrics(process: &mut RelayProcess, port: u16, needles: &[&str]) -> String { + let requested = needles.join(", "); + let mut last_scrape = String::new(); + let mut last_error: Option; let deadline = Instant::now() + METRICS_SCRAPE_DEADLINE; loop { assert!( process.try_wait().is_none(), - "relay exited before exporting {needle}" + "relay exited before exporting {requested}" ); - if let Ok(response) = scrape_metrics(port) { - if response.contains(needle) { - return response; + match scrape_metrics(port) { + Ok(response) => { + if needles + .iter() + .all(|needle| response.lines().any(|line| line.starts_with(needle))) + { + return response; + } + last_scrape = response; + last_error = None; } + Err(error) => last_error = Some(error.to_string()), } assert!( Instant::now() < deadline, - "relay did not export {needle} within {METRICS_SCRAPE_DEADLINE:?}" + "relay did not export {requested} within {METRICS_SCRAPE_DEADLINE:?}; last error: {last_error:?}; last scrape: {last_scrape}" ); thread::sleep(Duration::from_millis(20)); } @@ -814,10 +832,10 @@ mod postgres_tests { /// fires immediately, so the wait is bounded by /// [`METRICS_SCRAPE_DEADLINE`] and never a fixed sleep. /// - /// The completion-timestamp gauge is the needle because `sample` writes it - /// last, after the cache already serves the report: observing it proves the - /// whole cycle ran, with no window where the counters have landed but the - /// timestamp has not. + /// The sampler writes counters and histograms before the completion gauge, + /// but a concurrent `/metrics` scrape is not an atomic snapshot. Wait for + /// the actual timestamp, counter, and duration samples together so a scrape + /// that overlaps those sequential writes cannot report a false failure. #[test] #[ignore = "requires PostgreSQL"] fn startup_owns_the_dependency_sampler() { @@ -839,10 +857,14 @@ mod postgres_tests { ("REDIS_URL", &redis_url), ("BUZZ_GIT_CONFORMANCE_PROBE", "false"), ]); - let scrape = wait_for_scraped_metric( + let scrape = wait_for_scraped_metrics( &mut process, metrics_port, - "buzz_readiness_dependency_sample_completed_timestamp_seconds", + &[ + "buzz_readiness_dependency_sample_completed_timestamp_seconds ", + "buzz_readiness_dependency_checks_total{", + "buzz_readiness_check_duration_seconds_count{check=\"overall\"}", + ], ); let output = process.terminate(); let logs = format!( @@ -851,14 +873,6 @@ mod postgres_tests { String::from_utf8_lossy(&output.stderr) ); - assert!( - scrape.contains("buzz_readiness_dependency_checks_total{"), - "the same cycle must publish its per-dependency outcomes: {scrape}" - ); - assert!( - scrape.contains("buzz_readiness_check_duration_seconds"), - "a completed evaluation must publish its latency too: {scrape}" - ); assert!( !scrape.contains("buzz_readiness_checks_total{"), "no readiness probe was sent, so the sampler alone produced this: {scrape}" diff --git a/crates/buzz-test-client/tests/community_ban_routes.rs b/crates/buzz-test-client/tests/community_ban_routes.rs new file mode 100644 index 00000000000..de9b3d3eea7 --- /dev/null +++ b/crates/buzz-test-client/tests/community_ban_routes.rs @@ -0,0 +1,701 @@ +//! Community-ban coverage for the relay's registered HTTP and root WebSocket routes. +//! +//! The route inventory is shared with `scripts/check-community-ban-route-inventory.py`. +//! This ignored test uses a disposable local relay/PostgreSQL/MinIO stack and +//! fresh tenant hosts and identities. GIF requests stop at malformed local +//! input before any provider request; Git and workflow requests use absent +//! resources and must stop before subprocess or durable work. +//! +//! ```text +//! ./scripts/start-relay-for-tests.sh +//! RELAY_URL=ws://localhost:3000 RELAY_HTTP_URL=http://localhost:3000 \ +//! cargo test -p buzz-test-client --test community_ban_routes -- --ignored --nocapture +//! ``` + +use std::{collections::BTreeSet, time::Duration}; + +use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; +use base64::Engine as _; +use buzz_test_client::{BuzzTestClient, TestClientError}; +use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; +use reqwest::{Client, Method, Response, StatusCode}; +use sha2::{Digest, Sha256}; +use sqlx::{Pool, Postgres}; +use uuid::Uuid; + +const INVENTORY: &str = include_str!("fixtures/community-ban-route-inventory.tsv"); +const BANNED_MESSAGE: &str = "blocked: you are banned from this community"; + +const COMMUNITY_BAN_MATRIX_CASES: &[&str] = &[ + "events", + "buzz_sidebar", + "buzz_read_state", + "moderation_command", + "query", + "count", + "gif_search", + "gif_share", + "workflow_runs", + "workflow_approvals", + "invite_mint", + "moderation_reports", + "moderation_owner_ban", + "moderation_audit", + "moderation_restricted", + "media_upload", + "media_get", + "media_head", + "git_info_refs", + "git_upload_pack", + "git_receive_pack", + "git_default_get", + "git_default_post", +]; + +#[derive(Clone, Debug)] +struct RouteRow { + id: String, + method: String, + path: String, + exercise: String, + control: String, +} + +#[derive(Clone)] +struct Principal { + name: &'static str, + keys: Keys, + nip_oa_tag: Option, +} + +fn relay_http_url() -> String { + std::env::var("RELAY_HTTP_URL").unwrap_or_else(|_| { + relay_url() + .replace("wss://", "https://") + .replace("ws://", "http://") + }) +} + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +fn relay_authority() -> String { + let url = url::Url::parse(&relay_http_url()).expect("relay HTTP URL"); + url[url::Position::BeforeHost..url::Position::AfterPort].to_string() +} + +fn http_origin_for_host(host: &str) -> String { + let scheme = if relay_http_url().starts_with("https://") { + "https" + } else { + "http" + }; + format!("{scheme}://{host}") +} + +fn test_host() -> String { + format!( + "community-ban-{}.{}", + Uuid::new_v4().simple(), + relay_authority() + ) +} + +fn database_url() -> String { + std::env::var("DATABASE_URL") + .or_else(|_| std::env::var("BUZZ_TEST_DATABASE_URL")) + .expect("set DATABASE_URL or BUZZ_TEST_DATABASE_URL to a disposable relay-test database") +} + +async fn pool() -> Pool { + sqlx::postgres::PgPoolOptions::new() + .max_connections(2) + .connect(&database_url()) + .await + .expect("connect to disposable relay-test PostgreSQL") +} + +async fn ensure_community(pool: &Pool, host: &str) -> Uuid { + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO communities (id, host) VALUES ($1, $2) \ + ON CONFLICT (lower(host)) DO NOTHING", + ) + .bind(id) + .bind(host) + .execute(pool) + .await + .unwrap_or_else(|error| panic!("seed disposable community {host}: {error}")); + sqlx::query_scalar("SELECT id FROM communities WHERE lower(host) = lower($1)") + .bind(host) + .fetch_one(pool) + .await + .unwrap_or_else(|error| panic!("lookup disposable community {host}: {error}")) +} + +async fn seed_member(pool: &Pool, community: Uuid, keys: &Keys, role: &str) { + sqlx::query("INSERT INTO users (community_id, pubkey) VALUES ($1, $2) ON CONFLICT DO NOTHING") + .bind(community) + .bind(keys.public_key().to_bytes().to_vec()) + .execute(pool) + .await + .unwrap_or_else(|error| panic!("seed {role} user: {error}")); + sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ + VALUES ($1, $2, $3, NULL) \ + ON CONFLICT (community_id, pubkey) DO UPDATE \ + SET role = EXCLUDED.role, updated_at = now()", + ) + .bind(community) + .bind(keys.public_key().to_hex()) + .bind(role) + .execute(pool) + .await + .unwrap_or_else(|error| panic!("seed {role} relay member: {error}")); +} + +async fn ban_member(pool: &Pool, community: Uuid, actor: &Keys, target: &Keys) { + sqlx::query( + "INSERT INTO community_bans (community_id, pubkey, banned, actor_pubkey) \ + VALUES ($1, $2, TRUE, $3) \ + ON CONFLICT (community_id, pubkey) DO UPDATE \ + SET banned = TRUE, actor_pubkey = EXCLUDED.actor_pubkey", + ) + .bind(community) + .bind(target.public_key().to_bytes().to_vec()) + .bind(actor.public_key().to_bytes().to_vec()) + .execute(pool) + .await + .unwrap_or_else(|error| panic!("seed community ban: {error}")); +} + +fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +fn nip98_header(keys: &Keys, url: &str, method: &str, body: &[u8]) -> String { + let mut tags = vec![ + Tag::parse(["u", url]).expect("NIP-98 URL tag"), + Tag::parse(["method", method]).expect("NIP-98 method tag"), + Tag::parse(["nonce", &Uuid::new_v4().to_string()]).expect("NIP-98 nonce tag"), + ]; + if !body.is_empty() { + tags.push(Tag::parse(["payload", &sha256_hex(body)]).expect("NIP-98 payload tag")); + } + let event = EventBuilder::new(Kind::Custom(27_235), "") + .tags(tags) + .sign_with_keys(keys) + .expect("sign NIP-98 event"); + format!("Nostr {}", STANDARD.encode(event.as_json().as_bytes())) +} + +fn blossom_header(keys: &Keys, tag: &str, hash: &str, host: &str) -> String { + let expiration = (Timestamp::now().as_secs() + 55).to_string(); + let tags = vec![ + Tag::parse(["t", tag]).expect("Blossom operation tag"), + Tag::parse(["x", hash]).expect("Blossom hash tag"), + Tag::parse(["expiration", &expiration]).expect("Blossom expiration tag"), + Tag::parse(["server", host]).expect("Blossom server tag"), + ]; + let event = EventBuilder::new(Kind::Custom(24_242), "community-ban route test") + .tags(tags) + .sign_with_keys(keys) + .expect("sign Blossom auth event"); + format!( + "Nostr {}", + URL_SAFE_NO_PAD.encode(event.as_json().as_bytes()) + ) +} + +fn parse_inventory() -> Vec { + INVENTORY + .lines() + .skip(1) + .filter(|line| !line.is_empty()) + .map(|line| { + let columns: Vec<_> = line.split('\t').collect(); + assert_eq!( + columns.len(), + 9, + "malformed community-ban inventory row: {line}" + ); + RouteRow { + id: columns[0].to_string(), + method: columns[2].to_string(), + path: columns[3].to_string(), + exercise: columns[7].to_string(), + control: columns[8].to_string(), + } + }) + .collect() +} + +fn route_path(row: &RouteRow, case: &str, actor: &Principal) -> String { + let uuid = Uuid::new_v4(); + let repo_owner = actor.keys.public_key().to_hex(); + let missing_repo = format!("community-ban-{}", uuid.simple()); + let missing_blob = sha256_hex(format!("missing-{}", uuid.simple()).as_bytes()); + let mut path = row + .path + .replace("{workflow_id}", &uuid.to_string()) + .replace("{run_id}", &Uuid::new_v4().to_string()) + .replace("{sha256_ext}", &format!("{missing_blob}.jpg")) + .replace("{owner}", &repo_owner) + .replace("{repo}", &missing_repo); + if case == "git_info_refs" { + path.push_str("?service=git-upload-pack"); + } + path +} + +fn request_body(case: &str, actor: &Principal) -> Vec { + match case { + "events" => EventBuilder::new(Kind::TextNote, "community-ban route matrix") + .sign_with_keys(&actor.keys) + .expect("sign test message") + .as_json() + .into_bytes(), + "moderation_command" => { + let target = Keys::generate(); + EventBuilder::new(Kind::Custom(9_040), "") + .tags([Tag::parse(["p", &target.public_key().to_hex()]).expect("target tag")]) + .sign_with_keys(&actor.keys) + .expect("sign ban command") + .as_json() + .into_bytes() + } + "query" | "count" => serde_json::to_vec(&serde_json::json!([{ + "kinds": [1], + "limit": 1 + }])) + .expect("serialize Nostr filter"), + "gif_search" | "gif_share" | "invite_mint" | "buzz_read_state" => b"{".to_vec(), + "media_upload" => b"not an image file".to_vec(), + "git_upload_pack" | "git_receive_pack" => Vec::new(), + "git_default_post" => serde_json::to_vec(&serde_json::json!({ + "branch": "main", + "expected_manifest": "missing" + })) + .expect("serialize default-branch request"), + _ => Vec::new(), + } +} + +fn git_signing_method<'a>(case: &str, request_method: &'a str) -> &'a str { + match case { + // Smart-HTTP clone and push intentionally reuse a GET-scoped NIP-98 + // credential for their POST pack requests. + "git_upload_pack" | "git_receive_pack" => "GET", + _ => request_method, + } +} + +fn nip98_url_for_case(origin: &str, path: &str, case: &str) -> String { + let repo_root = match case { + "git_info_refs" => path.split_once("/info/refs").map(|(prefix, _)| prefix), + "git_upload_pack" => path.strip_suffix("/git-upload-pack"), + "git_receive_pack" => path.strip_suffix("/git-receive-pack"), + _ => None, + }; + match repo_root { + Some(repo_root) => format!("{origin}{repo_root}"), + None => format!("{origin}{path}"), + } +} + +async fn send_route_request( + client: &Client, + row: &RouteRow, + case: &str, + actor: &Principal, + host: &str, +) -> Response { + let path = route_path(row, case, actor); + let url = format!("{}{}", relay_http_url().trim_end_matches('/'), path); + let signed_url = nip98_url_for_case(&http_origin_for_host(host), &path, case); + let body = request_body(case, actor); + let method = Method::from_bytes(row.method.as_bytes()).expect("inventory HTTP method"); + let mut request = client + .request(method.clone(), url) + .header(reqwest::header::HOST, host) + .header( + reqwest::header::CONTENT_TYPE, + if case == "media_upload" { + "image/jpeg" + } else { + "application/json" + }, + ); + + if case == "media_upload" || case == "media_get" || case == "media_head" { + let (operation, hash) = if case == "media_upload" { + ("upload", sha256_hex(&body)) + } else { + ( + "get", + path.split('/') + .next_back() + .unwrap_or_default() + .split('.') + .next() + .unwrap_or_default() + .to_string(), + ) + }; + let auth = blossom_header(&actor.keys, operation, &hash, host); + request = request.header(reqwest::header::AUTHORIZATION, auth); + if case == "media_upload" { + request = request.header("X-SHA-256", hash); + } + } else { + let auth_method = git_signing_method(case, row.method.as_str()); + let auth = nip98_header(&actor.keys, &signed_url, auth_method, &body); + request = request.header(reqwest::header::AUTHORIZATION, auth); + } + + if let Some(tag) = &actor.nip_oa_tag { + request = request.header("x-auth-tag", tag); + } + if !body.is_empty() + || method == Method::POST + || method == Method::PUT + || method == Method::PATCH + { + request = request.body(body); + } + request + .timeout(Duration::from_secs(15)) + .send() + .await + .unwrap_or_else(|error| { + panic!("{} {} for {} failed: {error}", row.method, path, actor.name) + }) +} + +fn matrix_cases(row: &RouteRow) -> Vec<&str> { + row.exercise + .strip_prefix("matrix:") + .expect("route-matrix exercise") + .split('+') + .collect() +} + +fn assert_ban_denial( + response_status: StatusCode, + body: &str, + route: &RouteRow, + case: &str, + actor: &str, +) { + assert_eq!( + response_status, + StatusCode::FORBIDDEN, + "{} {} case {case}, {actor} must be denied by the ban gate; body: {body}", + route.method, + route.path + ); + if route.id.starts_with("git_default_branch") { + assert_eq!( + body, r#"{"error":"blocked: you are banned from this community"}"#, + "{} {} must preserve Git settings' ban denial response; body: {body}", + route.method, route.path + ); + } else if route.id.starts_with("git_") { + assert!( + body.contains("blocked: banned from this community"), + "{} {} must preserve Git's ban denial contract; body: {body}", + route.method, + route.path + ); + } else if route.id.starts_with("buzz_") { + assert_eq!( + serde_json::from_str::(body).expect("accessory denial JSON") + ["error"]["code"], + "forbidden" + ); + } else if route.method == "HEAD" { + assert!( + body.is_empty(), + "{} {} must suppress the denial body for HEAD; body: {body}", + route.method, + route.path + ); + } else if route.id.starts_with("media_") { + // Blossom deliberately maps both non-membership and ban refusals to + // the same policy response. These fixtures are direct relay members, + // and the paired clear member control proves this is the ban gate. + assert_eq!( + body, r#"{"error":"relay membership required"}"#, + "{} {} must preserve Blossom's policy-denial response; body: {body}", + route.method, route.path + ); + } else { + assert!( + body.contains(BANNED_MESSAGE), + "{} {} must preserve the community ban denial contract; body: {body}", + route.method, + route.path + ); + } +} + +fn assert_allowed_control(response_status: StatusCode, body: &str, route: &RouteRow, case: &str) { + assert!( + matches!(response_status.as_u16(), 200..=299 | 400 | 404 | 409 | 415 | 422), + "{} {} case {case}: unbanned control must pass authentication and authorization, then reach the fixture's expected success or input/resource result; got {}: {body}", + route.method, + route.path, + response_status + ); + let denial_message = if route.id.starts_with("git_default_branch") { + BANNED_MESSAGE + } else if route.id.starts_with("git_") { + "blocked: banned from this community" + } else if route.id.starts_with("media_") { + "relay membership required" + } else { + BANNED_MESSAGE + }; + assert!( + !body.contains(denial_message), + "{} {} case {case}: allowed control must not receive a ban denial; body: {body}", + route.method, + route.path + ); +} + +fn principal<'a>(name: &str, principals: &[&'a Principal]) -> &'a Principal { + principals + .iter() + .find(|principal| principal.name == name) + .unwrap_or_else(|| panic!("unknown route-matrix control principal {name}")) +} + +#[tokio::test] +#[ignore] +async fn community_http_ban_matrix() { + let client = Client::builder() + .timeout(Duration::from_secs(15)) + .build() + .expect("build HTTP client"); + let db = pool().await; + let host = test_host(); + let community = ensure_community(&db, &host).await; + let implemented_cases: BTreeSet<_> = COMMUNITY_BAN_MATRIX_CASES.iter().copied().collect(); + let inventory = parse_inventory(); + let inventory_cases: BTreeSet<_> = inventory + .iter() + .filter(|route| route.exercise.starts_with("matrix:")) + .flat_map(matrix_cases) + .collect(); + assert_eq!( + inventory_cases, implemented_cases, + "every route-matrix exercise must have a behavior implementation" + ); + + let banned_member = Principal { + name: "member", + keys: Keys::generate(), + nip_oa_tag: None, + }; + let banned_admin = Principal { + name: "admin", + keys: Keys::generate(), + nip_oa_tag: None, + }; + let banned_agent_keys = Keys::generate(); + let banned_agent = Principal { + name: "agent", + nip_oa_tag: Some( + buzz_sdk::nip_oa::compute_auth_tag( + &banned_admin.keys, + &banned_agent_keys.public_key(), + "", + ) + .expect("sign delegated-agent owner proof"), + ), + keys: banned_agent_keys, + }; + let banned_moderator_agent_keys = Keys::generate(); + let banned_moderator_agent = Principal { + name: "moderator-agent", + nip_oa_tag: Some( + buzz_sdk::nip_oa::compute_auth_tag( + &banned_admin.keys, + &banned_moderator_agent_keys.public_key(), + "", + ) + .expect("sign moderator-agent owner proof"), + ), + keys: banned_moderator_agent_keys, + }; + let clear_member = Principal { + name: "member", + keys: Keys::generate(), + nip_oa_tag: None, + }; + let clear_admin = Principal { + name: "admin", + keys: Keys::generate(), + nip_oa_tag: None, + }; + let clear_owner = Principal { + name: "owner", + keys: Keys::generate(), + nip_oa_tag: None, + }; + let clear_moderator_agent_keys = Keys::generate(); + let clear_moderator_agent = Principal { + name: "clear-moderator-agent", + nip_oa_tag: Some( + buzz_sdk::nip_oa::compute_auth_tag( + &clear_owner.keys, + &clear_moderator_agent_keys.public_key(), + "", + ) + .expect("sign clear moderator-agent owner proof"), + ), + keys: clear_moderator_agent_keys, + }; + seed_member(&db, community, &banned_member.keys, "member").await; + seed_member(&db, community, &banned_admin.keys, "admin").await; + seed_member(&db, community, &clear_member.keys, "member").await; + seed_member(&db, community, &clear_admin.keys, "admin").await; + seed_member(&db, community, &clear_owner.keys, "owner").await; + seed_member(&db, community, &banned_moderator_agent.keys, "admin").await; + seed_member(&db, community, &clear_moderator_agent.keys, "admin").await; + ban_member(&db, community, &clear_owner.keys, &banned_member.keys).await; + ban_member(&db, community, &clear_owner.keys, &banned_admin.keys).await; + + let banned = [&banned_member, &banned_admin, &banned_agent]; + let controls = [ + &clear_member, + &clear_admin, + &clear_owner, + &clear_moderator_agent, + ]; + for route in parse_inventory() + .into_iter() + .filter(|route| route.exercise.starts_with("matrix:")) + { + for case in matrix_cases(&route) { + let ban_actors: Vec<&Principal> = if case == "moderation_owner_ban" { + vec![&banned_moderator_agent] + } else { + banned.to_vec() + }; + for actor in ban_actors { + let response = send_route_request(&client, &route, case, actor, &host).await; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + assert_ban_denial(status, &body, &route, case, actor.name); + } + + let control_name = match case { + "moderation_command" => "owner", + "moderation_owner_ban" => "clear-moderator-agent", + _ => route.control.as_str(), + }; + let control = principal(control_name, &controls); + let response = send_route_request(&client, &route, case, control, &host).await; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + assert_allowed_control(status, &body, &route, case); + } + } +} + +#[tokio::test] +#[ignore] +async fn ban_is_scoped_to_host_resolved_community() { + let client = Client::builder() + .timeout(Duration::from_secs(15)) + .build() + .expect("build HTTP client"); + let db = pool().await; + let host_a = test_host(); + let host_b = test_host(); + let community_a = ensure_community(&db, &host_a).await; + let community_b = ensure_community(&db, &host_b).await; + let actor = Principal { + name: "tenant-control", + keys: Keys::generate(), + nip_oa_tag: None, + }; + let issuer = Keys::generate(); + seed_member(&db, community_a, &actor.keys, "member").await; + seed_member(&db, community_b, &actor.keys, "member").await; + ban_member(&db, community_a, &issuer, &actor.keys).await; + + let route = parse_inventory() + .into_iter() + .find(|route| route.id == "http_query") + .expect("query route is inventoried"); + let in_b = send_route_request(&client, &route, "query", &actor, &host_b).await; + let in_b_status = in_b.status(); + let in_b_body = in_b.text().await.unwrap_or_default(); + assert_eq!( + in_b_status, + StatusCode::OK, + "ban in A must not block B: {in_b_body}" + ); + + let in_a = send_route_request(&client, &route, "query", &actor, &host_a).await; + let in_a_status = in_a.status(); + let in_a_body = in_a.text().await.unwrap_or_default(); + assert_ban_denial(in_a_status, &in_a_body, &route, "query", actor.name); +} + +#[tokio::test] +#[ignore] +async fn banned_root_websocket_is_refused() { + let db = pool().await; + let host = relay_authority(); + let community = ensure_community(&db, &host).await; + let issuer = Keys::generate(); + let banned = Keys::generate(); + let clear = Keys::generate(); + let banned_owner = Keys::generate(); + let owner_agent = Keys::generate(); + ban_member(&db, community, &issuer, &banned).await; + ban_member(&db, community, &issuer, &banned_owner).await; + + match BuzzTestClient::connect(&relay_url(), &banned).await { + Err(error) => assert!( + error.to_string().to_ascii_lowercase().contains("banned"), + "root WebSocket rejection must identify the ban, got {error}" + ), + Ok(client) => { + let _ = client.disconnect().await; + panic!("a community-banned principal authenticated on the root WebSocket") + } + } + + let auth_tag = buzz_sdk::nip_oa::compute_auth_tag(&banned_owner, &owner_agent.public_key(), "") + .expect("sign owner proof for WebSocket agent"); + let auth_tag: Vec = serde_json::from_str(&auth_tag).expect("decode owner proof"); + let auth_tag = Tag::parse(auth_tag).expect("parse NIP-OA auth tag"); + let mut agent_socket = BuzzTestClient::connect_unauthenticated(&relay_url()) + .await + .expect("open root WebSocket for owner-banned agent"); + let error = agent_socket + .authenticate_with_nip_oa(&owner_agent, &auth_tag) + .await + .expect_err("a NIP-OA agent of a banned owner must be refused"); + assert!( + error.to_string().to_ascii_lowercase().contains("banned"), + "root WebSocket owner ban must identify the restriction, got {error}" + ); + drop(agent_socket); + + let control = BuzzTestClient::connect(&relay_url(), &clear) + .await + .unwrap_or_else(|error: TestClientError| { + panic!("unbanned root WebSocket control must authenticate: {error}") + }); + control + .disconnect() + .await + .expect("disconnect unbanned root WebSocket control"); +} diff --git a/crates/buzz-test-client/tests/fixtures/community-ban-regressions.tsv b/crates/buzz-test-client/tests/fixtures/community-ban-regressions.tsv new file mode 100644 index 00000000000..8422f392bf4 --- /dev/null +++ b/crates/buzz-test-client/tests/fixtures/community-ban-regressions.tsv @@ -0,0 +1,20 @@ +id file test lane property +http_membership crates/buzz-relay/src/handlers/ingest.rs relay_membership_step_refuses_banned_member_on_http postgres-ci ban_and_unbanned_http_gate +root_admission crates/buzz-relay/src/handlers/auth.rs fi_ban_denial_emits_terminal_authorization_denied postgres-ci root_nip42_ban_denial +root_admission_race crates/buzz-relay/src/handlers/auth.rs ban_committed_during_auth_refuses_admission postgres-ci admission_ban_race_and_other_community_control +root_ban_eviction crates/buzz-relay/src/handlers/relay_admin.rs ban_closes_socket_even_when_audit_insert_fails postgres-ci root_ban_closes_local_socket_before_failed_audit +root_owner_eviction crates/buzz-relay/src/handlers/auth.rs root_auth_records_owner_for_lookup_free_revoke postgres-ci root_socket_owner_cascade_eviction +root_fail_closed crates/buzz-relay/src/handlers/auth.rs fi_ban_check_error_emits_terminal_authorization_unavailable unit root_restriction_lookup_unavailable +root_db_error crates/buzz-relay/src/handlers/auth.rs handler_accounts_ban_check_database_error unit root_restriction_lookup_unavailable_off_mode +audio_admission crates/buzz-relay/src/audio/handler.rs audio_join_rejects_community_banned_member postgres-ci huddle_banned_member_admission +audio_admin_admission crates/buzz-relay/src/audio/handler.rs audio_join_rejects_banned_admin postgres-ci huddle_banned_admin_admission_and_unbanned_control +audio_owner_admission crates/buzz-relay/src/audio/handler.rs audio_join_rejects_banned_owner_agent postgres-ci huddle_agent_banned_through_owner +audio_local_eviction crates/buzz-relay/src/audio/handler.rs ban_disconnect_closes_live_audio_socket postgres-ci pod_local_huddle_socket_eviction +audio_admission_race crates/buzz-relay/src/audio/handler.rs nip_fi_audio_ban_during_final_admission_sends_canonical_denial postgres-ci huddle_admission_ban_race +tenant_local_eviction crates/buzz-relay/src/state.rs disconnect_pubkey_is_fenced_to_the_banning_community unit pod_local_tenant_fence +media_fail_closed crates/buzz-relay/src/api/media.rs failed_restriction_lookup_is_503_not_403 postgres-ci media_upload_get_head_fail_closed +git_fail_closed crates/buzz-relay/src/api/git/transport.rs ban_gate_fails_closed_with_503_when_the_store_is_unreachable postgres-ci git_restriction_lookup_fail_closed +ingest_owner_ban crates/buzz-relay/src/handlers/ingest.rs owner_ban_blocks_every_agent_write_until_unban postgres-ci agent_owner_ban_write_gate +moderation_owner_ban crates/buzz-relay/src/api/bridge.rs moderation_read_honors_fresh_and_stored_banned_agent_owners postgres-ci moderation_owner_ban_auth_time_and_stored_link +timeout_admin_denial crates/buzz-test-client/tests/regression_relay_admin_ban_gate.rs banned_and_timed_out_admins_are_refused_but_unrestricted_admin_can_administer relay-e2e timed_out_admin_relay_admin_denial +http_tenant_isolation crates/buzz-test-client/tests/community_ban_routes.rs ban_is_scoped_to_host_resolved_community relay-e2e http_ban_tenant_isolation diff --git a/crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv b/crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv new file mode 100644 index 00000000000..b1431793fcf --- /dev/null +++ b/crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv @@ -0,0 +1,75 @@ +id listener method path auth_plane membership restriction exercise control +relay_root_ws relay GET / dual_nip11_nip42 conditional conditional_enforce e2e:banned_root_websocket_is_refused unbanned +relay_info relay GET /info public_metadata not_applicable not_applicable:public_metadata classified - +nip05 relay GET /.well-known/nostr.json public_metadata not_applicable not_applicable:public_metadata classified - +relay_health relay GET /health health_probe not_applicable not_applicable:health_probe classified - +relay_liveness relay GET /_liveness health_probe not_applicable not_applicable:health_probe classified - +relay_readiness relay GET /_readiness health_probe not_applicable not_applicable:health_probe classified - +http_events relay POST /events community_nip98 community enforce matrix:events+moderation_command member +http_query relay POST /query community_nip98 community enforce matrix:query member +http_count relay POST /count community_nip98 community enforce matrix:count member +gif_search relay POST /gifs/search community_nip98 community enforce matrix:gif_search member +gif_share relay POST /gifs/share community_nip98 community enforce matrix:gif_share member +workflow_runs relay GET /workflows/{workflow_id}/runs community_nip98 community enforce matrix:workflow_runs member +workflow_approvals relay GET /workflows/{workflow_id}/runs/{run_id}/approvals community_nip98 community enforce matrix:workflow_approvals member +operator_list_communities relay GET /operator/communities operator_control not_applicable not_applicable:relay_operator_control classified - +operator_provision_community relay POST /operator/communities operator_control not_applicable not_applicable:relay_operator_control classified - +operator_register_listener_keys relay POST /operator/listener/pubkeys operator_control not_applicable not_applicable:relay_operator_control classified - +operator_remove_listener_keys relay DELETE /operator/listener/pubkeys operator_control not_applicable not_applicable:relay_operator_control classified - +operator_archive_community relay POST /operator/communities/archive operator_control not_applicable not_applicable:relay_operator_control classified - +operator_unarchive_community relay POST /operator/communities/unarchive operator_control not_applicable not_applicable:relay_operator_control classified - +operator_delete_community relay POST /operator/communities/delete operator_control not_applicable not_applicable:relay_operator_control classified - +operator_community_availability relay GET /operator/communities/availability operator_control not_applicable not_applicable:relay_operator_control classified - +operator_transfer_community relay POST /operator/communities/transfer operator_control not_applicable not_applicable:relay_operator_control classified - +invite_mint relay POST /api/invites community_nip98 community enforce matrix:invite_mint admin +join_policy relay GET /api/join-policy public_metadata not_applicable not_applicable:public_metadata classified - +join_policy_terms relay GET /api/join-policy/terms public_metadata not_applicable not_applicable:public_metadata classified - +join_policy_privacy relay GET /api/join-policy/privacy public_metadata not_applicable not_applicable:public_metadata classified - +invite_accept_policy relay POST /api/invites/accept-policy pre_membership_anonymous exempt not_applicable:no_authenticated_principal classified - +invite_claim relay POST /api/invites/claim pre_membership_nip98 exempt enforce pending:BUZZ-268 BUZZ-268 +nip_fi_disconnect relay POST /api/nip-fi/disconnect internal_nip_fi not_applicable not_applicable:internal_auth_plane classified - +moderation_reports relay GET /moderation/reports community_nip98 community enforce matrix:moderation_reports+moderation_owner_ban admin +moderation_audit relay GET /moderation/audit community_nip98 community enforce matrix:moderation_audit+moderation_owner_ban admin +moderation_restricted relay GET /moderation/restricted community_nip98 community enforce matrix:moderation_restricted+moderation_owner_ban admin +workflow_webhook relay POST /hooks/{id} workflow_webhook_secret not_applicable enforce pending:BUZZ-272 BUZZ-272 +mesh_demo_echo relay POST /_mesh/demo/echo testbed_internal not_applicable not_applicable:testbed_only classified - +huddle_audio relay GET /huddle/{channel_id}/audio community_nip42 community enforce pg:audio_join_rejects_community_banned_member+audio_join_rejects_banned_admin+audio_join_rejects_banned_owner_agent - +media_upload relay PUT /upload community_blossom community enforce matrix:media_upload member +media_upload_alias relay PUT /media/upload community_blossom community enforce matrix:media_upload member +media_read_get relay GET /media/{sha256_ext} community_blossom community enforce matrix:media_get member +media_read_head relay HEAD /media/{sha256_ext} community_blossom community enforce matrix:media_head member +git_info_refs relay GET /git/{owner}/{repo}/info/refs community_git_nip98 community enforce matrix:git_info_refs admin +git_upload_pack relay POST /git/{owner}/{repo}/git-upload-pack community_git_nip98 community enforce matrix:git_upload_pack admin +git_receive_pack relay POST /git/{owner}/{repo}/git-receive-pack community_git_nip98 community enforce matrix:git_receive_pack admin +git_default_branch_get relay GET /git/{owner}/{repo}/default-branch community_git_nip98 community enforce matrix:git_default_get admin +git_default_branch_post relay POST /git/{owner}/{repo}/default-branch community_git_nip98 community enforce matrix:git_default_post admin +git_policy_hook relay POST /internal/git/policy internal_git_hook internal not_applicable:internal_auth_plane classified - +admin_probe relay GET /api/admin/v1/probe operator_control not_applicable not_applicable:relay_admin_control classified - +admin_reports_list relay GET /api/admin/v1/reports operator_control not_applicable not_applicable:relay_admin_control classified - +admin_report_detail relay GET /api/admin/v1/reports/{id} operator_control not_applicable not_applicable:relay_admin_control classified - +admin_report_resolve relay POST /api/admin/v1/reports/{id}/resolve operator_control not_applicable not_applicable:relay_admin_control classified - +admin_report_reopen relay POST /api/admin/v1/reports/{id}/reopen operator_control not_applicable not_applicable:relay_admin_control classified - +admin_report_cancel relay POST /api/admin/v1/reports/{id}/cancel operator_control not_applicable not_applicable:relay_admin_control classified - +admin_feedback_list relay GET /api/admin/v1/feedback operator_control not_applicable not_applicable:relay_admin_control classified - +admin_feedback_detail_get relay GET /api/admin/v1/feedback/{id} operator_control not_applicable not_applicable:relay_admin_control classified - +admin_feedback_detail_patch relay PATCH /api/admin/v1/feedback/{id} operator_control not_applicable not_applicable:relay_admin_control classified - +admin_feedback_attachment relay GET /api/admin/v1/feedback/{id}/attachments/{sha256} operator_control not_applicable not_applicable:relay_admin_control classified - +admin_operators_list relay GET /api/admin/v1/operators operator_control not_applicable not_applicable:relay_admin_control classified - +admin_operator_upsert relay PUT /api/admin/v1/operators/{pubkey} operator_control not_applicable not_applicable:relay_admin_control classified - +admin_operator_delete relay DELETE /api/admin/v1/operators/{pubkey} operator_control not_applicable not_applicable:relay_admin_control classified - +admin_restrictions_list relay GET /api/admin/v1/members/restrictions operator_control not_applicable not_applicable:relay_admin_control classified - +admin_member_unban relay DELETE /api/admin/v1/members/{pubkey}/ban operator_control not_applicable not_applicable:relay_admin_control classified - +admin_member_untimeout relay DELETE /api/admin/v1/members/{pubkey}/timeout operator_control not_applicable not_applicable:relay_admin_control classified - +admin_member_ban relay POST /api/admin/v1/members/{pubkey}/ban operator_control not_applicable not_applicable:relay_admin_control classified - +admin_member_timeout relay POST /api/admin/v1/members/{pubkey}/timeout operator_control not_applicable not_applicable:relay_admin_control classified - +admin_delete_event relay POST /api/admin/v1/events/{id}/delete operator_control not_applicable not_applicable:relay_admin_control classified - +health_liveness health GET /_liveness health_probe not_applicable not_applicable:health_probe classified - +health_readiness health GET /_readiness health_probe not_applicable not_applicable:health_probe classified - +health_status health GET /_status health_probe not_applicable not_applicable:health_probe classified - +health_mesh health GET /_mesh health_probe not_applicable not_applicable:health_probe classified - +admin_communities relay GET /api/admin/v1/communities operator_control not_applicable not_applicable:relay_admin_control classified - +admin_event relay GET /api/admin/v1/events/{id} operator_control not_applicable not_applicable:relay_admin_control classified - +admin_member_search relay GET /api/admin/v1/members/search operator_control not_applicable not_applicable:relay_admin_control classified - +admin_member relay GET /api/admin/v1/members/{pubkey} operator_control not_applicable not_applicable:relay_admin_control classified - +buzz_sidebar relay GET /buzz/v1/me/sidebar community_nip98 community enforce matrix:buzz_sidebar member +buzz_read_state relay POST /buzz/v1/me/read-state community_nip98 community enforce matrix:buzz_read_state member diff --git a/crates/buzz-test-client/tests/regression_relay_admin_ban_gate.rs b/crates/buzz-test-client/tests/regression_relay_admin_ban_gate.rs index c37be8ca404..5d588461f33 100644 --- a/crates/buzz-test-client/tests/regression_relay_admin_ban_gate.rs +++ b/crates/buzz-test-client/tests/regression_relay_admin_ban_gate.rs @@ -1,17 +1,14 @@ //! Regression test for the NIP-43 relay-admin durable-ban bypass //! (BUZZ-SEC-007 class, reported 2026-07-27). //! -//! `ingest_event` exempts relay-admin kinds 9030-9033 from its durable -//! write-path restriction gate so a *timed out* admin keeps its administrative -//! capability. That exemption was ban-blind, so a **banned** admin could still -//! add/remove relay members and change the workspace icon via signed NIP-98 -//! `POST /events`. The ban is now enforced inside -//! `relay_admin::handle_relay_admin_event`; this test pins both halves of that -//! contract — bans refused, timeouts still admitted. +//! `ingest_event` runs the durable write-restriction gate before relay-admin +//! kinds 9030-9033. Bans and active timeouts must reject those commands, while +//! an unrestricted admin can still mutate the roster and workspace icon via +//! signed NIP-98 `POST /events`. //! -//! Requires a running relay and its Postgres. Ignored by default: +//! Requires a running relay and its Postgres. Export `DATABASE_URL` for a +//! disposable local database, then run (ignored by default): //! REPRO_RELAY_HTTP=http://localhost:3999 REPRO_HOST=localhost:3999 \ -//! DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz_relay_admin_regression \ //! cargo test -p buzz-test-client --test regression_relay_admin_ban_gate \ //! -- --ignored --nocapture @@ -123,14 +120,14 @@ async fn seed(p: &sqlx::Pool, cid: Uuid, keys: &Keys, role: &str /// Asserts the full contract rather than just "the exploit stopped": /// - banned admin: 403 + exact `blocked:` prefix on 9030/9031/9033, and a /// banned *owner* likewise on 9032 (owner-only kind), covering all four -/// exempt kinds, +/// relay-admin kinds, /// - no roster, role, or icon mutation from any of those attempts, -/// - a *timed-out* admin still reaches relay-admin authorization (the ingest -/// exemption's whole purpose — the fix must not silently widen to timeouts), +/// - a *timed-out* admin gets 403 + the `restricted:` timeout prefix on 9031, +/// and the target member remains in the roster, /// - an unrestricted admin's behaviour is unchanged, mutation included. #[tokio::test] #[ignore] -async fn banned_admin_is_refused_but_timed_out_admin_still_administers() { +async fn banned_and_timed_out_admins_are_refused_but_unrestricted_admin_can_administer() { let p = pool().await; let cid = community_id(&p).await; @@ -290,7 +287,7 @@ async fn banned_admin_is_refused_but_timed_out_admin_still_administers() { "9033: banned admin must not change the workspace icon, got {icon:?}" ); - // ── Timed-out admin: still administers (ingest exemption preserved). ── + // ── Timed-out admin: relay-admin and content writes are refused. ── let (ts, tb) = post_event( &timed_out_admin, &signed( @@ -302,13 +299,22 @@ async fn banned_admin_is_refused_but_timed_out_admin_still_administers() { .await; println!("[timed-out] 9031 remove -> {ts} {tb}"); assert_eq!( - ts, 200, - "timed-out admin must still administer the roster: {tb}" + ts, 403, + "timed-out admin must be refused relay-admin commands: {tb}" + ); + let timeout_message: serde_json::Value = serde_json::from_str(&tb).unwrap_or_default(); + let timeout_text = timeout_message + .get("error") + .and_then(|value| value.as_str()) + .unwrap_or(&tb); + assert!( + timeout_text.starts_with("restricted: you are timed out"), + "timed-out admin denial must use the timeout restriction prefix: {tb}" ); assert_eq!( - role_of(&victim2).await, - None, - "timed-out admin's removal must take effect" + role_of(&victim2).await.as_deref(), + Some("member"), + "timed-out admin must not remove a member" ); // Control: the same timed-out admin is still write-blocked for content. diff --git a/scripts/check-community-ban-route-inventory.py b/scripts/check-community-ban-route-inventory.py new file mode 100644 index 00000000000..6fbc511f1f4 --- /dev/null +++ b/scripts/check-community-ban-route-inventory.py @@ -0,0 +1,556 @@ +#!/usr/bin/env python3 +"""Keep the community-ban route inventory bound to production Axum routes.""" + +from __future__ import annotations + +import ast +import csv +import re +import sys +from collections import Counter +from functools import lru_cache +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +RELAY_SRC = ROOT / "crates/buzz-relay/src" +ROUTE_INVENTORY = ( + ROOT / "crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv" +) +REGRESSION_INVENTORY = ( + ROOT / "crates/buzz-test-client/tests/fixtures/community-ban-regressions.tsv" +) +ROUTE_TEST = ROOT / "crates/buzz-test-client/tests/community_ban_routes.rs" +HTTP_METHODS = { + "get": "GET", + "head": "HEAD", + "post": "POST", + "put": "PUT", + "delete": "DELETE", + "patch": "PATCH", + "options": "OPTIONS", + "trace": "TRACE", + "connect": "CONNECT", + "any": "ANY", +} +AUTH_PLANES = { + "dual_nip11_nip42", + "public_metadata", + "health_probe", + "community_nip98", + "community_blossom", + "community_git_nip98", + "community_nip42", + "operator_control", + "pre_membership_anonymous", + "pre_membership_nip98", + "internal_nip_fi", + "workflow_webhook_secret", + "testbed_internal", + "internal_git_hook", +} +MEMBERSHIP_CLASSES = { + "community", + "conditional", + "exempt", + "internal", + "not_applicable", +} +RESTRICTION_CLASSES = {"enforce", "conditional_enforce"} +REQUIRED_REGRESSIONS = { + "http_membership", + "root_admission", + "root_admission_race", + "root_ban_eviction", + "root_owner_eviction", + "root_fail_closed", + "root_db_error", + "audio_admission", + "audio_admin_admission", + "audio_owner_admission", + "audio_local_eviction", + "audio_admission_race", + "tenant_local_eviction", + "media_fail_closed", + "git_fail_closed", + "ingest_owner_ban", + "moderation_owner_ban", + "timeout_admin_denial", + "http_tenant_isolation", +} +RAW_STRING_PREFIX = re.compile(r"(?:b)?r(#+)?\"") +CHAR_LITERAL = re.compile(r"'(?:\\(?:u\{[0-9a-fA-F_]+\}|.)|[^'\\])'") + + +def mask_comments_and_strings(source: str) -> str: + """Replace comments and string contents with spaces, preserving offsets.""" + out = list(source) + i = 0 + while i < len(source): + if source.startswith("//", i): + end = source.find("\n", i) + end = len(source) if end < 0 else end + for j in range(i, end): + out[j] = " " + i = end + continue + if source.startswith("/*", i): + depth = 1 + j = i + 2 + while j < len(source) and depth: + if source.startswith("/*", j): + depth += 1 + j += 2 + elif source.startswith("*/", j): + depth -= 1 + j += 2 + else: + j += 1 + for k in range(i, j): + if out[k] != "\n": + out[k] = " " + i = j + continue + + raw_prefix = RAW_STRING_PREFIX.match(source, i) + if raw_prefix: + hashes = raw_prefix.group(1) or "" + end_token = '"' + hashes + start = i + body_start = raw_prefix.end() + end = source.find(end_token, body_start) + end = len(source) if end < 0 else end + len(end_token) + for j in range(start, end): + if out[j] != "\n": + out[j] = " " + i = end + continue + + if source[i] == '"' or ( + source[i] == "b" and i + 1 < len(source) and source[i + 1] == '"' + ): + start = i + quote = i if source[i] == '"' else i + 1 + j = quote + 1 + while j < len(source): + if source[j] == "\\": + j += 2 + continue + if source[j] == '"': + j += 1 + break + j += 1 + for k in range(start, j): + if out[k] != "\n": + out[k] = " " + i = j + continue + + char_literal = CHAR_LITERAL.match(source, i) + if char_literal: + end = char_literal.end() + for j in range(i, end): + if out[j] != "\n": + out[j] = " " + i = end + continue + + i += 1 + return "".join(out) + + +def matching_delimiter(mask: str, start: int) -> int: + opening = mask[start] + pairs = {"(": ")", "[": "]", "{": "}"} + closing = pairs[opening] + depth = 0 + for index in range(start, len(mask)): + if mask[index] == opening: + depth += 1 + elif mask[index] == closing: + depth -= 1 + if depth == 0: + return index + raise ValueError(f"unclosed {opening!r} at byte {start}") + + +def test_module_ranges(mask: str) -> list[tuple[int, int]]: + ranges = [] + pattern = re.compile( + r"#\s*\[\s*cfg\s*\(\s*test\s*\)\s*\]\s*" + r"(?:#\s*\[[^\]]*\]\s*)*(?:pub(?:\([^)]*\))?\s+)?" + r"mod\s+[A-Za-z_]\w*\s*\{" + ) + for match in pattern.finditer(mask): + brace = mask.find("{", match.start(), match.end()) + ranges.append((match.start(), matching_delimiter(mask, brace) + 1)) + return ranges + + +def in_ranges(position: int, ranges: list[tuple[int, int]]) -> bool: + return any(start <= position < end for start, end in ranges) + + +def router_functions(path: Path, source: str, mask: str): + pattern = re.compile(r"\bfn\s+([A-Za-z_]\w*)\s*\(") + excluded = test_module_ranges(mask) + relative = path.relative_to(RELAY_SRC).with_suffix("") + module_parts = list(relative.parts) + if module_parts[-1] == "mod": + module_parts.pop() + module = "::".join(module_parts) + + for match in pattern.finditer(mask): + name = match.group(1) + if name != "router" and not name.endswith("_router"): + continue + if in_ranges(match.start(), excluded): + continue + paren = mask.find("(", match.start(), match.end()) + close_paren = matching_delimiter(mask, paren) + brace = mask.find("{", close_paren + 1) + if brace < 0: + continue + end = matching_delimiter(mask, brace) + 1 + yield f"{module}::{name}", name, match.start(), brace, end + + +def split_first_top_level_comma(mask: str, start: int, end: int) -> int | None: + stack: list[str] = [] + pairs = {"(": ")", "[": "]", "{": "}"} + for index in range(start, end): + char = mask[index] + if char in pairs: + stack.append(pairs[char]) + elif stack and char == stack[-1]: + stack.pop() + elif char == "," and not stack: + return index + return None + + +def string_value(expression: str) -> str | None: + expression = expression.strip() + if expression.startswith('"'): + try: + value = ast.literal_eval(expression) + except (SyntaxError, ValueError): + return None + return value if isinstance(value, str) else None + raw = re.fullmatch(r'r(#+)?"(.*)"\1', expression, re.DOTALL) + return raw.group(2) if raw else None + + +def route_constants() -> dict[str, str]: + constants = {} + pattern = re.compile( + r"(?:pub(?:\([^)]*\))?\s+)?const\s+([A-Za-z_]\w*)\s*:\s*&str\s*=\s*" + r'("(?:\\.|[^"\\])*"|r(?:#+)?".*?"(?:#+)?)\s*;', + re.DOTALL, + ) + for path in RELAY_SRC.rglob("*.rs"): + if "tests" in path.parts or path.name.endswith("_tests.rs"): + continue + for match in pattern.finditer(path.read_text()): + value = string_value(match.group(2)) + if value is not None: + constants[match.group(1)] = value + return constants + + +def route_path(expression: str, constants: dict[str, str]) -> str: + literal = string_value(expression) + if literal is not None: + return literal + constant = re.search(r"([A-Za-z_]\w*)\s*$", expression.strip()) + if constant and constant.group(1) in constants: + return constants[constant.group(1)] + raise ValueError(f"route path is not a literal or known string constant: {expression.strip()}") + + +def registered_calls(mask: str, source: str, function_start: int, function_end: int, call: str): + pattern = re.compile(rf"\.\s*{re.escape(call)}\s*\(") + for match in pattern.finditer(mask, function_start, function_end): + opening = mask.find("(", match.start(), match.end()) + closing = matching_delimiter(mask, opening) + comma = split_first_top_level_comma(mask, opening + 1, closing) + if comma is None: + continue + yield ( + source[opening + 1 : comma].strip(), + mask[comma + 1 : closing], + source[comma + 1 : closing], + ) + + +def builder_prefixes(builders) -> dict[str, str]: + main = next( + (builder for builder in builders if builder[0] == "router::build_router"), + None, + ) + if main is None: + raise ValueError("could not find relay router::build_router") + _, _, path, source, mask, start, _, end = main + prefixes = {} + for first, second_mask, second_source in registered_calls(mask, source, start, end, "nest"): + prefix = route_path(first, route_constants()) + target_match = re.search(r"((?:[A-Za-z_]\w*::)*[A-Za-z_]\w*)\s*\(", second_mask) + if prefix is None or target_match is None: + continue + target = target_match.group(1) + candidates = [b[0] for b in builders if b[0] == target or b[0].endswith(f"::{target}")] + if len(candidates) == 1: + prefixes[candidates[0]] = prefix + return prefixes + + +def collect_registered_routes() -> Counter: + constants = route_constants() + builders = [] + for path in RELAY_SRC.rglob("*.rs"): + if "tests" in path.parts or path.name.endswith("_tests.rs"): + continue + source = path.read_text() + mask = mask_comments_and_strings(source) + for qualified_name, name, start, brace, end in router_functions(path, source, mask): + builders.append((qualified_name, name, path, source, mask, brace, start, end)) + + prefixes = builder_prefixes(builders) + registrations: Counter = Counter() + errors = [] + for path in RELAY_SRC.rglob("*.rs"): + if "tests" in path.parts or path.name.endswith("_tests.rs"): + continue + source = path.read_text() + mask = mask_comments_and_strings(source) + test_ranges = test_module_ranges(mask) + router_ranges = [ + (builder[6], builder[7]) for builder in builders if builder[2] == path + ] + for match in re.finditer(r"\.\s*route\s*\(", mask): + if in_ranges(match.start(), test_ranges): + continue + if not any(start <= match.start() < end for start, end in router_ranges): + line = source.count("\n", 0, match.start()) + 1 + errors.append( + f"{path.relative_to(ROOT)}:{line}: production .route registration " + "is outside a recognized router builder" + ) + + for qualified_name, name, path, source, mask, _, start, end in builders: + listener = "health" if qualified_name == "router::build_health_router" else "relay" + prefix = prefixes.get(qualified_name, "") + for path_expression, method_mask, _ in registered_calls(mask, source, start, end, "route"): + try: + route = route_path(path_expression, constants) + except ValueError as error: + errors.append(f"{path.relative_to(ROOT)}::{name}: {error}") + continue + methods = { + method.upper() + for method in re.findall( + r"\b(get|head|post|put|delete|patch|options|trace|connect|any)\s*\(", + method_mask, + ) + } + if not methods: + errors.append( + f"{path.relative_to(ROOT)}::{name}: no Axum HTTP method in route expression" + ) + continue + full_path = f"{prefix.rstrip('/')}{route}" if prefix else route + for method in methods: + registrations[(listener, method, full_path)] += 1 + + if errors: + raise ValueError("route scanner could not classify registrations:\n " + "\n ".join(errors)) + duplicates = [route for route, count in registrations.items() if count > 1] + if duplicates: + rendered = ", ".join(f"{scope} {method} {path}" for scope, method, path in duplicates) + raise ValueError(f"duplicate production route registrations: {rendered}") + return registrations + + +def read_tsv(path: Path, expected_header: list[str]) -> list[dict[str, str]]: + with path.open(newline="") as file: + reader = csv.DictReader(file, delimiter="\t") + if reader.fieldnames != expected_header: + raise ValueError(f"{path.relative_to(ROOT)} header must be {expected_header}") + rows = list(reader) + if any(None in row or any(value is None for value in row.values()) for row in rows): + raise ValueError(f"{path.relative_to(ROOT)} contains a row with the wrong number of columns") + return rows + + +@lru_cache(maxsize=None) +def source_mask(path: Path) -> str | None: + if not path.is_file(): + return None + return mask_comments_and_strings(path.read_text()) + + +def test_function_exists(path: Path, name: str) -> bool: + mask = source_mask(path) + if mask is None: + return False + return re.search(rf"\bfn\s+{re.escape(name)}\s*\(", mask) is not None + + +def check_route_inventory() -> tuple[int, int, int]: + routes = read_tsv( + ROUTE_INVENTORY, + [ + "id", + "listener", + "method", + "path", + "auth_plane", + "membership", + "restriction", + "exercise", + "control", + ], + ) + by_key: dict[tuple[str, str, str], dict[str, str]] = {} + ids = set() + matrix_cases = set() + pending = [] + for row in routes: + route_id = row["id"] + if not route_id or route_id in ids: + raise ValueError(f"duplicate or empty route id: {route_id!r}") + ids.add(route_id) + key = (row["listener"], row["method"], row["path"]) + if key in by_key: + raise ValueError(f"duplicate inventory route: {key}") + by_key[key] = row + if row["listener"] not in {"relay", "health"}: + raise ValueError(f"{route_id}: unknown listener {row['listener']!r}") + if row["auth_plane"] not in AUTH_PLANES: + raise ValueError(f"{route_id}: unknown auth plane {row['auth_plane']!r}") + if row["membership"] not in MEMBERSHIP_CLASSES: + raise ValueError(f"{route_id}: unknown membership class {row['membership']!r}") + if row["method"] not in set(HTTP_METHODS.values()): + raise ValueError(f"{route_id}: unknown HTTP method {row['method']!r}") + + restriction = row["restriction"] + exercise = row["exercise"] + if restriction in RESTRICTION_CLASSES: + if not exercise or exercise == "classified": + raise ValueError(f"{route_id}: enforced restriction needs a behavior exercise") + elif restriction.startswith("not_applicable:"): + if not restriction.partition(":")[2] or exercise != "classified": + raise ValueError(f"{route_id}: restriction exemption needs a reason and classification") + else: + raise ValueError(f"{route_id}: unrecognized restriction class {restriction!r}") + + if row["membership"] == "exempt" and restriction.startswith("not_applicable:"): + if route_id != "invite_accept_policy": + raise ValueError( + f"{route_id}: membership exemption cannot silently exempt restriction" + ) + if exercise.startswith("matrix:"): + if row["control"] not in {"member", "admin", "owner"}: + raise ValueError(f"{route_id}: route-matrix row needs an allowed control principal") + matrix_cases.update(exercise.partition(":")[2].split("+")) + elif exercise.startswith("e2e:"): + test_name = exercise.partition(":")[2] + if not test_function_exists(ROUTE_TEST, test_name): + raise ValueError(f"{route_id}: missing E2E test {test_name!r}") + elif exercise.startswith("pg:"): + tests = exercise.partition(":")[2].split("+") + for test_name in tests: + found = any( + test_function_exists(path, test_name) + for path in RELAY_SRC.rglob("*.rs") + if "tests" not in path.parts and not path.name.endswith("_tests.rs") + ) + if not found: + raise ValueError(f"{route_id}: missing PostgreSQL regression {test_name!r}") + elif exercise.startswith("pending:"): + pending.append((route_id, exercise)) + elif exercise != "classified": + raise ValueError(f"{route_id}: unrecognized exercise classification {exercise!r}") + + registrations = collect_registered_routes() + registered = set(registrations) + inventoried = set(by_key) + missing = sorted(registered - inventoried) + stale = sorted(inventoried - registered) + if missing or stale: + details = [] + if missing: + details.append("registered routes without inventory rows: " + ", ".join(map(str, missing))) + if stale: + details.append("inventory rows without registered routes: " + ", ".join(map(str, stale))) + raise ValueError("; ".join(details)) + + if set(pending) != {("invite_claim", "pending:BUZZ-268"), ("workflow_webhook", "pending:BUZZ-272")}: + raise ValueError( + "pending coverage must explicitly track BUZZ-268 invitations and BUZZ-272 webhook owner bans" + ) + claim = next((row for row in routes if row["id"] == "invite_claim"), None) + if claim is None or claim["membership"] != "exempt" or claim["restriction"] != "enforce": + raise ValueError("invite claim must remain membership-exempt and restriction-enforced") + acceptance = next((row for row in routes if row["id"] == "invite_accept_policy"), None) + if acceptance is None or acceptance["membership"] != "exempt": + raise ValueError("anonymous policy acceptance needs its separate pre-membership classification") + + route_test_mask = mask_comments_and_strings(ROUTE_TEST.read_text()) + supported = re.search( + r"const\s+COMMUNITY_BAN_MATRIX_CASES\s*:\s*&\[&str\]\s*=\s*&\[(.*?)\];", + route_test_mask, + re.DOTALL, + ) + if supported is None: + raise ValueError("route E2E test must declare COMMUNITY_BAN_MATRIX_CASES") + supported_cases = set(re.findall(r'"([a-z_]+)"', ROUTE_TEST.read_text()[supported.start() : supported.end()])) + if matrix_cases != supported_cases: + raise ValueError( + "route-matrix exercise cases differ from COMMUNITY_BAN_MATRIX_CASES: " + f"inventory-only={sorted(matrix_cases - supported_cases)}, " + f"test-only={sorted(supported_cases - matrix_cases)}" + ) + if "community_http_ban_matrix" not in route_test_mask: + raise ValueError("route inventory is not exercised by community_http_ban_matrix") + return len(routes), len(matrix_cases), len(pending) + + +def check_regression_inventory() -> int: + rows = read_tsv( + REGRESSION_INVENTORY, + ["id", "file", "test", "lane", "property"], + ) + seen = set() + for row in rows: + if not row["id"] or row["id"] in seen: + raise ValueError(f"duplicate or empty supporting regression id: {row['id']!r}") + seen.add(row["id"]) + if row["lane"] not in {"unit", "postgres-ci", "relay-e2e"}: + raise ValueError(f"{row['id']}: unknown validation lane {row['lane']!r}") + path = ROOT / row["file"] + if not test_function_exists(path, row["test"]): + raise ValueError(f"{row['id']}: missing referenced test {row['file']}::{row['test']}") + missing = REQUIRED_REGRESSIONS - seen + if missing: + raise ValueError(f"supporting regression inventory is missing {sorted(missing)}") + return len(rows) + + +def main() -> int: + try: + route_count, case_count, pending_count = check_route_inventory() + regression_count = check_regression_inventory() + except (OSError, ValueError, csv.Error) as error: + print(f"community-ban route inventory failed: {error}", file=sys.stderr) + return 1 + print( + "community-ban route inventory passed: " + f"{route_count} registered routes classified, {case_count} HTTP behavior cases, " + f"{regression_count} supporting regressions indexed, {pending_count} sibling implementation cases pending" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 4f5ed92ba36..cedd49f7bf6 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -78,6 +78,9 @@ ensure_infra() { run_unit_tests() { section "Unit Tests (no infra required)" + run_test_step "community-ban route inventory" \ + python3 scripts/check-community-ban-route-inventory.py + run_test_step "buzz-core tests" \ cargo test -p buzz-core --lib -- --nocapture @@ -339,6 +342,9 @@ run_unit_tests() { run_test_step "buzz-relay binary tests" \ cargo test -p buzz-relay --bin buzz-relay -- --nocapture + run_test_step "buzz-relay mesh receive lifetime regression" \ + cargo test -p buzz-relay --lib mesh_boot::tests::demo_echo_retains_pending_receive_across_housekeeping_ticks -- --exact --nocapture + run_test_step "buzz-relay boot lifecycle tests" \ cargo test -p buzz-relay --test boot_lifecycle -- --nocapture }