feat: add rate limiting and job queue depth cap (closes #15)
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 3s
CI/CD Pipeline / Clippy Lints (push) Successful in 45s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m24s
CI/CD Pipeline / Security Audit (push) Successful in 4s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m14s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Failing after 4s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m0s
CI/CD Pipeline / Build Debian Package (push) Failing after 5s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m24s
CI/CD Pipeline / Build RPM Package (push) Successful in 2m15s
CI/CD Pipeline / Build Alpine Package (push) Failing after 3m19s
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 3s
CI/CD Pipeline / Clippy Lints (push) Successful in 45s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m24s
CI/CD Pipeline / Security Audit (push) Successful in 4s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m14s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Failing after 4s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m0s
CI/CD Pipeline / Build Debian Package (push) Failing after 5s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m24s
CI/CD Pipeline / Build RPM Package (push) Successful in 2m15s
CI/CD Pipeline / Build Alpine Package (push) Failing after 3m19s
- Add custom RateLimitMiddleware using governor crate for per-IP rate limiting - Two-tier rate limiting: destructive (20 req/min, burst 10) and read (120 req/min, burst 30) - Health endpoints (/health, /api/v1/system/info) exempt from rate limiting - Add max_queue_depth to JobManager (default: 100, configurable via config.yaml) - Return 429 Too Many Requests with Retry-After header when queue is full - Add RateLimitConfig to config.yaml with all rate limit settings - Add 10 tests covering rate limiting, queue depth, and configuration defaults Co-authored-by: git-echo <git-echo@moon-dragon.us>
This commit is contained in:
committed by
GitHub
parent
6a4c4c95a4
commit
df2f4c70c9
340
tests/unit/rate_limit_test.rs
Normal file
340
tests/unit/rate_limit_test.rs
Normal file
@ -0,0 +1,340 @@
|
||||
//! Rate Limiting and Job Queue Depth Tests
|
||||
//!
|
||||
//! Tests for:
|
||||
//! - HTTP rate limiting (429 when exceeded)
|
||||
//! - Health endpoint exemption from rate limiting
|
||||
//! - Job queue depth cap (429 when full)
|
||||
//! - Configurable queue depth
|
||||
|
||||
use actix_web::{test, web, App};
|
||||
use linux_patch_api::api::rate_limit::RateLimitMiddleware;
|
||||
use linux_patch_api::api::routes::{configure_api_routes, configure_health_route};
|
||||
use linux_patch_api::auth::crl;
|
||||
use linux_patch_api::config::loader::RateLimitConfig;
|
||||
use linux_patch_api::jobs::manager::{JobManager, JobOperation};
|
||||
use linux_patch_api::packages::cache::PackageCacheState;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
/// Helper to build a test request with a peer IP address (required for rate limiting)
|
||||
fn test_request(method: actix_web::http::Method, uri: &str) -> test::TestRequest {
|
||||
test::TestRequest::with_uri(uri)
|
||||
.method(method)
|
||||
.peer_addr(SocketAddr::from(([127, 0, 0, 1], 12345)))
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_health_endpoint_exempt_from_rate_limiting() {
|
||||
let job_manager = web::Data::new(JobManager::new(5, 30, 100).unwrap());
|
||||
let backend = web::Data::new(linux_patch_api::packages::create_backend().unwrap());
|
||||
let cache_state = web::Data::new(PackageCacheState::new());
|
||||
let shared_crl_state = web::Data::new(crl::new_shared_state());
|
||||
let rl_cfg = RateLimitConfig::default();
|
||||
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.wrap(RateLimitMiddleware::new(rl_cfg))
|
||||
.app_data(job_manager.clone())
|
||||
.app_data(backend.clone())
|
||||
.app_data(cache_state.clone())
|
||||
.app_data(shared_crl_state.clone())
|
||||
.configure(|cfg| {
|
||||
configure_api_routes(
|
||||
cfg,
|
||||
job_manager.clone(),
|
||||
backend.clone(),
|
||||
cache_state.clone(),
|
||||
);
|
||||
})
|
||||
.configure(configure_health_route),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Health endpoint should always respond 200 regardless of rate limiting
|
||||
for _ in 0..50 {
|
||||
let req = test_request(actix_web::http::Method::GET, "/health").to_request();
|
||||
let resp = test::call_service(&app, req).await;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"Health endpoint should be exempt from rate limiting"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_system_info_exempt_from_rate_limiting() {
|
||||
let job_manager = web::Data::new(JobManager::new(5, 30, 100).unwrap());
|
||||
let backend = web::Data::new(linux_patch_api::packages::create_backend().unwrap());
|
||||
let cache_state = web::Data::new(PackageCacheState::new());
|
||||
let shared_crl_state = web::Data::new(crl::new_shared_state());
|
||||
let rl_cfg = RateLimitConfig::default();
|
||||
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.wrap(RateLimitMiddleware::new(rl_cfg))
|
||||
.app_data(job_manager.clone())
|
||||
.app_data(backend.clone())
|
||||
.app_data(cache_state.clone())
|
||||
.app_data(shared_crl_state.clone())
|
||||
.configure(|cfg| {
|
||||
configure_api_routes(
|
||||
cfg,
|
||||
job_manager.clone(),
|
||||
backend.clone(),
|
||||
cache_state.clone(),
|
||||
);
|
||||
})
|
||||
.configure(configure_health_route),
|
||||
)
|
||||
.await;
|
||||
|
||||
// /api/v1/system/info should be exempt from rate limiting
|
||||
for _ in 0..50 {
|
||||
let req = test_request(actix_web::http::Method::GET, "/api/v1/system/info").to_request();
|
||||
let resp = test::call_service(&app, req).await;
|
||||
// May return 200 or 500 depending on system, but should NOT be 429
|
||||
assert_ne!(
|
||||
resp.status(),
|
||||
429,
|
||||
"System info endpoint should be exempt from rate limiting"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_read_rate_limiting_returns_429() {
|
||||
let job_manager = web::Data::new(JobManager::new(5, 30, 100).unwrap());
|
||||
let backend = web::Data::new(linux_patch_api::packages::create_backend().unwrap());
|
||||
let cache_state = web::Data::new(PackageCacheState::new());
|
||||
let shared_crl_state = web::Data::new(crl::new_shared_state());
|
||||
// Use very low limits so sequential test requests can reliably trigger 429
|
||||
let rl_cfg = RateLimitConfig {
|
||||
enabled: true,
|
||||
destructive_per_minute: 20,
|
||||
destructive_burst: 10,
|
||||
read_per_minute: 5,
|
||||
read_burst: 3,
|
||||
};
|
||||
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.wrap(RateLimitMiddleware::new(rl_cfg))
|
||||
.app_data(job_manager.clone())
|
||||
.app_data(backend.clone())
|
||||
.app_data(cache_state.clone())
|
||||
.app_data(shared_crl_state.clone())
|
||||
.configure(|cfg| {
|
||||
configure_api_routes(
|
||||
cfg,
|
||||
job_manager.clone(),
|
||||
backend.clone(),
|
||||
cache_state.clone(),
|
||||
);
|
||||
})
|
||||
.configure(configure_health_route),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Read tier: 120 req/min, burst 30
|
||||
// Send more than burst_size requests to trigger rate limiting
|
||||
let mut rate_limited = false;
|
||||
for _ in 0..50 {
|
||||
let req = test_request(actix_web::http::Method::GET, "/api/v1/packages").to_request();
|
||||
let resp = test::call_service(&app, req).await;
|
||||
if resp.status() == 429 {
|
||||
rate_limited = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
rate_limited,
|
||||
"Read endpoint should return 429 after exceeding burst limit"
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_destructive_rate_limiting_returns_429() {
|
||||
let job_manager = web::Data::new(JobManager::new(5, 30, 100).unwrap());
|
||||
let backend = web::Data::new(linux_patch_api::packages::create_backend().unwrap());
|
||||
let cache_state = web::Data::new(PackageCacheState::new());
|
||||
let shared_crl_state = web::Data::new(crl::new_shared_state());
|
||||
// Use very low limits so sequential test requests can reliably trigger 429
|
||||
let rl_cfg = RateLimitConfig {
|
||||
enabled: true,
|
||||
destructive_per_minute: 5,
|
||||
destructive_burst: 3,
|
||||
read_per_minute: 120,
|
||||
read_burst: 30,
|
||||
};
|
||||
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.wrap(RateLimitMiddleware::new(rl_cfg))
|
||||
.app_data(job_manager.clone())
|
||||
.app_data(backend.clone())
|
||||
.app_data(cache_state.clone())
|
||||
.app_data(shared_crl_state.clone())
|
||||
.configure(|cfg| {
|
||||
configure_api_routes(
|
||||
cfg,
|
||||
job_manager.clone(),
|
||||
backend.clone(),
|
||||
cache_state.clone(),
|
||||
);
|
||||
})
|
||||
.configure(configure_health_route),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Destructive tier: 20 req/min, burst 10
|
||||
// Send more than burst_size requests to trigger rate limiting
|
||||
let mut rate_limited = false;
|
||||
for _ in 0..15 {
|
||||
let req = test_request(actix_web::http::Method::POST, "/api/v1/packages")
|
||||
.set_json(serde_json::json!({
|
||||
"packages": [{"name": "test-pkg"}],
|
||||
"options": {}
|
||||
}))
|
||||
.to_request();
|
||||
let resp = test::call_service(&app, req).await;
|
||||
if resp.status() == 429 {
|
||||
rate_limited = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
rate_limited,
|
||||
"Destructive endpoint should return 429 after exceeding burst limit"
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_rate_limiting_disabled() {
|
||||
let job_manager = web::Data::new(JobManager::new(5, 30, 100).unwrap());
|
||||
let backend = web::Data::new(linux_patch_api::packages::create_backend().unwrap());
|
||||
let cache_state = web::Data::new(PackageCacheState::new());
|
||||
let shared_crl_state = web::Data::new(crl::new_shared_state());
|
||||
let rl_cfg = RateLimitConfig {
|
||||
enabled: false,
|
||||
..RateLimitConfig::default()
|
||||
};
|
||||
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.wrap(RateLimitMiddleware::new(rl_cfg))
|
||||
.app_data(job_manager.clone())
|
||||
.app_data(backend.clone())
|
||||
.app_data(cache_state.clone())
|
||||
.app_data(shared_crl_state.clone())
|
||||
.configure(|cfg| {
|
||||
configure_api_routes(
|
||||
cfg,
|
||||
job_manager.clone(),
|
||||
backend.clone(),
|
||||
cache_state.clone(),
|
||||
);
|
||||
})
|
||||
.configure(configure_health_route),
|
||||
)
|
||||
.await;
|
||||
|
||||
// With rate limiting disabled, even excessive requests should not get 429
|
||||
let mut got_429 = false;
|
||||
for _ in 0..50 {
|
||||
let req = test_request(actix_web::http::Method::GET, "/api/v1/packages").to_request();
|
||||
let resp = test::call_service(&app, req).await;
|
||||
if resp.status() == 429 {
|
||||
got_429 = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!got_429,
|
||||
"Should not get 429 when rate limiting is disabled"
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_job_queue_depth_cap() {
|
||||
// Create JobManager with very small queue depth (2)
|
||||
let job_manager = JobManager::new(5, 30, 2).unwrap();
|
||||
|
||||
// Fill the queue with pending jobs
|
||||
job_manager
|
||||
.create_job(JobOperation::Install, vec!["pkg1".to_string()])
|
||||
.await
|
||||
.unwrap();
|
||||
job_manager
|
||||
.create_job(JobOperation::Install, vec!["pkg2".to_string()])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Queue should now be at capacity
|
||||
assert!(
|
||||
!job_manager.can_accept_job().await,
|
||||
"Queue should be at capacity after filling to max_queue_depth"
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_job_queue_depth_default() {
|
||||
// Default max_queue_depth should be 100
|
||||
let job_manager = JobManager::new(5, 30, 100).unwrap();
|
||||
assert_eq!(
|
||||
job_manager.max_queue_depth(),
|
||||
100,
|
||||
"Default max_queue_depth should be 100"
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_job_queue_depth_configurable() {
|
||||
// Verify queue depth is configurable
|
||||
let job_manager = JobManager::new(5, 30, 50).unwrap();
|
||||
assert_eq!(
|
||||
job_manager.max_queue_depth(),
|
||||
50,
|
||||
"max_queue_depth should be configurable"
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_can_accept_job_respects_queue_depth() {
|
||||
let job_manager = JobManager::new(5, 30, 3).unwrap();
|
||||
|
||||
// Should accept when queue is empty
|
||||
assert!(
|
||||
job_manager.can_accept_job().await,
|
||||
"Should accept job when queue is empty"
|
||||
);
|
||||
|
||||
// Fill to capacity
|
||||
job_manager
|
||||
.create_job(JobOperation::Install, vec!["a".to_string()])
|
||||
.await
|
||||
.unwrap();
|
||||
job_manager
|
||||
.create_job(JobOperation::Install, vec!["b".to_string()])
|
||||
.await
|
||||
.unwrap();
|
||||
job_manager
|
||||
.create_job(JobOperation::Install, vec!["c".to_string()])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Should reject when at capacity
|
||||
assert!(
|
||||
!job_manager.can_accept_job().await,
|
||||
"Should reject job when queue is at capacity"
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_rate_limit_config_defaults() {
|
||||
let config = RateLimitConfig::default();
|
||||
assert!(config.enabled, "Rate limiting should be enabled by default");
|
||||
assert_eq!(config.destructive_per_minute, 20);
|
||||
assert_eq!(config.destructive_burst, 10);
|
||||
assert_eq!(config.read_per_minute, 120);
|
||||
assert_eq!(config.read_burst, 30);
|
||||
}
|
||||
Reference in New Issue
Block a user