diff --git a/Justfile b/Justfile
index 1683153b56..495be73b77 100644
--- a/Justfile
+++ b/Justfile
@@ -475,7 +475,7 @@ test-unit:
# because they live in the binary target; the nested
# `tests::postgres_tests::` stays in the PostgreSQL lane.
cargo nextest run -p buzz-relay --lib --bin buzz-relay \
- -E 'test(/^api::admin::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))'
+ -E 'test(/^api::admin::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))'
# Note on audio::join::tests scope: the full suite is infra-free (no DB,
# no Redis). The infra-free audio/FI regression witnesses — bootstrap
# ordering barrier, CommitConfirmed arm, pending-close invisibility,
diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs
index f745405143..996b3aa460 100644
--- a/crates/buzz-relay/src/api/git/transport.rs
+++ b/crates/buzz-relay/src/api/git/transport.rs
@@ -4628,7 +4628,7 @@ mod off_mode_precedence_tests {
// hitting the absent-verifier 503. Without this, Case 3 sends
// `Bearer !!!not-valid-base64!!!` which passes `extract_bearer_token`
// (scheme/cardinality checks only) and then trips the absent-verifier
- // check at nip_fi_http.rs:332-335 → 503, not 403.
+ // check in `nip_fi_core::evaluate_attached_assertion` → 503, not 403.
// [FI-TRACE-DENIAL-ORACLE: verifier required for all crypto cases]
let state = {
use buzz_auth::{
diff --git a/crates/buzz-relay/src/api/nip_fi.rs b/crates/buzz-relay/src/api/nip_fi.rs
index bc3ad2eb5a..95bbac5245 100644
--- a/crates/buzz-relay/src/api/nip_fi.rs
+++ b/crates/buzz-relay/src/api/nip_fi.rs
@@ -42,15 +42,17 @@ use axum::{
body::Body,
extract::State,
http::{HeaderMap, Response, StatusCode},
+ response::IntoResponse,
};
use serde::Deserialize;
use tracing::{debug, warn};
use buzz_auth::{
CommandError, CommandIssuerPolicy, CommandVerifier, IssuerCapacity, JwksFetcher, NipFiDenyMap,
- NipFiMode, ProductionJwksSource, CLIENT_ATTACHED_HEADER,
+ NipFiMode, ProductionJwksSource,
};
+use crate::nip_fi_core::{extract_bearer_token, http_denial};
use crate::state::AppState;
/// Default per-issuer deny-set capacity when `deny_set_capacity` is absent.
@@ -89,28 +91,23 @@ pub async fn disconnect(
body: axum::body::Bytes,
) -> Response
{
// ── Extract the command JWT from the header ────────────────────────────
- let token = match extract_command_jwt(&headers) {
+ // Absent → 401 with `WWW-Authenticate: Nostr`; any other malformation
+ // → 403. [NIP-FI.md §Rejection table]
+ let token = match extract_bearer_token(&headers) {
Ok(t) => t,
- Err(status) => {
- return if status == StatusCode::UNAUTHORIZED {
- // [NIP-FI.md §Rejection table]: 401 MUST carry WWW-Authenticate: Nostr.
- auth_required_response()
- } else {
- plain_response(status, "evidence rejected\n")
- };
- }
+ Err(class) => return http_denial(class),
};
// ── Parse the JSON body ───────────────────────────────────────────────
let req: DisconnectRequest = match serde_json::from_slice(&body) {
Ok(r) => r,
- Err(_) => return plain_response(StatusCode::BAD_REQUEST, "bad request\n"),
+ Err(_) => return command_denial(CommandError::MalformedRequest),
};
// body.pubkey must be lowercase hex of exactly 32 bytes.
let body_pubkey = match parse_hex_pubkey(&req.pubkey) {
Some(k) => k,
- None => return plain_response(StatusCode::BAD_REQUEST, "bad request\n"),
+ None => return command_denial(CommandError::MalformedRequest),
};
// ── Command verifier ──────────────────────────────────────────────────
@@ -119,10 +116,7 @@ pub async fn disconnect(
None => {
// Mode is Off or not yet initialized.
debug!("nip-fi disconnect: no command verifier configured");
- return plain_response(
- StatusCode::SERVICE_UNAVAILABLE,
- "authorization unavailable\n",
- );
+ return command_denial(CommandError::AuthorizationUnavailable);
}
};
@@ -178,23 +172,12 @@ pub async fn disconnect(
disconnected_response()
}
- Err(CommandError::DenySetFull) => {
- warn!("nip-fi disconnect: deny set full — command rejected, no sessions closed");
- metrics::counter!("buzz_nip_fi_disconnect_capacity_rejections_total").increment(1);
- plain_response(StatusCode::SERVICE_UNAVAILABLE, "deny set full\n")
- }
- Err(CommandError::UntilExceedsCeiling) | Err(CommandError::MalformedRequest) => {
- plain_response(StatusCode::BAD_REQUEST, "bad request\n")
- }
- Err(CommandError::AuthorizationUnavailable) => plain_response(
- StatusCode::SERVICE_UNAVAILABLE,
- "authorization unavailable\n",
- ),
- Err(CommandError::EvidenceRejected) => {
- plain_response(StatusCode::FORBIDDEN, "evidence rejected\n")
- }
- Err(CommandError::AuthorizationDenied) => {
- plain_response(StatusCode::FORBIDDEN, "authorization denied\n")
+ Err(err) => {
+ if err == CommandError::DenySetFull {
+ warn!("nip-fi disconnect: deny set full — command rejected, no sessions closed");
+ metrics::counter!("buzz_nip_fi_disconnect_capacity_rejections_total").increment(1);
+ }
+ command_denial(err)
}
}
}
@@ -544,29 +527,6 @@ pub fn validate_command_issuer_config(
// ── Helpers ───────────────────────────────────────────────────────────────────
-/// Extract the command JWS token from the `Nostr-Federated-Identity: Bearer`
-/// header. Returns `Err(401)` if the header is absent, `Err(403)` otherwise.
-///
-/// The same header is used for assertion tokens at upgrade and for command
-/// tokens at the admin API — distinct roles on distinct paths, never mixed.
-fn extract_command_jwt(headers: &HeaderMap) -> Result<&str, StatusCode> {
- let mut values = headers.get_all(CLIENT_ATTACHED_HEADER).iter();
- let first = values.next().ok_or(StatusCode::UNAUTHORIZED)?;
- // Repeated header → reject.
- if values.next().is_some() {
- return Err(StatusCode::FORBIDDEN);
- }
- let raw = first.to_str().map_err(|_| StatusCode::FORBIDDEN)?;
- if raw.contains(',') {
- return Err(StatusCode::FORBIDDEN);
- }
- let token = raw.strip_prefix("Bearer ").ok_or(StatusCode::FORBIDDEN)?;
- if token.is_empty() || token.contains(char::is_whitespace) {
- return Err(StatusCode::FORBIDDEN);
- }
- Ok(token)
-}
-
fn parse_hex_pubkey(raw: &str) -> Option {
if raw.len() != 64
|| !raw
@@ -583,18 +543,14 @@ fn plain_response(status: StatusCode, body: &'static str) -> Response {
.status(status)
.header("Content-Type", "text/plain; charset=utf-8")
.body(Body::from(body))
- .unwrap_or_else(|_| Response::new(Body::empty()))
+ .unwrap_or_else(|_| status.into_response())
}
-/// Build the `401 authentication required` response with the mandatory
-/// `WWW-Authenticate: Nostr` header. [NIP-FI.md §Rejection table]
-fn auth_required_response() -> Response {
- Response::builder()
- .status(StatusCode::UNAUTHORIZED)
- .header("Content-Type", "text/plain; charset=utf-8")
- .header("WWW-Authenticate", "Nostr")
- .body(Body::from("authentication required\n"))
- .unwrap_or_else(|_| Response::new(Body::empty()))
+/// Render a command rejection with its spec-exact status and body.
+fn command_denial(err: CommandError) -> Response {
+ let status =
+ StatusCode::from_u16(err.http_status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
+ plain_response(status, err.response_body())
}
/// Spec-exact 200 success response.
@@ -615,50 +571,6 @@ fn disconnected_response() -> Response {
#[cfg(test)]
mod tests {
use super::*;
- use axum::http::HeaderValue;
-
- fn headers_with(value: &str) -> HeaderMap {
- let mut h = HeaderMap::new();
- h.insert(
- CLIENT_ATTACHED_HEADER,
- HeaderValue::from_str(value).unwrap(),
- );
- h
- }
-
- // ── JWT extraction contract ────────────────────────────────────────────
-
- #[test]
- fn absent_header_gives_401() {
- let h = HeaderMap::new();
- assert_eq!(extract_command_jwt(&h), Err(StatusCode::UNAUTHORIZED));
- }
-
- #[test]
- fn repeated_header_gives_403() {
- let mut h = HeaderMap::new();
- h.append(
- CLIENT_ATTACHED_HEADER,
- HeaderValue::from_static("Bearer aaa.bbb.ccc"),
- );
- h.append(
- CLIENT_ATTACHED_HEADER,
- HeaderValue::from_static("Bearer ddd.eee.fff"),
- );
- assert_eq!(extract_command_jwt(&h), Err(StatusCode::FORBIDDEN));
- }
-
- #[test]
- fn non_bearer_gives_403() {
- let h = headers_with("Token aaa.bbb.ccc");
- assert_eq!(extract_command_jwt(&h), Err(StatusCode::FORBIDDEN));
- }
-
- #[test]
- fn valid_bearer_extracted() {
- let h = headers_with("Bearer aaa.bbb.ccc");
- assert_eq!(extract_command_jwt(&h), Ok("aaa.bbb.ccc"));
- }
// ── Hex pubkey parsing ────────────────────────────────────────────────
@@ -704,21 +616,6 @@ mod tests {
);
}
- /// `401` MUST carry `WWW-Authenticate: Nostr` and the spec body.
- #[tokio::test]
- async fn auth_required_response_has_www_authenticate() {
- let resp = auth_required_response();
- assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
- let www_auth = resp
- .headers()
- .get("WWW-Authenticate")
- .and_then(|v| v.to_str().ok())
- .unwrap_or("");
- assert_eq!(www_auth, "Nostr", "401 MUST carry WWW-Authenticate: Nostr");
- let body_bytes = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
- assert_eq!(body_bytes.as_ref(), b"authentication required\n");
- }
-
/// `403` error responses MUST NOT carry `WWW-Authenticate`.
#[test]
fn error_responses_have_no_www_authenticate() {
@@ -763,7 +660,7 @@ mod route_integration_tests {
};
use buzz_auth::{
CommandIssuerPolicy, CommandVerifier, IssuerCapacity, IssuerRegistry, NipFiDenyMap,
- ProductionJwksSource,
+ ProductionJwksSource, CLIENT_ATTACHED_HEADER,
};
use std::sync::Arc;
use tower::ServiceExt;
@@ -2587,6 +2484,382 @@ mod route_integration_tests {
assert_eq!(got, want, "Off-mode legacy response must be byte-identical");
}
+ // ── Characterization: exact disconnect-route rejection contract ──────────
+ //
+ // Pin status, Content-Type, WWW-Authenticate and body bytes for every
+ // pre-success rejection, plus the observable check order
+ // (header → JSON body → pubkey → verifier present → verify).
+
+ const PLAIN: &str = "text/plain; charset=utf-8";
+
+ async fn send_raw(
+ state: Arc,
+ header_values: Vec,
+ body: &[u8],
+ ) -> axum::response::Response {
+ let mut req = Request::builder()
+ .method("POST")
+ .uri(TEST_PATH)
+ .header("Content-Type", "application/json");
+ for v in header_values {
+ req = req.header(CLIENT_ATTACHED_HEADER, v);
+ }
+ crate::router::build_router(state)
+ .oneshot(req.body(Body::from(body.to_vec())).unwrap())
+ .await
+ .unwrap()
+ }
+
+ async fn assert_plain_denial(
+ resp: axum::response::Response,
+ status: StatusCode,
+ body: &str,
+ why: &str,
+ ) {
+ // Only the 401 carries a challenge. [NIP-FI.md §Rejection table]
+ let challenge = (status == StatusCode::UNAUTHORIZED).then_some("Nostr");
+ assert_eq!(resp.status(), status, "{why}");
+ let header = |name| {
+ resp.headers()
+ .get(name)
+ .map(|v: &axum::http::HeaderValue| v.to_str().unwrap().to_owned())
+ };
+ assert_eq!(header("Content-Type").as_deref(), Some(PLAIN), "{why}");
+ assert_eq!(header("WWW-Authenticate").as_deref(), challenge, "{why}");
+ let got = axum::body::to_bytes(resp.into_body(), 256).await.unwrap();
+ assert_eq!(got.as_ref(), body.as_bytes(), "{why}");
+ }
+
+ fn valid_body() -> &'static [u8] {
+ // A syntactically valid body: the pubkey is well-formed lowercase hex.
+ br#"{"pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"}"#
+ }
+
+ #[tokio::test]
+ async fn characterize_route_header_transport_rejections() {
+ use axum::http::HeaderValue;
+ let state = build_test_state(1000).await;
+ let missing = send_raw(Arc::clone(&state), vec![], valid_body()).await;
+ assert_plain_denial(
+ missing,
+ StatusCode::UNAUTHORIZED,
+ "authentication required\n",
+ "missing header",
+ )
+ .await;
+
+ let rejected: Vec<(&str, Vec)> = vec![
+ (
+ "repeated header",
+ vec![
+ HeaderValue::from_static("Bearer aaa.bbb.ccc"),
+ HeaderValue::from_static("Bearer ddd.eee.fff"),
+ ],
+ ),
+ (
+ "comma-joined value",
+ vec![HeaderValue::from_static("Bearer aaa.bbb.ccc,ddd.eee.fff")],
+ ),
+ (
+ "non-Bearer scheme",
+ vec![HeaderValue::from_static("Token aaa.bbb.ccc")],
+ ),
+ ("empty token", vec![HeaderValue::from_static("Bearer ")]),
+ (
+ "space in token",
+ vec![HeaderValue::from_static("Bearer aaa bbb")],
+ ),
+ (
+ "tab in token",
+ vec![HeaderValue::from_static("Bearer aaa\tbbb")],
+ ),
+ // U+00A0 is `char::is_whitespace` but not ASCII whitespace; the
+ // obs-text bytes fail `HeaderValue::to_str`, so it rejects before
+ // any whitespace predicate runs.
+ (
+ "non-ASCII whitespace in token",
+ vec![HeaderValue::from_bytes(b"Bearer aaa\xc2\xa0bbb").unwrap()],
+ ),
+ ];
+ // A malformed body makes a dropped transport check observable: it
+ // would surface as 400 from body parsing, not the same 403 the
+ // verifier would give.
+ for (why, values) in rejected {
+ let resp = send_raw(Arc::clone(&state), values, b"not json").await;
+ assert_plain_denial(resp, StatusCode::FORBIDDEN, "evidence rejected\n", why).await;
+ }
+ }
+
+ #[tokio::test]
+ async fn characterize_route_check_order_before_verify() {
+ use axum::http::HeaderValue;
+ let state = build_test_state(1000).await;
+ let bearer = || vec![HeaderValue::from_static("Bearer aaa.bbb.ccc")];
+
+ // Header is checked before the JSON body.
+ let resp = send_raw(Arc::clone(&state), vec![], b"not json").await;
+ assert_plain_denial(
+ resp,
+ StatusCode::UNAUTHORIZED,
+ "authentication required\n",
+ "missing header + malformed JSON",
+ )
+ .await;
+ let resp = send_raw(
+ Arc::clone(&state),
+ vec![HeaderValue::from_static("Token x")],
+ br#"{"pubkey":"NOT-HEX"}"#,
+ )
+ .await;
+ assert_plain_denial(
+ resp,
+ StatusCode::FORBIDDEN,
+ "evidence rejected\n",
+ "bad scheme + bad pubkey",
+ )
+ .await;
+
+ // Body shape is checked before the (garbage) token is verified.
+ for (why, body) in [
+ ("malformed JSON", b"not json".as_slice()),
+ ("missing pubkey field", br#"{}"#.as_slice()),
+ (
+ "uppercase pubkey",
+ br#"{"pubkey":"79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798"}"#
+ .as_slice(),
+ ),
+ ("short pubkey", br#"{"pubkey":"abcd"}"#.as_slice()),
+ ] {
+ let resp = send_raw(Arc::clone(&state), bearer(), body).await;
+ assert_plain_denial(resp, StatusCode::BAD_REQUEST, "bad request\n", why).await;
+ }
+
+ // No verifier: body/pubkey still precede the verifier check, and the
+ // verifier check precedes verification (the token is garbage).
+ let mut no_verifier = build_test_app_state(1000, crate::config::Config::for_test()).await;
+ no_verifier.nip_fi_command_verifier = None;
+ let no_verifier = Arc::new(no_verifier);
+ let resp = send_raw(Arc::clone(&no_verifier), bearer(), br#"{"pubkey":"abcd"}"#).await;
+ assert_plain_denial(
+ resp,
+ StatusCode::BAD_REQUEST,
+ "bad request\n",
+ "bad pubkey precedes verifier presence",
+ )
+ .await;
+ let resp = send_raw(no_verifier, bearer(), valid_body()).await;
+ assert_plain_denial(
+ resp,
+ StatusCode::SERVICE_UNAVAILABLE,
+ "authorization unavailable\n",
+ "no command verifier configured",
+ )
+ .await;
+ }
+
+ #[tokio::test]
+ async fn characterize_route_command_error_arms() {
+ let post = |state: Arc, token: String, target: String| async move {
+ do_request(
+ state,
+ "POST",
+ vec![
+ ("Content-Type", "application/json".into()),
+ (CLIENT_ATTACHED_HEADER, format!("Bearer {token}")),
+ ],
+ Some(serde_json::json!({ "pubkey": target })),
+ )
+ .await
+ };
+ let state = build_test_state(1).await;
+
+ let t = target_hex();
+ let tampered = format!("{}X", mint_token(&t, 300, serde_json::json!({})));
+ let resp = post(Arc::clone(&state), tampered, t).await;
+ assert_plain_denial(
+ resp,
+ StatusCode::FORBIDDEN,
+ "evidence rejected\n",
+ "EvidenceRejected",
+ )
+ .await;
+
+ let t = target_hex();
+ let token = mint_token(&t, 300, serde_json::json!({"sub": "intruder@example.com"}));
+ let resp = post(Arc::clone(&state), token, t).await;
+ assert_plain_denial(
+ resp,
+ StatusCode::FORBIDDEN,
+ "authorization denied\n",
+ "AuthorizationDenied",
+ )
+ .await;
+
+ let t = target_hex();
+ let token = mint_token(&t, 10 * 365 * 24 * 3600, serde_json::json!({}));
+ let resp = post(Arc::clone(&state), token, t).await;
+ assert_plain_denial(
+ resp,
+ StatusCode::BAD_REQUEST,
+ "bad request\n",
+ "UntilExceedsCeiling",
+ )
+ .await;
+
+ // Fill the capacity-1 deny set, then the next distinct target is full.
+ let t = target_hex();
+ let resp = post(
+ Arc::clone(&state),
+ mint_token(&t, 300, serde_json::json!({})),
+ t,
+ )
+ .await;
+ assert_eq!(resp.status(), StatusCode::OK, "control: slot filled");
+ let t = target_hex();
+ let resp = post(
+ Arc::clone(&state),
+ mint_token(&t, 300, serde_json::json!({})),
+ t,
+ )
+ .await;
+ assert_plain_denial(
+ resp,
+ StatusCode::SERVICE_UNAVAILABLE,
+ "deny set full\n",
+ "DenySetFull",
+ )
+ .await;
+
+ // An unseeded key source has no key set for the issuer.
+ let mut unseeded = build_test_app_state(1000, crate::config::Config::for_test()).await;
+ let key_source = Arc::new(
+ ProductionJwksSource::new(vec![test_jwks_config()], buzz_auth::HttpJwksFetcher::new())
+ .expect("key source"),
+ );
+ let mut registry = IssuerRegistry::new();
+ registry.insert(test_issuer_policy());
+ let deny_map = NipFiDenyMap::new(
+ 1000,
+ vec![IssuerCapacity {
+ issuer: TEST_ISS.to_owned(),
+ capacity: 1000,
+ }],
+ );
+ let policy =
+ CommandIssuerPolicy::new(TEST_ISS.to_owned(), 30, vec![TEST_SUB.to_owned()], 1000)
+ .expect("command policy");
+ unseeded.nip_fi_command_verifier = Some(Arc::new(CommandVerifier::new(
+ registry,
+ key_source,
+ vec![policy],
+ deny_map,
+ )));
+ let t = target_hex();
+ let token = mint_token(&t, 300, serde_json::json!({}));
+ let resp = post(Arc::new(unseeded), token, t).await;
+ assert_plain_denial(
+ resp,
+ StatusCode::SERVICE_UNAVAILABLE,
+ "authorization unavailable\n",
+ "AuthorizationUnavailable",
+ )
+ .await;
+ }
+
+ /// `buzz_nip_fi_disconnect_capacity_rejections_total` counts exactly the
+ /// `DenySetFull` rejections and no other command-error arm.
+ ///
+ /// `#[test]` with a current-thread runtime, not `#[tokio::test]`:
+ /// `metrics::with_local_recorder` is thread-local and takes a sync closure,
+ /// so each request runs to completion on this thread inside its own
+ /// recorder's scope.
+ #[test]
+ fn capacity_rejection_counter_counts_only_deny_set_full() {
+ use axum::body::Bytes;
+
+ fn capacity_rejections_during(
+ rt: &tokio::runtime::Runtime,
+ request: impl std::future::Future