refactor: Upload extractor, 429 refusal test, stricter contract test

This commit is contained in:
2026-10-25 21:59:31 +01:00
parent c8d9138349
commit 2ac1d4e0cf
10 changed files with 139 additions and 52 deletions

View File

@@ -64,6 +64,17 @@ fn slow_down(reason: RejectionReason) -> axum::response::Response {
}
}
/// Every `/api/v1` limiter is built here so its refusals use the one error shape.
fn api_governor(quota: Quota) -> axum_governor::GovernorConfig<std::net::IpAddr> {
GovernorConfigBuilder::default()
.with_extractor(PeerIp::default())
.expect_connect_info()
.quota_default(quota)
.error_handler(slow_down)
.finish()
.unwrap()
}
fn per_minute(n: u64) -> Quota {
let n = NonZeroU32::new(n.clamp(1, u32::MAX as u64) as u32).unwrap();
Quota::requests_per_minute(n)
@@ -130,23 +141,11 @@ fn cors_layer(origins: &str) -> CorsLayer {
}
fn api_routes(rate_limit: u64, cors_origins: &str) -> Router<AppState> {
let cfg = GovernorConfigBuilder::default()
.with_extractor(PeerIp::default())
.expect_connect_info()
.quota_default(per_minute(rate_limit))
.error_handler(slow_down)
.finish()
.unwrap();
let cfg = api_governor(per_minute(rate_limit));
let base = api::router().merge(federation::api_routes());
let webhook_cfg = GovernorConfigBuilder::default()
.with_extractor(PeerIp::default())
.expect_connect_info()
.quota_default(per_minute(rate_limit / 4))
.error_handler(slow_down)
.finish()
.unwrap();
let webhook_cfg = api_governor(per_minute(rate_limit / 4));
let webhook_routes = api::webhook_router().layer(GovernorLayer::new(webhook_cfg));
Router::new()
@@ -154,3 +153,46 @@ fn api_routes(rate_limit: u64, cors_origins: &str) -> Router<AppState> {
.nest("/api/v1", webhook_routes)
.layer(cors_layer(cors_origins))
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{body::Body, http::Request};
use tower::ServiceExt;
async fn body_json(resp: axum::response::Response) -> serde_json::Value {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
#[tokio::test]
async fn api_rate_limit_refusal_is_the_one_error_shape() {
let app = Router::new()
.route("/x", routing::get(|| async { "ok" }))
.layer(GovernorLayer::new(api_governor(per_minute(1))));
let addr: std::net::SocketAddr = "127.0.0.1:1".parse().unwrap();
let mut last = None;
for _ in 0..3 {
let mut req = Request::builder().uri("/x").body(Body::empty()).unwrap();
req.extensions_mut()
.insert(axum::extract::ConnectInfo::<std::net::SocketAddr>(addr));
last = Some(app.clone().oneshot(req).await.unwrap());
}
let resp = last.unwrap();
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
let body = body_json(resp).await;
assert_eq!(body["code"], "RATE_LIMITED");
assert!(body["error"].is_string());
}
#[tokio::test]
async fn unidentifiable_caller_is_refused_in_the_one_error_shape() {
let resp = slow_down(RejectionReason::KeyExtractionFailed(
axum_governor::ExtractionError::MissingConnectInfo,
));
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(body_json(resp).await["code"], "INTERNAL");
}
}