From e77875a065b194b4068ddc090cfa6fcc032268fa Mon Sep 17 00:00:00 2001 From: seiler Date: Mon, 5 Oct 2026 21:15:07 +0000 Subject: [PATCH] test(relay): map community ban routes and regressions Signed-off-by: seiler Co-authored-by: Codex (cherry picked from commit dcb83eaec669ed044ab2f16c8b7612bd54ed1d9c) Signed-off-by: Brad Seiler --- .github/workflows/_ci-relay.yml | 6 + Justfile | 1 + TESTING.md | 78 ++ crates/buzz-relay/src/api/bridge.rs | 183 ++++- crates/buzz-relay/src/audio/handler.rs | 128 ++++ .../tests/community_ban_routes.rs | 693 ++++++++++++++++++ .../fixtures/community-ban-regressions.tsv | 20 + .../community-ban-route-inventory.tsv | 69 ++ .../check-community-ban-route-inventory.py | 556 ++++++++++++++ scripts/run-tests.sh | 3 + 10 files changed, 1733 insertions(+), 4 deletions(-) create mode 100644 crates/buzz-test-client/tests/community_ban_routes.rs create mode 100644 crates/buzz-test-client/tests/fixtures/community-ban-regressions.tsv create mode 100644 crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv create mode 100644 scripts/check-community-ban-route-inventory.py diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index e77df7312c..783f03fa7b 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -624,6 +624,8 @@ jobs: - 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 @@ -636,6 +638,10 @@ jobs: cargo test -p buzz-test-client --test e2e_relay nip29_relay_rejects_role_change_by_unprivileged_actor -- --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 37cac13a78..d0143768f0 100644 --- a/Justfile +++ b/Justfile @@ -399,6 +399,7 @@ test-unit: ./scripts/test-ensure-local-relay-key.sh # Shared Huddle transport, codec, and loopback authentication regressions. cargo test -p buzz-audio-client + 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 # Mesh JWKS proxy-bypass regression: infra-free loopback I/O with diff --git a/TESTING.md b/TESTING.md index fa3a9a3a82..4bf29e2c82 100644 --- a/TESTING.md +++ b/TESTING.md @@ -31,6 +31,84 @@ 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. 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 remains restriction-enforced: it is the sole +row marked pending in the inventory until BUZZ-268 qualifies the updated +v1/v2 lifecycle: +retain valid unexpired v1 codes, mint only v2, reject banned claimants and +owners transactionally, permanently invalidate v2 on issuer ban, and honor the +optional absolute invalid-after boundary for v1. Legacy v1 codes have no issuer +identity, so issuer-specific revocation remains limited until a verified fleet +drain or security approval. 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 also use a secret-authenticated caller with no user principal. +Before creating a run, the handler separately checks the saved workflow owner's +channel membership and role, but it does not check that owner's community-ban +state. The inventory's `not_applicable:webhook_secret_auth` entry is a +provisional caller-auth classification, not proof that an owner ban is +irrelevant. Whether the owner's ban should gate webhook trigger and run +creation remains an explicit policy question. + +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 +prefix 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 diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 10c88e3697..0da0c60721 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -2971,13 +2971,13 @@ 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()) }) .await?; let pubkey = *admission.proven_pubkey(); let token_owner = admission.token_owner(); - 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 @@ -2990,7 +2990,7 @@ async fn authorize_moderation_read( &tenant, &pubkey_bytes, super::relay_members::extract_auth_tag_header(headers), - None, + signed_created_at, token_owner, ) .await @@ -3191,7 +3191,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 { @@ -5603,6 +5603,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}; @@ -5614,6 +5624,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"); @@ -7596,6 +7607,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/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index e4ce733ffd..a1d11cdeea 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -15422,6 +15422,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-test-client/tests/community_ban_routes.rs b/crates/buzz-test-client/tests/community_ban_routes.rs new file mode 100644 index 0000000000..3708261a79 --- /dev/null +++ b/crates/buzz-test-client/tests/community_ban_routes.rs @@ -0,0 +1,693 @@ +//! 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", + "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" => 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.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 0000000000..e2a7abee16 --- /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_control crates/buzz-test-client/tests/regression_relay_admin_ban_gate.rs banned_admin_is_refused_but_timed_out_admin_still_administers relay-e2e timeout_is_not_ban +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 0000000000..0738a94296 --- /dev/null +++ b/crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv @@ -0,0 +1,69 @@ +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 not_applicable:webhook_secret_auth classified - +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 - diff --git a/scripts/check-community-ban-route-inventory.py b/scripts/check-community-ban-route-inventory.py new file mode 100644 index 0000000000..0caf26d4c0 --- /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_control", + "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 = string_value(first) + 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 len(pending) != 1 or pending[0] != ("invite_claim", "pending:BUZZ-268"): + raise ValueError( + "the only pending route coverage must be invite_claim pending:BUZZ-268" + ) + 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} BUZZ-268 invite case pending" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index f513a5ad31..24cb2205c8 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