use std::net::SocketAddr;
use axum::body::Body;
use axum::extract::ConnectInfo;
use axum::http::{Request, StatusCode};
use gradatum_server::config::RateLimitConfig;
use http_body_util::BodyExt;
use tower::ServiceExt;
const EXTERNAL_IP: &str = "192.0.2.1:12345";
const LOOPBACK_IP: &str = "127.0.0.1:12345";
fn ping_req(peer: &str) -> Request<Body> {
let addr: SocketAddr = peer
.parse()
.unwrap_or_else(|e| panic!("parse SocketAddr '{}' : {}", peer, e));
let mut req = Request::builder()
.uri("/ping")
.method("GET")
.body(Body::empty())
.expect("construire requête GET /ping — ne peut pas échouer");
req.extensions_mut().insert(ConnectInfo(addr));
req
}
#[tokio::test]
async fn burst_within_limit_ok_with_real_body() {
let rl = RateLimitConfig {
enabled: true,
per_minute: 600,
burst: 10,
exempt_localhost: false,
};
let app = gradatum_server::build_rate_limit_test_app(&rl);
for i in 1u32..=10 {
let resp = app
.clone()
.oneshot(ping_req(EXTERNAL_IP))
.await
.unwrap_or_else(|e| panic!("requête {i}/10 GET /ping échouée : {e}"));
assert_eq!(
resp.status(),
StatusCode::OK,
"requête {i}/10 : attendu 200 OK (dans le burst), obtenu {}",
resp.status()
);
let bytes = resp
.into_body()
.collect()
.await
.unwrap_or_else(|e| panic!("collect body requête {i}/10 : {e}"))
.to_bytes();
assert!(
!bytes.is_empty(),
"requête {i}/10 : body ne doit pas être vide"
);
let body_str = std::str::from_utf8(&bytes)
.unwrap_or_else(|e| panic!("body requête {i}/10 non-UTF-8 : {e}"));
assert_eq!(
body_str, "pong",
"requête {i}/10 : body attendu 'pong', obtenu '{body_str}'"
);
}
}
#[tokio::test]
async fn burst_exceeded_returns_429_with_retry_after() {
let rl = RateLimitConfig {
enabled: true,
per_minute: 600,
burst: 10,
exempt_localhost: false,
};
let app = gradatum_server::build_rate_limit_test_app(&rl);
for i in 1u32..=10 {
let resp = app
.clone()
.oneshot(ping_req(EXTERNAL_IP))
.await
.unwrap_or_else(|e| panic!("requête de chauffe {i}/10 échouée : {e}"));
assert_eq!(
resp.status(),
StatusCode::OK,
"requête de chauffe {i}/10 : attendu 200, obtenu {}",
resp.status()
);
}
let resp_11 = app
.clone()
.oneshot(ping_req(EXTERNAL_IP))
.await
.expect("11e requête GET /ping — oneshot doit retourner un Result");
assert_eq!(
resp_11.status(),
StatusCode::TOO_MANY_REQUESTS,
"11e requête : attendu 429 Too Many Requests, obtenu {}",
resp_11.status()
);
assert!(
resp_11.headers().contains_key("retry-after"),
"réponse 429 doit contenir le header 'retry-after', headers : {:?}",
resp_11.headers()
);
}
#[tokio::test]
async fn localhost_exempt_returns_real_handler_body() {
let rl = RateLimitConfig {
enabled: true,
per_minute: 60,
burst: 1, exempt_localhost: true,
};
let app = gradatum_server::build_rate_limit_test_app(&rl);
for i in 1u32..=50 {
let resp = app
.clone()
.oneshot(ping_req(LOOPBACK_IP))
.await
.unwrap_or_else(|e| panic!("requête loopback {i}/50 échouée : {e}"));
assert_eq!(
resp.status(),
StatusCode::OK,
"requête loopback {i}/50 : attendu 200 OK (bypass), obtenu {}",
resp.status()
);
let bytes = resp
.into_body()
.collect()
.await
.unwrap_or_else(|e| panic!("collect body loopback {i}/50 : {e}"))
.to_bytes();
assert!(
!bytes.is_empty(),
"requête loopback {i}/50 : body ne doit pas être vide (bypass loopback doit appeler le handler réel)"
);
let body_str = std::str::from_utf8(&bytes)
.unwrap_or_else(|e| panic!("body loopback {i}/50 non-UTF-8 : {e}"));
assert_eq!(
body_str, "pong",
"requête loopback {i}/50 : body attendu 'pong', obtenu '{body_str}'"
);
}
}