mirror of
https://github.com/block/buzz.git
synced 2026-11-01 06:51:17 +00:00
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:
6
.github/workflows/_ci-relay.yml
vendored
6
.github/workflows/_ci-relay.yml
vendored
@@ -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
|
||||
|
||||
1
Justfile
1
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
|
||||
|
||||
78
TESTING.md
78
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
693
crates/buzz-test-client/tests/community_ban_routes.rs
Normal file
693
crates/buzz-test-client/tests/community_ban_routes.rs
Normal 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");
|
||||
}
|
||||
20
crates/buzz-test-client/tests/fixtures/community-ban-regressions.tsv
vendored
Normal file
20
crates/buzz-test-client/tests/fixtures/community-ban-regressions.tsv
vendored
Normal 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
|
||||
|
69
crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv
vendored
Normal file
69
crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv
vendored
Normal 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 -
|
||||
|
556
scripts/check-community-ban-route-inventory.py
Normal file
556
scripts/check-community-ban-route-inventory.py
Normal 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())
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user