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
103
Cargo.lock
generated
103
Cargo.lock
generated
@ -44,6 +44,18 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "actix-governor"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0954b0f27aabd8f56bb03f2a77b412ddf3f8c034a3c27b2086c1fc75415760df"
|
||||
dependencies = [
|
||||
"actix-http",
|
||||
"actix-web",
|
||||
"futures",
|
||||
"governor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "actix-http"
|
||||
version = "3.12.1"
|
||||
@ -968,6 +980,19 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "5.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"hashbrown 0.14.5",
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
@ -1314,6 +1339,12 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-timer"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
@ -1382,6 +1413,26 @@ dependencies = [
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "governor"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68a7f542ee6b35af73b06abc0dad1c1bae89964e4e253bc4b587b91c9637867b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dashmap",
|
||||
"futures",
|
||||
"futures-timer",
|
||||
"no-std-compat",
|
||||
"nonzero_ext",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"quanta",
|
||||
"rand 0.8.6",
|
||||
"smallvec",
|
||||
"spinning_top",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.3.27"
|
||||
@ -1934,6 +1985,7 @@ name = "linux-patch-api"
|
||||
version = "1.3.2"
|
||||
dependencies = [
|
||||
"actix",
|
||||
"actix-governor",
|
||||
"actix-rt",
|
||||
"actix-tls",
|
||||
"actix-web",
|
||||
@ -2104,6 +2156,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "no-std-compat"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
@ -2114,6 +2172,12 @@ dependencies = [
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nonzero_ext"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "6.1.1"
|
||||
@ -2383,6 +2447,12 @@ dependencies = [
|
||||
"plotters-backend",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@ -2441,6 +2511,21 @@ version = "2.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
|
||||
|
||||
[[package]]
|
||||
name = "quanta"
|
||||
version = "0.12.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"raw-cpuid",
|
||||
"wasi",
|
||||
"web-sys",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
@ -2593,6 +2678,15 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "raw-cpuid"
|
||||
version = "11.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
@ -3085,6 +3179,15 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spinning_top"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
|
||||
@ -16,6 +16,9 @@ actix-web-actors = "4"
|
||||
actix = "0.13"
|
||||
actix-tls = { version = "3", features = ["rustls-0_23"] }
|
||||
|
||||
# Rate limiting (actix-governor for per-IP rate limiting)
|
||||
actix-governor = "0.6"
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
@ -118,6 +121,10 @@ path = "tests/e2e/test_enrollment_e2e.rs"
|
||||
name = "auth_test"
|
||||
path = "tests/integration/auth_test.rs"
|
||||
|
||||
[[test]]
|
||||
name = "rate_limit_test"
|
||||
path = "tests/unit/rate_limit_test.rs"
|
||||
|
||||
[[bench]]
|
||||
name = "api_benchmarks"
|
||||
harness = false
|
||||
|
||||
@ -190,6 +190,19 @@ pub async fn rollback_job(
|
||||
|
||||
info!(request_id = %request_id, job_id = %job_id_str, "Initiating job rollback");
|
||||
|
||||
// Check job queue capacity
|
||||
if !job_manager.can_accept_job().await {
|
||||
let response = ApiResponse::<()>::error(
|
||||
"QUEUE_FULL",
|
||||
"Job queue is at capacity. Please retry later.",
|
||||
None,
|
||||
true,
|
||||
);
|
||||
return HttpResponse::TooManyRequests()
|
||||
.insert_header(("Retry-After", "60"))
|
||||
.json(response);
|
||||
}
|
||||
|
||||
// Parse job ID
|
||||
let job_id = match Uuid::parse_str(&job_id_str) {
|
||||
Ok(id) => id,
|
||||
@ -321,7 +334,7 @@ pub async fn delete_job(
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure routes for job endpoints
|
||||
/// Configure all job routes
|
||||
pub fn configure_routes(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("/jobs")
|
||||
|
||||
@ -252,6 +252,19 @@ pub async fn install_packages(
|
||||
|
||||
info!(request_id = %request_id, packages = ?package_names, "Installing packages");
|
||||
|
||||
// Check job queue capacity
|
||||
if !job_manager.can_accept_job().await {
|
||||
let response = ApiResponse::<()>::error(
|
||||
"QUEUE_FULL",
|
||||
"Job queue is at capacity. Please retry later.",
|
||||
None,
|
||||
true,
|
||||
);
|
||||
return HttpResponse::TooManyRequests()
|
||||
.insert_header(("Retry-After", "60"))
|
||||
.json(response);
|
||||
}
|
||||
|
||||
// Create async job
|
||||
match job_manager
|
||||
.create_job(JobOperation::Install, package_names.clone())
|
||||
@ -337,6 +350,19 @@ pub async fn update_package(
|
||||
|
||||
info!(request_id = %request_id, package = %package_name, "Updating package");
|
||||
|
||||
// Check job queue capacity
|
||||
if !job_manager.can_accept_job().await {
|
||||
let response = ApiResponse::<()>::error(
|
||||
"QUEUE_FULL",
|
||||
"Job queue is at capacity. Please retry later.",
|
||||
None,
|
||||
true,
|
||||
);
|
||||
return HttpResponse::TooManyRequests()
|
||||
.insert_header(("Retry-After", "60"))
|
||||
.json(response);
|
||||
}
|
||||
|
||||
// Create async job
|
||||
match job_manager
|
||||
.create_job(JobOperation::Update, vec![package_name.clone()])
|
||||
@ -420,6 +446,20 @@ pub async fn remove_package(
|
||||
}
|
||||
|
||||
info!(request_id = %request_id, package = %package_name, "Removing package");
|
||||
|
||||
// Check job queue capacity
|
||||
if !job_manager.can_accept_job().await {
|
||||
let response = ApiResponse::<()>::error(
|
||||
"QUEUE_FULL",
|
||||
"Job queue is at capacity. Please retry later.",
|
||||
None,
|
||||
true,
|
||||
);
|
||||
return HttpResponse::TooManyRequests()
|
||||
.insert_header(("Retry-After", "60"))
|
||||
.json(response);
|
||||
}
|
||||
|
||||
match job_manager
|
||||
.create_job(JobOperation::Remove, vec![package_name.clone()])
|
||||
.await
|
||||
@ -484,7 +524,7 @@ pub async fn remove_package(
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure routes for package endpoints
|
||||
/// Configure all package routes
|
||||
pub fn configure_routes(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("/packages")
|
||||
|
||||
@ -105,6 +105,19 @@ pub async fn apply_patches(
|
||||
"Applying patches"
|
||||
);
|
||||
|
||||
// Check job queue capacity
|
||||
if !job_manager.can_accept_job().await {
|
||||
let response = ApiResponse::<()>::error(
|
||||
"QUEUE_FULL",
|
||||
"Job queue is at capacity. Please retry later.",
|
||||
None,
|
||||
true,
|
||||
);
|
||||
return HttpResponse::TooManyRequests()
|
||||
.insert_header(("Retry-After", "60"))
|
||||
.json(response);
|
||||
}
|
||||
|
||||
// Create async job
|
||||
let package_list = body.packages.clone().unwrap_or_default();
|
||||
match job_manager
|
||||
@ -321,7 +334,7 @@ pub async fn apply_patches(
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure routes for patch endpoints
|
||||
/// Configure all patch routes
|
||||
pub fn configure_routes(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("/patches")
|
||||
|
||||
@ -229,6 +229,19 @@ pub async fn reboot_system(
|
||||
}
|
||||
}
|
||||
|
||||
// Check job queue capacity
|
||||
if !job_manager.can_accept_job().await {
|
||||
let response = ApiResponse::<()>::error(
|
||||
"QUEUE_FULL",
|
||||
"Job queue is at capacity. Please retry later.",
|
||||
None,
|
||||
true,
|
||||
);
|
||||
return HttpResponse::TooManyRequests()
|
||||
.insert_header(("Retry-After", "60"))
|
||||
.json(response);
|
||||
}
|
||||
|
||||
// Create async job for reboot
|
||||
match job_manager.create_job(JobOperation::Reboot, vec![]).await {
|
||||
Ok(job_id) => {
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
//! - WebSocket endpoint for real-time job status streaming
|
||||
|
||||
pub mod handlers;
|
||||
pub mod rate_limit;
|
||||
pub mod routes;
|
||||
|
||||
// Re-export handlers for convenience
|
||||
|
||||
209
src/api/rate_limit.rs
Normal file
209
src/api/rate_limit.rs
Normal file
@ -0,0 +1,209 @@
|
||||
//! Rate Limiting Middleware
|
||||
//!
|
||||
//! Custom Actix-web middleware that provides per-IP rate limiting with two tiers:
|
||||
//! - **Destructive tier**: POST/PUT/DELETE methods (20 req/min, burst 10 by default)
|
||||
//! - **Read tier**: GET methods (120 req/min, burst 30 by default)
|
||||
//! - **Health exempt**: /health, /api/v1/system/info bypass rate limiting entirely
|
||||
|
||||
use actix_governor::governor::clock::{Clock, DefaultClock};
|
||||
use actix_governor::governor::middleware::NoOpMiddleware;
|
||||
use actix_governor::governor::state::keyed::DefaultKeyedStateStore;
|
||||
use actix_governor::governor::{Quota, RateLimiter};
|
||||
use actix_web::body::BoxBody;
|
||||
use actix_web::dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform};
|
||||
use actix_web::http::Method;
|
||||
use actix_web::{HttpResponse, ResponseError};
|
||||
use std::future::{ready, Ready};
|
||||
use std::net::IpAddr;
|
||||
use std::num::NonZeroU32;
|
||||
use std::sync::Arc;
|
||||
use tracing::info;
|
||||
|
||||
use crate::config::loader::RateLimitConfig;
|
||||
|
||||
/// Paths exempt from rate limiting
|
||||
const EXEMPT_PATHS: &[&str] = &["/health", "/api/v1/system/info"];
|
||||
|
||||
/// Rate limiting middleware factory
|
||||
pub struct RateLimitMiddleware {
|
||||
config: RateLimitConfig,
|
||||
}
|
||||
|
||||
impl RateLimitMiddleware {
|
||||
pub fn new(config: RateLimitConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
}
|
||||
|
||||
/// Error returned when rate limit is exceeded
|
||||
#[derive(Debug)]
|
||||
pub struct RateLimitError {
|
||||
retry_after_secs: u64,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RateLimitError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"Rate limit exceeded. Retry after {} seconds.",
|
||||
self.retry_after_secs
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl ResponseError for RateLimitError {
|
||||
fn status_code(&self) -> actix_web::http::StatusCode {
|
||||
actix_web::http::StatusCode::TOO_MANY_REQUESTS
|
||||
}
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
HttpResponse::TooManyRequests()
|
||||
.insert_header(("Retry-After", self.retry_after_secs.to_string()))
|
||||
.content_type("text/plain; charset=utf-8")
|
||||
.body(self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Type alias for per-IP rate limiter
|
||||
pub type KeyedRateLimiter =
|
||||
RateLimiter<IpAddr, DefaultKeyedStateStore<IpAddr>, DefaultClock, NoOpMiddleware>;
|
||||
|
||||
/// Shared rate limiter state
|
||||
#[derive(Clone)]
|
||||
pub struct RateLimiters {
|
||||
/// Rate limiter for destructive operations (POST/PUT/DELETE)
|
||||
destructive: Arc<KeyedRateLimiter>,
|
||||
/// Rate limiter for read operations (GET)
|
||||
read: Arc<KeyedRateLimiter>,
|
||||
/// Whether rate limiting is enabled
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl RateLimiters {
|
||||
/// Build rate limiters from configuration
|
||||
pub fn new(config: &RateLimitConfig) -> Self {
|
||||
let destructive_quota =
|
||||
Quota::per_minute(NonZeroU32::new(config.destructive_per_minute).unwrap())
|
||||
.allow_burst(NonZeroU32::new(config.destructive_burst).unwrap());
|
||||
|
||||
let read_quota = Quota::per_minute(NonZeroU32::new(config.read_per_minute).unwrap())
|
||||
.allow_burst(NonZeroU32::new(config.read_burst).unwrap());
|
||||
|
||||
let destructive = Arc::new(KeyedRateLimiter::keyed(destructive_quota));
|
||||
let read = Arc::new(KeyedRateLimiter::keyed(read_quota));
|
||||
|
||||
info!(
|
||||
enabled = config.enabled,
|
||||
destructive_per_min = config.destructive_per_minute,
|
||||
destructive_burst = config.destructive_burst,
|
||||
read_per_min = config.read_per_minute,
|
||||
read_burst = config.read_burst,
|
||||
"Rate limiters configured"
|
||||
);
|
||||
|
||||
Self {
|
||||
destructive,
|
||||
read,
|
||||
enabled: config.enabled,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a request should be rate limited
|
||||
/// Returns Ok(()) if the request is allowed, Err(RateLimitError) if rate limited
|
||||
pub fn check(
|
||||
&self,
|
||||
method: &Method,
|
||||
path: &str,
|
||||
peer_ip: IpAddr,
|
||||
) -> Result<(), RateLimitError> {
|
||||
if !self.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Exempt paths bypass rate limiting entirely
|
||||
if EXEMPT_PATHS.contains(&path) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let limiter = match *method {
|
||||
Method::POST | Method::PUT | Method::DELETE => &self.destructive,
|
||||
Method::GET => &self.read,
|
||||
_ => &self.read, // Default to read tier for other methods
|
||||
};
|
||||
|
||||
match limiter.check_key(&peer_ip) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(negative) => {
|
||||
let retry_after = negative
|
||||
.wait_time_from(DefaultClock::default().now())
|
||||
.as_secs();
|
||||
Err(RateLimitError {
|
||||
retry_after_secs: retry_after.max(1),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Transform<S, ServiceRequest> for RateLimitMiddleware
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<BoxBody>, Error = actix_web::Error>,
|
||||
S::Future: 'static,
|
||||
{
|
||||
type Response = ServiceResponse<BoxBody>;
|
||||
type Error = actix_web::Error;
|
||||
type Transform = RateLimitService<S>;
|
||||
type InitError = ();
|
||||
type Future = Ready<Result<Self::Transform, Self::InitError>>;
|
||||
|
||||
fn new_transform(&self, service: S) -> Self::Future {
|
||||
ready(Ok(RateLimitService {
|
||||
service,
|
||||
limiters: RateLimiters::new(&self.config),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Rate limiting service wrapper
|
||||
pub struct RateLimitService<S> {
|
||||
service: S,
|
||||
limiters: RateLimiters,
|
||||
}
|
||||
|
||||
impl<S> Service<ServiceRequest> for RateLimitService<S>
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<BoxBody>, Error = actix_web::Error>,
|
||||
S::Future: 'static,
|
||||
{
|
||||
type Response = ServiceResponse<BoxBody>;
|
||||
type Error = actix_web::Error;
|
||||
type Future =
|
||||
std::pin::Pin<Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>>>>;
|
||||
|
||||
forward_ready!(service);
|
||||
|
||||
fn call(&self, req: ServiceRequest) -> Self::Future {
|
||||
// Extract peer IP
|
||||
let peer_ip = req
|
||||
.connection_info()
|
||||
.peer_addr()
|
||||
.and_then(|addr| addr.parse::<IpAddr>().ok());
|
||||
|
||||
// Check rate limiting
|
||||
if let Some(ip) = peer_ip {
|
||||
let method = req.method().clone();
|
||||
let path = req.path().to_string();
|
||||
|
||||
if let Err(e) = self.limiters.check(&method, &path, ip) {
|
||||
// Rate limited - return 429 response
|
||||
let (http_req, _) = req.into_parts();
|
||||
let response = e.error_response();
|
||||
let srv_resp = ServiceResponse::new(http_req, response);
|
||||
return Box::pin(ready(Ok(srv_resp)));
|
||||
}
|
||||
}
|
||||
|
||||
// Not rate limited - pass through to the inner service
|
||||
Box::pin(self.service.call(req))
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,11 @@
|
||||
//! API Routes Configuration
|
||||
//!
|
||||
//! Aggregates all endpoint routes and configures the Actix-web application.
|
||||
//! Rate limiting is applied at the App level in main.rs using actix-governor
|
||||
//! with method-based filtering:
|
||||
//! - **Read tier** (120 req/min, burst 30): GET methods
|
||||
//! - **Destructive tier** (20 req/min, burst 10): POST/PUT/DELETE methods
|
||||
//! - **Health exempt**: /health, /api/v1/system/info (health-exempt routes)
|
||||
|
||||
use actix_web::{web, HttpResponse};
|
||||
use tracing::info;
|
||||
@ -17,6 +22,7 @@ async fn method_not_allowed() -> HttpResponse {
|
||||
.insert_header(("Allow", "GET, POST, PUT, DELETE"))
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Configure all API routes for the application
|
||||
pub fn configure_api_routes(
|
||||
cfg: &mut web::ServiceConfig,
|
||||
@ -26,6 +32,10 @@ pub fn configure_api_routes(
|
||||
) {
|
||||
info!("Configuring API v1 routes");
|
||||
|
||||
// Health-exempt endpoint: /api/v1/system/info is registered separately
|
||||
// so it can bypass rate limiting applied at the App level
|
||||
cfg.service(web::resource("/api/v1/system/info").route(web::get().to(system::get_system_info)));
|
||||
|
||||
cfg.app_data(job_manager)
|
||||
.app_data(backend)
|
||||
.app_data(cache_state)
|
||||
@ -33,15 +43,10 @@ pub fn configure_api_routes(
|
||||
web::scope("/api/v1")
|
||||
// VULN-005: Default handler for unsupported methods returns 405 instead of 404
|
||||
.default_service(web::route().to(method_not_allowed))
|
||||
// Package Management Endpoints
|
||||
.configure(packages::configure_routes)
|
||||
// Patch Management Endpoints
|
||||
.configure(patches::configure_routes)
|
||||
// System Management Endpoints
|
||||
.configure(system::configure_routes)
|
||||
// Job Management Endpoints
|
||||
.configure(jobs::configure_routes)
|
||||
// WebSocket Endpoint
|
||||
.configure(websocket::configure_routes),
|
||||
);
|
||||
}
|
||||
|
||||
@ -60,12 +60,58 @@ pub struct JobsConfig {
|
||||
pub timeout_minutes: u64,
|
||||
#[serde(default = "default_storage_path")]
|
||||
pub storage_path: String,
|
||||
#[serde(default = "default_max_queue_depth")]
|
||||
pub max_queue_depth: usize,
|
||||
}
|
||||
|
||||
fn default_storage_path() -> String {
|
||||
"/var/lib/linux_patch_api/jobs".to_string()
|
||||
}
|
||||
|
||||
fn default_max_queue_depth() -> usize {
|
||||
100
|
||||
}
|
||||
|
||||
/// Rate limiting configuration
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct RateLimitConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_destructive_per_minute")]
|
||||
pub destructive_per_minute: u32,
|
||||
#[serde(default = "default_destructive_burst")]
|
||||
pub destructive_burst: u32,
|
||||
#[serde(default = "default_read_per_minute")]
|
||||
pub read_per_minute: u32,
|
||||
#[serde(default = "default_read_burst")]
|
||||
pub read_burst: u32,
|
||||
}
|
||||
|
||||
fn default_destructive_per_minute() -> u32 {
|
||||
20
|
||||
}
|
||||
fn default_destructive_burst() -> u32 {
|
||||
10
|
||||
}
|
||||
fn default_read_per_minute() -> u32 {
|
||||
120
|
||||
}
|
||||
fn default_read_burst() -> u32 {
|
||||
30
|
||||
}
|
||||
|
||||
impl Default for RateLimitConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
destructive_per_minute: default_destructive_per_minute(),
|
||||
destructive_burst: default_destructive_burst(),
|
||||
read_per_minute: default_read_per_minute(),
|
||||
read_burst: default_read_burst(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Logging configuration
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct LoggingConfig {
|
||||
@ -445,6 +491,8 @@ pub struct AppConfig {
|
||||
pub package_manager: Option<PackageManagerConfig>,
|
||||
#[serde(default)]
|
||||
pub enrollment: Option<EnrollmentConfig>,
|
||||
#[serde(default)]
|
||||
pub rate_limit: RateLimitConfig,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
|
||||
@ -6,6 +6,6 @@
|
||||
//! - Auto-reload on file change via notify watcher
|
||||
|
||||
pub mod loader;
|
||||
pub use loader::{validate_certs, AppConfig, CertStatus, EnrollmentConfig};
|
||||
pub use loader::{validate_certs, AppConfig, CertStatus, EnrollmentConfig, RateLimitConfig};
|
||||
pub mod validator;
|
||||
pub mod watcher;
|
||||
|
||||
@ -140,6 +140,7 @@ pub struct JobStatusEvent {
|
||||
pub struct JobManager {
|
||||
max_concurrent: usize,
|
||||
timeout_minutes: u64,
|
||||
max_queue_depth: usize,
|
||||
jobs: Arc<RwLock<HashMap<Uuid, Job>>>,
|
||||
/// Broadcast sender for job status events
|
||||
event_sender: broadcast::Sender<JobStatusEvent>,
|
||||
@ -147,11 +148,16 @@ pub struct JobManager {
|
||||
|
||||
impl JobManager {
|
||||
/// Create a new job manager
|
||||
pub fn new(max_concurrent: usize, timeout_minutes: u64) -> Result<Self> {
|
||||
pub fn new(
|
||||
max_concurrent: usize,
|
||||
timeout_minutes: u64,
|
||||
max_queue_depth: usize,
|
||||
) -> Result<Self> {
|
||||
let (event_sender, _) = broadcast::channel(256);
|
||||
Ok(Self {
|
||||
max_concurrent,
|
||||
timeout_minutes,
|
||||
max_queue_depth,
|
||||
jobs: Arc::new(RwLock::new(HashMap::new())),
|
||||
event_sender,
|
||||
})
|
||||
@ -167,6 +173,11 @@ impl JobManager {
|
||||
self.max_concurrent
|
||||
}
|
||||
|
||||
/// Get max queue depth
|
||||
pub fn max_queue_depth(&self) -> usize {
|
||||
self.max_queue_depth
|
||||
}
|
||||
|
||||
/// Subscribe to job status events
|
||||
/// Returns a broadcast receiver that will receive JobStatusEvent messages
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<JobStatusEvent> {
|
||||
@ -335,9 +346,17 @@ impl JobManager {
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Check if can accept new job (respecting max_concurrent)
|
||||
/// Check if can accept new job (respecting max_queue_depth)
|
||||
/// Returns false when the total number of pending + running jobs
|
||||
/// equals or exceeds the configured queue depth cap.
|
||||
pub async fn can_accept_job(&self) -> bool {
|
||||
self.running_count().await < self.max_concurrent
|
||||
let jobs = self.jobs.read().await;
|
||||
let active_count = jobs
|
||||
.values()
|
||||
.filter(|j| j.status == JobStatus::Running || j.status == JobStatus::Pending)
|
||||
.count();
|
||||
drop(jobs);
|
||||
active_count < self.max_queue_depth
|
||||
}
|
||||
|
||||
/// Delete a completed/failed job from history
|
||||
@ -401,6 +420,7 @@ impl Clone for JobManager {
|
||||
Self {
|
||||
max_concurrent: self.max_concurrent,
|
||||
timeout_minutes: self.timeout_minutes,
|
||||
max_queue_depth: self.max_queue_depth,
|
||||
jobs: self.jobs.clone(),
|
||||
event_sender: self.event_sender.clone(),
|
||||
}
|
||||
|
||||
32
src/main.rs
32
src/main.rs
@ -252,10 +252,15 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
|
||||
// Initialize job manager
|
||||
let job_manager = JobManager::new(config.jobs.max_concurrent, config.jobs.timeout_minutes)?;
|
||||
let job_manager = JobManager::new(
|
||||
config.jobs.max_concurrent,
|
||||
config.jobs.timeout_minutes,
|
||||
config.jobs.max_queue_depth,
|
||||
)?;
|
||||
info!(
|
||||
max_jobs = config.jobs.max_concurrent,
|
||||
timeout_minutes = config.jobs.timeout_minutes,
|
||||
max_queue_depth = config.jobs.max_queue_depth,
|
||||
"Job manager initialized"
|
||||
);
|
||||
|
||||
@ -311,35 +316,36 @@ async fn main() -> Result<()> {
|
||||
// Clone whitelist manager for use inside the HttpServer closure
|
||||
let wl = whitelist_manager.clone();
|
||||
|
||||
// Clone rate limit config for use inside the HttpServer closure
|
||||
let rate_limit_config = config.rate_limit.clone();
|
||||
|
||||
// Create server builder
|
||||
// Security middleware stack (order matters):
|
||||
// 1. WhitelistMiddleware — IP-based access control (deny-by-default)
|
||||
// 2. SecurityHeadersMiddleware — VULN-006: reject duplicate critical headers
|
||||
// 3. Logger — request logging (after auth decisions)
|
||||
// 3. RateLimitMiddleware — per-IP rate limiting (read + destructive tiers)
|
||||
// 4. Logger — request logging (after auth decisions)
|
||||
let server_builder = HttpServer::new(move || {
|
||||
let mut app = App::new()
|
||||
App::new()
|
||||
.wrap(WhitelistMiddleware::new(wl.clone()))
|
||||
.wrap(SecurityHeadersMiddleware::new())
|
||||
.wrap(linux_patch_api::api::rate_limit::RateLimitMiddleware::new(
|
||||
rate_limit_config.clone(),
|
||||
))
|
||||
.wrap(Logger::default())
|
||||
.app_data(job_manager_data.clone())
|
||||
.app_data(backend_data.clone())
|
||||
.app_data(cache_state.clone())
|
||||
.app_data(crl_state_data.clone());
|
||||
|
||||
// Configure API routes
|
||||
app = app.configure(|cfg| {
|
||||
.app_data(crl_state_data.clone())
|
||||
.configure(|cfg| {
|
||||
configure_api_routes(
|
||||
cfg,
|
||||
job_manager_data.clone(),
|
||||
backend_data.clone(),
|
||||
cache_state.clone(),
|
||||
);
|
||||
});
|
||||
|
||||
// Configure health route (outside API scope)
|
||||
app = app.configure(configure_health_route);
|
||||
|
||||
app
|
||||
})
|
||||
.configure(configure_health_route)
|
||||
})
|
||||
.workers(4)
|
||||
// VULN-004: Configure header size limit to 8KB to prevent DoS via oversized headers
|
||||
|
||||
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