test(auth): bind route controls to exact outcomes and merged routers

Co-authored-by: Sarnow <1894dcbafaa4df1ac607a9bf879ac0b7d77fbccb0e0f4619ecc252c8adbad8a2@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
This commit is contained in:
Brad Seiler
2026-10-29 20:22:01 -04:00
parent f52d70506a
commit 18ffd15b5e
6 changed files with 194 additions and 67 deletions

View File

@@ -44,7 +44,9 @@ row remains, and on any shape it cannot classify: `.route_service`,
`.nest_service`, `.fallback_service`, or `.fallback` (except allow-listed
registrations, each pinned to its builder and exact argument and allowed once),
`.nest` outside `build_router`, a nest target that does not resolve to exactly
one router builder, or a builder nested more than once. `scripts/check-community-ban-route-inventory.test.py`
one router builder, or a builder nested more than once. New or repeated
`.merge(...)` registrations also fail; existing root aliases and the Git
settings merge are pinned to their exact builder and argument. `scripts/check-community-ban-route-inventory.test.py`
pins each of those shapes with a fixture. Run both directly with:
```bash
@@ -63,8 +65,12 @@ Use a fresh disposable local relay, PostgreSQL, Redis, and MinIO stack for that
command, and set `DATABASE_URL` or `BUZZ_TEST_DATABASE_URL` to the disposable
PostgreSQL database. Start the relay with `BUZZ_V1_ENABLED=true` so the
accessory-route cases exercise their production handlers. The matrix creates
unique tenant hosts and identities; GIF
requests stop at malformed local input, and Git/workflow requests use absent resources.
unique tenant hosts and identities. Unset `BUZZ_KLIPY_API_KEY`: GIF controls
assert the exact provider-disabled response without contacting a provider.
The malformed JPEG control asserts its validation response; Git, workflow and
media-read controls assert exact missing-resource responses. Successful reads
and writes must return their expected JSON shape, not merely avoid a ban.
Ordinary-member routes also run a clear delegated-agent control.
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,
@@ -72,26 +78,15 @@ NIP-05, policy documents, and health probes have no authenticated community
principal. Anonymous policy-receipt acceptance is pre-membership and has no
principal to restrict. Invite claim is also pre-membership, but it carries a
verified NIP-98 principal and is classified as restriction-enforced in the
inventory. Enforcement will land with BUZZ-268; coverage remains pending until
that sibling lands and qualifies the updated v1/v2 lifecycle. After BUZZ-268
lands, the intended contract is to retain valid
unexpired v1 codes and mint only v2, with a 72-hour default and 30-day maximum
lifetime. Both formats will reject banned claimants and owners transactionally,
and issuer bans will permanently invalidate v2. For v1, an unset operator
`invalid_after` will use natural expiry; when set, the code will reject at or
after that absolute boundary, while an earlier code expiry still takes effect.
That operator boundary will not affect v2. Legacy v1 codes have no issuer
identity, so issuer-specific revocation remains limited until a verified fleet
drain or security approval. This sibling contract, including the operator
cutoff, is not implemented by BUZZ-269. Relay-operator APIs, deployment-admin APIs,
inventory. The integrated invitation regressions cover restricted nonmembers,
verified owners and v1/v2 lifecycle behavior; see
[Invitation compatibility and revocation](#invitation-compatibility-and-revocation)
for the authoritative contract. Relay-operator APIs, deployment-admin APIs,
NIP-FI commands, the localhost Git hook, and the gated mesh test endpoint use
separate auth planes; their exemption reason is recorded on each route row.
Workflow webhooks use a secret-authenticated caller with no user principal.
After BUZZ-272 lands, run admission will also check the saved workflow owner's
community ban. That behavior is not implemented by BUZZ-269, and its coverage
remains explicitly pending BUZZ-272; the pending row is not an exemption for
banned owners. BUZZ-269 currently has two pending sibling cases: invite claim and
workflow-owner webhook admission; final integration requires zero pending rows.
separate auth planes; each exemption is recorded in its route row.
Secret-authenticated workflow webhooks recheck the saved workflow owner's
community ban through the workflow engine. Their PostgreSQL regression covers
owner bans, tenant scope and failed lookups. No sibling coverage remains pending.
Root and huddle admission, final-admission races, fail-closed reads, owner-to-
agent restriction, timeout behavior, and tenant-scoped eviction point to their
@@ -108,9 +103,11 @@ direct `.route(...)` calls inside production functions named `router` or ending
in `_router`; a `.route(...)` elsewhere is a scanner error. It resolves a
`.nest(...)` prefix only when the relay's `build_router` passes a literal
or a resolved string constant and a direct call to another recognized router function. It does not
expand macros, discover `.route_service(...)` or `.nest_service(...)`, resolve
dynamic path/prefix expressions, or interpret inline nested routers and
conditional/opaque router composition. It also does not derive auth or
expand macros or interpret dynamic paths, prefixes or arbitrary router
composition. Service-style calls and unsupported nesting or merging are errors,
rather than silently omitted registrations. The existing root merge aliases and
Git settings merge are explicit, single-use exceptions; their builders' direct
routes remain inventoried. 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

View File

@@ -2,8 +2,8 @@
//!
//! 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
//! fresh tenant hosts and identities. Run without BUZZ_KLIPY_API_KEY so GIF
//! controls assert the disabled-provider response; Git and workflow requests use absent
//! resources and must stop before subprocess or durable work.
//!
//! ```text
@@ -252,7 +252,7 @@ fn route_path(row: &RouteRow, case: &str, actor: &Principal) -> String {
fn request_body(case: &str, actor: &Principal) -> Vec<u8> {
match case {
"events" => EventBuilder::new(Kind::TextNote, "community-ban route matrix")
"events" => EventBuilder::new(Kind::Metadata, "{}")
.sign_with_keys(&actor.keys)
.expect("sign test message")
.as_json()
@@ -274,7 +274,9 @@ fn request_body(case: &str, actor: &Principal) -> Vec<u8> {
"gif_search" | "gif_share" | "profile_search" | "invite_mint" | "buzz_read_state" => {
b"{".to_vec()
}
"media_upload" => b"not an image file".to_vec(),
"media_upload" => vec![
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, b'J', b'F', b'I', b'F', 0,
],
"git_upload_pack" | "git_receive_pack" => Vec::new(),
"git_default_post" => serde_json::to_vec(&serde_json::json!({
"branch": "main",
@@ -444,28 +446,78 @@ fn assert_ban_denial(
}
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
let expected_status = match case {
"events"
| "moderation_command"
| "query"
| "count"
| "buzz_sidebar"
| "moderation_reports"
| "moderation_owner_ban"
| "moderation_audit"
| "moderation_restricted" => StatusCode::OK,
"profile_search" | "invite_mint" | "buzz_read_state" => StatusCode::BAD_REQUEST,
// This isolated fixture runs without a GIF provider key.
"gif_search" | "gif_share" | "workflow_runs" | "workflow_approvals" | "media_get"
| "media_head" | "git_info_refs" | "git_upload_pack" | "git_receive_pack"
| "git_default_get" | "git_default_post" => StatusCode::NOT_FOUND,
"media_upload" => StatusCode::UNPROCESSABLE_ENTITY,
_ => panic!("no positive-control contract for {case}"),
};
assert!(
!body.contains(denial_message),
"{} {} case {case}: allowed control must not receive a ban denial; body: {body}",
route.method,
route.path
assert_eq!(
response_status, expected_status,
"{} {} case {case}: positive control reached an unexpected result: {body}",
route.method, route.path
);
if case == "media_head" {
assert!(body.is_empty(), "HEAD must suppress the missing-blob body");
return;
}
if matches!(
case,
"git_info_refs" | "git_upload_pack" | "git_receive_pack"
) {
assert_eq!(body, "repository not found");
return;
}
let value: serde_json::Value = serde_json::from_str(body)
.unwrap_or_else(|error| panic!("case {case}: expected JSON, got {body}: {error}"));
match case {
"events" | "moderation_command" => {
assert_eq!(value["accepted"], true, "case {case}: {body}");
assert!(value["event_id"].as_str().is_some_and(|id| id.len() == 64));
}
"query"
| "moderation_reports"
| "moderation_owner_ban"
| "moderation_audit"
| "moderation_restricted" => assert!(value.is_array(), "case {case}: {body}"),
"count" => assert!(value["count"].as_u64().is_some(), "count response: {body}"),
"buzz_sidebar" => {
assert!(value["channels"].is_array(), "sidebar response: {body}");
assert!(value.get("next_cursor").is_some());
}
"buzz_read_state" => {
assert_eq!(value["error"]["code"], "invalid_request");
assert!(value["error"]["request_id"].as_str().is_some());
}
_ => {
let expected_error = match case {
"profile_search" => "invalid profile search JSON",
"invite_mint" => {
"invalid invite JSON: EOF while parsing an object at line 1 column 1"
}
"gif_search" | "gif_share" => "GIF search is not configured",
"workflow_runs" | "workflow_approvals" => "workflow not found",
"media_get" => "not found",
"media_upload" => "invalid image data",
"git_default_get" | "git_default_post" => "repository not found",
_ => unreachable!(),
};
assert_eq!(value["error"], expected_error, "case {case}: {body}");
}
}
}
fn principal<'a>(name: &str, principals: &[&'a Principal]) -> &'a Principal {
@@ -538,6 +590,19 @@ async fn community_http_ban_matrix() {
keys: Keys::generate(),
nip_oa_tag: None,
};
let clear_member_agent_keys = Keys::generate();
let clear_member_agent = Principal {
name: "clear-member-agent",
nip_oa_tag: Some(
buzz_sdk::nip_oa::compute_auth_tag(
&clear_member.keys,
&clear_member_agent_keys.public_key(),
"",
)
.expect("sign clear ordinary-owner agent proof"),
),
keys: clear_member_agent_keys,
};
let clear_admin = Principal {
name: "admin",
keys: Keys::generate(),
@@ -574,6 +639,7 @@ async fn community_http_ban_matrix() {
let banned = [&banned_member, &banned_admin, &banned_agent];
let controls = [
&clear_member,
&clear_member_agent,
&clear_admin,
&clear_owner,
&clear_moderator_agent,
@@ -600,11 +666,18 @@ async fn community_http_ban_matrix() {
"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);
let control_names = if control_name == "member" {
vec!["member", "clear-member-agent"]
} else {
vec![control_name]
};
for name in control_names {
let control = principal(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);
}
}
}
}

View File

@@ -18,3 +18,7 @@ ingest_owner_ban crates/buzz-relay/src/handlers/ingest.rs owner_ban_blocks_every
moderation_owner_ban crates/buzz-relay/src/api/bridge.rs moderation_read_honors_fresh_and_stored_banned_agent_owners postgres-ci moderation_owner_ban_auth_time_and_stored_link
timeout_admin_denial crates/buzz-test-client/tests/regression_relay_admin_ban_gate.rs banned_and_timed_out_admins_are_refused_but_unrestricted_admin_can_administer relay-e2e timed_out_admin_relay_admin_denial
http_tenant_isolation crates/buzz-test-client/tests/community_ban_routes.rs ban_is_scoped_to_host_resolved_community relay-e2e http_ban_tenant_isolation
invite_restricted_claim crates/buzz-relay/src/api/invites/postgres_tests/restriction_tests.rs signed_nonmember_claims_reject_restrictions_without_consuming_invite postgres-ci restricted_nonmember_claim_never_consumes_invite
workflow_owner_ban crates/buzz-relay/src/api/bridge/workflow_webhook_postgres_tests.rs webhook_admission_enforces_owner_ban_tenant_scope_and_lookup_failure postgres-ci webhook_owner_ban_tenant_scope_and_fail_closed_lookup
durable_live_revalidation crates/buzz-relay/src/state.rs missed_member_owner_ban_and_first_owner_link_revoke_root_audio_sessions postgres-ci missed_fanout_member_and_owner_revocation
token_owner_revalidation crates/buzz-relay/src/state.rs token_owner_admission_survives_scan_with_nip_oa_disabled_until_owner_removal postgres-ci nip_fi_owner_admission_matches_live_scan
1 id file test lane property
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_admin_denial crates/buzz-test-client/tests/regression_relay_admin_ban_gate.rs banned_and_timed_out_admins_are_refused_but_unrestricted_admin_can_administer relay-e2e timed_out_admin_relay_admin_denial
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
21 invite_restricted_claim crates/buzz-relay/src/api/invites/postgres_tests/restriction_tests.rs signed_nonmember_claims_reject_restrictions_without_consuming_invite postgres-ci restricted_nonmember_claim_never_consumes_invite
22 workflow_owner_ban crates/buzz-relay/src/api/bridge/workflow_webhook_postgres_tests.rs webhook_admission_enforces_owner_ban_tenant_scope_and_lookup_failure postgres-ci webhook_owner_ban_tenant_scope_and_fail_closed_lookup
23 durable_live_revalidation crates/buzz-relay/src/state.rs missed_member_owner_ban_and_first_owner_link_revoke_root_audio_sessions postgres-ci missed_fanout_member_and_owner_revocation
24 token_owner_revalidation crates/buzz-relay/src/state.rs token_owner_admission_survives_scan_with_nip_oa_disabled_until_owner_removal postgres-ci nip_fi_owner_admission_matches_live_scan

View File

@@ -29,12 +29,12 @@ join_policy relay GET /api/join-policy public_metadata not_applicable not_applic
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
invite_claim relay POST /api/invites/claim pre_membership_nip98 exempt enforce pg:signed_nonmember_claims_reject_restrictions_without_consuming_invite -
nip_fi_disconnect relay POST /api/nip-fi/disconnect internal_nip_fi not_applicable not_applicable:internal_auth_plane classified -
moderation_reports relay GET /moderation/reports community_nip98 community enforce matrix:moderation_reports+moderation_owner_ban admin
moderation_audit relay GET /moderation/audit community_nip98 community enforce matrix:moderation_audit+moderation_owner_ban admin
moderation_restricted relay GET /moderation/restricted community_nip98 community enforce matrix:moderation_restricted+moderation_owner_ban admin
workflow_webhook relay POST /hooks/{id} workflow_webhook_secret not_applicable enforce pending:BUZZ-272 BUZZ-272
workflow_webhook relay POST /hooks/{id} workflow_webhook_secret not_applicable enforce pg:webhook_admission_enforces_owner_ban_tenant_scope_and_lookup_failure -
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
1 id listener method path auth_plane membership restriction exercise control
29 join_policy_terms relay GET /api/join-policy/terms public_metadata not_applicable not_applicable:public_metadata classified -
30 join_policy_privacy relay GET /api/join-policy/privacy public_metadata not_applicable not_applicable:public_metadata classified -
31 invite_accept_policy relay POST /api/invites/accept-policy pre_membership_anonymous exempt not_applicable:no_authenticated_principal classified -
32 invite_claim relay POST /api/invites/claim pre_membership_nip98 exempt enforce pending:BUZZ-268 pg:signed_nonmember_claims_reject_restrictions_without_consuming_invite BUZZ-268 -
33 nip_fi_disconnect relay POST /api/nip-fi/disconnect internal_nip_fi not_applicable not_applicable:internal_auth_plane classified -
34 moderation_reports relay GET /moderation/reports community_nip98 community enforce matrix:moderation_reports+moderation_owner_ban admin
35 moderation_audit relay GET /moderation/audit community_nip98 community enforce matrix:moderation_audit+moderation_owner_ban admin
36 moderation_restricted relay GET /moderation/restricted community_nip98 community enforce matrix:moderation_restricted+moderation_owner_ban admin
37 workflow_webhook relay POST /hooks/{id} workflow_webhook_secret not_applicable enforce pending:BUZZ-272 pg:webhook_admission_enforces_owner_ban_tenant_scope_and_lookup_failure BUZZ-272 -
38 mesh_demo_echo relay POST /_mesh/demo/echo testbed_internal not_applicable not_applicable:testbed_only classified -
39 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 -
40 media_upload relay PUT /upload community_blossom community enforce matrix:media_upload member

View File

@@ -348,10 +348,17 @@ def builder_prefixes(builders) -> dict[str, str]:
return prefixes
# Service and fallback registrations bypass the per-route method
# classification, so each one is a hard error unless this list names its
# builder, call, and exact (whitespace-normalized) argument, once.
# Service, fallback, and merge registrations bypass per-route classification.
# Pin the existing composition seams by builder and exact normalized argument;
# reject new or repeated compositions until their routes can be classified.
ALLOWED_UNROUTED_REGISTRATIONS = {
# These root aliases are constructed in build_router at the root prefix.
("router::build_router", "merge", "media_router"),
("router::build_router", "merge", "git_router"),
("router::build_router", "merge", "git_policy_router"),
("router::build_router", "merge", "optional"),
# Git settings already carry their complete /git paths; no nest prefix.
("api::git::transport::git_router", "merge", "super::settings::router()"),
# SPA static bundles; it only serves files, never a community API.
("router::build_router", "fallback_service", "spa_fallback"),
# Unknown accessory paths answer 404 instead of SPA HTML.
@@ -381,6 +388,11 @@ def collect_registered_routes() -> Counter:
mask = mask_comments_and_strings(source)
test_ranges = test_module_ranges(mask)
path_builders = [builder for builder in builders if builder[2] == path]
# Root aliases have root paths only when merged outside a nest target.
nest_arguments = []
for nest in re.finditer(r"\.\s*nest\s*\(", mask):
opening = mask.find("(", nest.start(), nest.end())
nest_arguments.append((opening, matching_delimiter(mask, opening)))
def enclosing_builder(position: int) -> str | None:
for builder in path_builders:
@@ -389,7 +401,7 @@ def collect_registered_routes() -> Counter:
return None
for match in re.finditer(
r"\.\s*(route|nest|route_service|nest_service|fallback_service|fallback)\s*\(",
r"\.\s*(route|nest|route_service|nest_service|fallback_service|fallback|merge)\s*\(",
mask,
):
if in_ranges(match.start(), test_ranges):
@@ -398,13 +410,22 @@ def collect_registered_routes() -> Counter:
builder = enclosing_builder(match.start())
line = source.count("\n", 0, match.start()) + 1
location = f"{path.relative_to(ROOT)}:{line}"
if call.endswith("_service") or call == "fallback":
if call.endswith("_service") or call in {"fallback", "merge"}:
opening = mask.find("(", match.start(), match.end())
argument = " ".join(
source[opening + 1 : matching_delimiter(mask, opening)].split()
)
registration = (builder, call, argument)
if registration not in ALLOWED_UNROUTED_REGISTRATIONS:
if (
call == "merge"
and builder == "router::build_router"
and in_ranges(match.start(), nest_arguments)
):
errors.append(
f"{location}: root .merge registration inside a .nest target "
"is not classified"
)
elif registration not in ALLOWED_UNROUTED_REGISTRATIONS:
errors.append(
f"{location}: production .{call} registration is not classified; "
"use .route with an HTTP method or allow-list it"
@@ -551,7 +572,6 @@ def check_route_inventory() -> tuple[int, int, int]:
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}")
@@ -573,10 +593,8 @@ def check_route_inventory() -> tuple[int, int, int]:
details.append("inventory rows without registered routes: " + ", ".join(map(str, stale)))
raise ValueError("; ".join(details))
if set(pending) != {("invite_claim", "pending:BUZZ-268"), ("workflow_webhook", "pending:BUZZ-272")}:
raise ValueError(
"pending coverage must explicitly track BUZZ-268 invitations and BUZZ-272 webhook owner bans"
)
if pending:
raise ValueError(f"integrated ban coverage cannot retain pending sibling cases: {pending}")
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")

View File

@@ -68,6 +68,41 @@ class RouteScannerFixtures(unittest.TestCase):
with self.assertRaisesRegex(ValueError, message):
self.scan(router, admin)
def test_nested_builder_merge_is_rejected(self):
self.assert_rejected(
r"\.merge registration is not classified",
build_router(),
ADMIN.replace('get(probe))', 'get(probe)).merge(crate::api::profiles::router(state))'),
)
def test_unclassified_root_merge_is_rejected(self):
self.assert_rejected(
r"\.merge registration is not classified",
build_router(".merge(api::admin::router(state.clone()))"),
)
def test_existing_root_merge_alias_is_allowed_once(self):
self.scan(build_router(".merge(media_router)"))
self.assert_rejected(
r"allow-listed \.merge registration appears more than once",
build_router(".merge(media_router).merge(media_router)"),
)
def test_root_merge_alias_moved_inside_nest_is_rejected(self):
self.assert_rejected(
r"root \.merge registration inside a \.nest target is not classified",
build_router().replace(
'api::admin::router(state.clone()))',
'api::admin::router(state.clone()).merge(git_policy_router))',
),
)
def test_merge_outside_router_builder_is_rejected(self):
self.assert_rejected(
r"\.merge registration is not classified",
build_router() + "\nfn hidden() { opaque.merge(private_router); }",
)
def test_same_named_constants_resolve_per_module(self):
routes = self.scan(build_router())
self.assertEqual(