Private
Public Access
1
0
Files
linux_patch_api/tests/unit/rate_limit_test.rs
Draco-Lunaris-Echo df2f4c70c9
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
feat: add rate limiting and job queue depth cap (closes #15)
- 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>
2026-06-06 15:39:49 -05:00

341 lines
11 KiB
Rust

//! 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);
}