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>
341 lines
11 KiB
Rust
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);
|
|
}
|