test(relay): map community ban routes and regressions

Signed-off-by: seiler <seiler@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
(cherry picked from commit dcb83eaec669ed044ab2f16c8b7612bd54ed1d9c)
Signed-off-by: Brad Seiler <seiler@squareup.com>
This commit is contained in:
seiler
2026-10-05 21:15:07 +00:00
committed by Brad Seiler
parent c0c22bbf80
commit e77875a065
10 changed files with 1733 additions and 4 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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<String> =
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.

View File

@@ -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<String>,
}
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<Postgres> {
sqlx::postgres::PgPoolOptions::new()
.max_connections(2)
.connect(&database_url())
.await
.expect("connect to disposable relay-test PostgreSQL")
}
async fn ensure_community(pool: &Pool<Postgres>, 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<Postgres>, 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<Postgres>, 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<RouteRow> {
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<u8> {
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<String> = 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");
}

View File

@@ -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
1 id file test lane property
2 http_membership crates/buzz-relay/src/handlers/ingest.rs relay_membership_step_refuses_banned_member_on_http postgres-ci ban_and_unbanned_http_gate
3 root_admission crates/buzz-relay/src/handlers/auth.rs fi_ban_denial_emits_terminal_authorization_denied postgres-ci root_nip42_ban_denial
4 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
5 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
6 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
7 root_fail_closed crates/buzz-relay/src/handlers/auth.rs fi_ban_check_error_emits_terminal_authorization_unavailable unit root_restriction_lookup_unavailable
8 root_db_error crates/buzz-relay/src/handlers/auth.rs handler_accounts_ban_check_database_error unit root_restriction_lookup_unavailable_off_mode
9 audio_admission crates/buzz-relay/src/audio/handler.rs audio_join_rejects_community_banned_member postgres-ci huddle_banned_member_admission
10 audio_admin_admission crates/buzz-relay/src/audio/handler.rs audio_join_rejects_banned_admin postgres-ci huddle_banned_admin_admission_and_unbanned_control
11 audio_owner_admission crates/buzz-relay/src/audio/handler.rs audio_join_rejects_banned_owner_agent postgres-ci huddle_agent_banned_through_owner
12 audio_local_eviction crates/buzz-relay/src/audio/handler.rs ban_disconnect_closes_live_audio_socket postgres-ci pod_local_huddle_socket_eviction
13 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
14 tenant_local_eviction crates/buzz-relay/src/state.rs disconnect_pubkey_is_fenced_to_the_banning_community unit pod_local_tenant_fence
15 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
16 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
17 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
18 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
19 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
20 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

View File

@@ -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 -
1 id listener method path auth_plane membership restriction exercise control
2 relay_root_ws relay GET / dual_nip11_nip42 conditional conditional_enforce e2e:banned_root_websocket_is_refused unbanned
3 relay_info relay GET /info public_metadata not_applicable not_applicable:public_metadata classified -
4 nip05 relay GET /.well-known/nostr.json public_metadata not_applicable not_applicable:public_metadata classified -
5 relay_health relay GET /health health_probe not_applicable not_applicable:health_probe classified -
6 relay_liveness relay GET /_liveness health_probe not_applicable not_applicable:health_probe classified -
7 relay_readiness relay GET /_readiness health_probe not_applicable not_applicable:health_probe classified -
8 http_events relay POST /events community_nip98 community enforce matrix:events+moderation_command member
9 http_query relay POST /query community_nip98 community enforce matrix:query member
10 http_count relay POST /count community_nip98 community enforce matrix:count member
11 gif_search relay POST /gifs/search community_nip98 community enforce matrix:gif_search member
12 gif_share relay POST /gifs/share community_nip98 community enforce matrix:gif_share member
13 workflow_runs relay GET /workflows/{workflow_id}/runs community_nip98 community enforce matrix:workflow_runs member
14 workflow_approvals relay GET /workflows/{workflow_id}/runs/{run_id}/approvals community_nip98 community enforce matrix:workflow_approvals member
15 operator_list_communities relay GET /operator/communities operator_control not_applicable not_applicable:relay_operator_control classified -
16 operator_provision_community relay POST /operator/communities operator_control not_applicable not_applicable:relay_operator_control classified -
17 operator_register_listener_keys relay POST /operator/listener/pubkeys operator_control not_applicable not_applicable:relay_operator_control classified -
18 operator_remove_listener_keys relay DELETE /operator/listener/pubkeys operator_control not_applicable not_applicable:relay_operator_control classified -
19 operator_archive_community relay POST /operator/communities/archive operator_control not_applicable not_applicable:relay_operator_control classified -
20 operator_unarchive_community relay POST /operator/communities/unarchive operator_control not_applicable not_applicable:relay_operator_control classified -
21 operator_delete_community relay POST /operator/communities/delete operator_control not_applicable not_applicable:relay_operator_control classified -
22 operator_community_availability relay GET /operator/communities/availability operator_control not_applicable not_applicable:relay_operator_control classified -
23 operator_transfer_community relay POST /operator/communities/transfer operator_control not_applicable not_applicable:relay_operator_control classified -
24 invite_mint relay POST /api/invites community_nip98 community enforce matrix:invite_mint admin
25 join_policy relay GET /api/join-policy public_metadata not_applicable not_applicable:public_metadata classified -
26 join_policy_terms relay GET /api/join-policy/terms public_metadata not_applicable not_applicable:public_metadata classified -
27 join_policy_privacy relay GET /api/join-policy/privacy public_metadata not_applicable not_applicable:public_metadata classified -
28 invite_accept_policy relay POST /api/invites/accept-policy pre_membership_anonymous exempt not_applicable:no_authenticated_principal classified -
29 invite_claim relay POST /api/invites/claim pre_membership_nip98 exempt enforce pending:BUZZ-268 BUZZ-268
30 nip_fi_disconnect relay POST /api/nip-fi/disconnect internal_nip_fi not_applicable not_applicable:internal_auth_plane classified -
31 moderation_reports relay GET /moderation/reports community_nip98 community enforce matrix:moderation_reports+moderation_owner_ban admin
32 moderation_audit relay GET /moderation/audit community_nip98 community enforce matrix:moderation_audit+moderation_owner_ban admin
33 moderation_restricted relay GET /moderation/restricted community_nip98 community enforce matrix:moderation_restricted+moderation_owner_ban admin
34 workflow_webhook relay POST /hooks/{id} workflow_webhook_secret not_applicable not_applicable:webhook_secret_auth classified -
35 mesh_demo_echo relay POST /_mesh/demo/echo testbed_internal not_applicable not_applicable:testbed_only classified -
36 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 -
37 media_upload relay PUT /upload community_blossom community enforce matrix:media_upload member
38 media_upload_alias relay PUT /media/upload community_blossom community enforce matrix:media_upload member
39 media_read_get relay GET /media/{sha256_ext} community_blossom community enforce matrix:media_get member
40 media_read_head relay HEAD /media/{sha256_ext} community_blossom community enforce matrix:media_head member
41 git_info_refs relay GET /git/{owner}/{repo}/info/refs community_git_nip98 community enforce matrix:git_info_refs admin
42 git_upload_pack relay POST /git/{owner}/{repo}/git-upload-pack community_git_nip98 community enforce matrix:git_upload_pack admin
43 git_receive_pack relay POST /git/{owner}/{repo}/git-receive-pack community_git_nip98 community enforce matrix:git_receive_pack admin
44 git_default_branch_get relay GET /git/{owner}/{repo}/default-branch community_git_nip98 community enforce matrix:git_default_get admin
45 git_default_branch_post relay POST /git/{owner}/{repo}/default-branch community_git_nip98 community enforce matrix:git_default_post admin
46 git_policy_hook relay POST /internal/git/policy internal_git_hook internal not_applicable:internal_auth_plane classified -
47 admin_probe relay GET /api/admin/v1/probe operator_control not_applicable not_applicable:relay_admin_control classified -
48 admin_reports_list relay GET /api/admin/v1/reports operator_control not_applicable not_applicable:relay_admin_control classified -
49 admin_report_detail relay GET /api/admin/v1/reports/{id} operator_control not_applicable not_applicable:relay_admin_control classified -
50 admin_report_resolve relay POST /api/admin/v1/reports/{id}/resolve operator_control not_applicable not_applicable:relay_admin_control classified -
51 admin_report_reopen relay POST /api/admin/v1/reports/{id}/reopen operator_control not_applicable not_applicable:relay_admin_control classified -
52 admin_report_cancel relay POST /api/admin/v1/reports/{id}/cancel operator_control not_applicable not_applicable:relay_admin_control classified -
53 admin_feedback_list relay GET /api/admin/v1/feedback operator_control not_applicable not_applicable:relay_admin_control classified -
54 admin_feedback_detail_get relay GET /api/admin/v1/feedback/{id} operator_control not_applicable not_applicable:relay_admin_control classified -
55 admin_feedback_detail_patch relay PATCH /api/admin/v1/feedback/{id} operator_control not_applicable not_applicable:relay_admin_control classified -
56 admin_feedback_attachment relay GET /api/admin/v1/feedback/{id}/attachments/{sha256} operator_control not_applicable not_applicable:relay_admin_control classified -
57 admin_operators_list relay GET /api/admin/v1/operators operator_control not_applicable not_applicable:relay_admin_control classified -
58 admin_operator_upsert relay PUT /api/admin/v1/operators/{pubkey} operator_control not_applicable not_applicable:relay_admin_control classified -
59 admin_operator_delete relay DELETE /api/admin/v1/operators/{pubkey} operator_control not_applicable not_applicable:relay_admin_control classified -
60 admin_restrictions_list relay GET /api/admin/v1/members/restrictions operator_control not_applicable not_applicable:relay_admin_control classified -
61 admin_member_unban relay DELETE /api/admin/v1/members/{pubkey}/ban operator_control not_applicable not_applicable:relay_admin_control classified -
62 admin_member_untimeout relay DELETE /api/admin/v1/members/{pubkey}/timeout operator_control not_applicable not_applicable:relay_admin_control classified -
63 admin_member_ban relay POST /api/admin/v1/members/{pubkey}/ban operator_control not_applicable not_applicable:relay_admin_control classified -
64 admin_member_timeout relay POST /api/admin/v1/members/{pubkey}/timeout operator_control not_applicable not_applicable:relay_admin_control classified -
65 admin_delete_event relay POST /api/admin/v1/events/{id}/delete operator_control not_applicable not_applicable:relay_admin_control classified -
66 health_liveness health GET /_liveness health_probe not_applicable not_applicable:health_probe classified -
67 health_readiness health GET /_readiness health_probe not_applicable not_applicable:health_probe classified -
68 health_status health GET /_status health_probe not_applicable not_applicable:health_probe classified -
69 health_mesh health GET /_mesh health_probe not_applicable not_applicable:health_probe classified -

View File

@@ -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())

View File

@@ -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