#[cfg(feature = "ban-manager")]
use axum::Extension;
use axum::{
Json,
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
#[cfg(feature = "ban-manager")]
use super::routes::OperatorIdentity;
use super::server::LimiteronState;
use super::service::AdminService;
use super::service::AdminServiceError;
pub(crate) fn service_error_status(e: &AdminServiceError) -> StatusCode {
use axum::http::StatusCode as S;
match e {
AdminServiceError::NotConfigured(_) => S::SERVICE_UNAVAILABLE,
AdminServiceError::Invalid(_) => S::BAD_REQUEST,
AdminServiceError::NotFound(_) => S::NOT_FOUND,
AdminServiceError::Forbidden(_) => S::FORBIDDEN,
AdminServiceError::Internal(_) => S::INTERNAL_SERVER_ERROR,
}
}
pub(crate) fn service_error_message(e: &AdminServiceError) -> String {
match e {
AdminServiceError::NotConfigured(what) => crate::i18n::t(
"admin-service-not-configured",
&[("what", (*what).to_string())],
),
other => other.to_string(),
}
}
#[derive(Serialize)]
pub struct ApiResponse<T: Serialize> {
pub success: bool,
pub message: String,
pub data: Option<T>,
}
impl<T: Serialize> ApiResponse<T> {
pub fn ok(data: T) -> Self {
Self {
success: true,
message: "OK".to_string(),
data: Some(data),
}
}
pub fn error(message: impl Into<String>) -> Self {
Self {
success: false,
message: message.into(),
data: None,
}
}
}
pub async fn healthz() -> Json<serde_json::Value> {
Json(serde_json::json!({ "status": "ok" }))
}
pub async fn readyz(State(state): State<LimiteronState>) -> axum::response::Response {
let status = state.governor.health_status().await;
let body = serde_json::json!({
"status": if status.healthy() { "ready" } else { "not_ready" },
"storage_healthy": status.storage_healthy,
"ban_storage_healthy": status.ban_storage_healthy,
"cache_healthy": status.cache_healthy,
"background_tasks_alive": status.background_tasks_alive,
});
if status.healthy() {
(StatusCode::OK, Json(body)).into_response()
} else {
(StatusCode::SERVICE_UNAVAILABLE, Json(body)).into_response()
}
}
#[cfg(feature = "prometheus")]
pub async fn metrics(State(state): State<LimiteronState>) -> axum::response::Response {
use axum::http::header;
let content_type = "text/plain; version=0.0.4";
let text = match state.metrics.clone().or_else(crate::telemetry::try_global) {
Some(m) => m.gather(),
None => "# limiteron metrics not configured\n".to_string(),
};
([(header::CONTENT_TYPE, content_type)], text).into_response()
}
#[cfg(not(feature = "prometheus"))]
pub async fn metrics() -> axum::response::Response {
use axum::http::header;
(
[(header::CONTENT_TYPE, "text/plain; version=0.0.4")],
"# limiteron metrics feature disabled\n".to_string(),
)
.into_response()
}
#[derive(Serialize)]
pub struct SystemStatus {
pub total_requests: u64,
pub blocked_requests: u64,
pub success_rate: f64,
#[cfg(feature = "ban-manager")]
pub active_bans: usize,
#[cfg(feature = "circuit-breaker")]
pub circuit_breaker: String,
}
pub async fn get_status(State(state): State<LimiteronState>) -> Json<ApiResponse<SystemStatus>> {
Json(ApiResponse::ok(state.service().status().await))
}
pub async fn introspect(State(state): State<LimiteronState>) -> Json<serde_json::Value> {
Json(state.service().introspect().await)
}
pub(crate) const BATCH_MAX_ITEMS: usize = 1000;
pub async fn apply_config(
State(state): State<LimiteronState>,
Json(config): Json<crate::config::FlowControlConfig>,
) -> (StatusCode, Json<ApiResponse<serde_json::Value>>) {
match state.service().apply_config(config).await {
Ok(data) => (
StatusCode::OK,
Json(ApiResponse {
success: true,
message: crate::i18n::t_simple("admin-config-applied"),
data: Some(data),
}),
),
Err(e) => (
service_error_status(&e),
Json(ApiResponse::error(service_error_message(&e))),
),
}
}
#[derive(Deserialize)]
pub struct BatchCheckItem {
#[serde(default)]
pub user_id: Option<String>,
#[serde(default)]
pub ip: Option<String>,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub method: Option<String>,
}
#[derive(Deserialize)]
pub struct BatchCheckBody {
pub requests: Vec<BatchCheckItem>,
}
pub async fn check_batch(
State(state): State<LimiteronState>,
Json(body): Json<BatchCheckBody>,
) -> (StatusCode, Json<ApiResponse<serde_json::Value>>) {
match state.service().check_batch(body).await {
Ok((allowed_count, results)) => (
StatusCode::OK,
Json(ApiResponse {
success: true,
message: crate::i18n::t(
"admin-batch-check-allowed",
&[
("allowed", allowed_count.to_string()),
("total", results.len().to_string()),
],
),
data: Some(serde_json::json!({ "results": results })),
}),
),
Err(e) => (
service_error_status(&e),
Json(ApiResponse::error(service_error_message(&e))),
),
}
}
#[derive(Deserialize)]
pub struct TokenPrefetchItem {
pub key: String,
pub tokens: u64,
}
#[derive(Deserialize)]
pub struct TokenPrefetchBody {
pub items: Vec<TokenPrefetchItem>,
}
pub(crate) static TOKEN_PREFETCHER: std::sync::OnceLock<crate::limiters::BatchTokenPrefetcher> =
std::sync::OnceLock::new();
pub(crate) fn token_prefetcher() -> &'static crate::limiters::BatchTokenPrefetcher {
TOKEN_PREFETCHER.get_or_init(crate::limiters::BatchTokenPrefetcher::new)
}
pub async fn prefetch_tokens(
Json(body): Json<TokenPrefetchBody>,
) -> (StatusCode, Json<ApiResponse<serde_json::Value>>) {
match crate::admin::service::prefetch_tokens_detached(body).await {
Ok((granted_count, results)) => (
StatusCode::OK,
Json(ApiResponse {
success: true,
message: crate::i18n::t(
"admin-tokens-prefetch-granted",
&[
("granted", granted_count.to_string()),
("total", results.len().to_string()),
],
),
data: Some(serde_json::Value::Array(results)),
}),
),
Err(e) => (
service_error_status(&e),
Json(ApiResponse::error(service_error_message(&e))),
),
}
}
#[derive(Deserialize)]
pub struct UnbanRequest {
pub reason: Option<String>,
#[serde(default)]
pub operator: Option<String>,
}
#[derive(Deserialize, Default)]
pub struct BanTargetQuery {
#[serde(rename = "type")]
pub target_type: Option<String>,
}
#[cfg(feature = "ban-manager")]
pub async fn delete_ban(
State(state): State<LimiteronState>,
Extension(operator): Extension<OperatorIdentity>,
Path(target): Path<String>,
Query(query): Query<BanTargetQuery>,
Json(req): Json<UnbanRequest>,
) -> (StatusCode, Json<ApiResponse<()>>) {
match state
.service()
.delete_ban(operator.0, target, query, req)
.await
{
Ok(message) => (
StatusCode::OK,
Json(ApiResponse {
success: true,
message,
data: Some(()),
}),
),
Err(e) => (
service_error_status(&e),
Json(ApiResponse::error(service_error_message(&e))),
),
}
}
#[cfg(not(feature = "ban-manager"))]
pub async fn delete_ban(
Path(_target): Path<String>,
Query(_query): Query<BanTargetQuery>,
) -> (StatusCode, Json<ApiResponse<()>>) {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiResponse::error(crate::i18n::t_simple(
"admin-ban-manager-not-configured",
))),
)
}
#[derive(Deserialize)]
#[cfg(feature = "ban-manager")]
pub struct CreateBanRequest {
pub target: crate::storage::BanTarget,
pub reason: String,
#[serde(default)]
pub operator: Option<String>,
#[serde(default)]
pub duration_secs: Option<u64>,
}
#[derive(Serialize)]
#[cfg(feature = "ban-manager")]
pub struct BanResponse {
pub id: String,
pub ban_times: u32,
pub expires_at: i64,
pub is_manual: bool,
}
#[cfg(feature = "ban-manager")]
pub async fn create_ban(
State(state): State<LimiteronState>,
Extension(operator): Extension<OperatorIdentity>,
Json(req): Json<CreateBanRequest>,
) -> (StatusCode, Json<ApiResponse<BanResponse>>) {
match state.service().create_ban(operator.0, req).await {
Ok(ban) => (StatusCode::CREATED, Json(ApiResponse::ok(ban))),
Err(e) => (
service_error_status(&e),
Json(ApiResponse::error(service_error_message(&e))),
),
}
}
#[cfg(not(feature = "ban-manager"))]
pub async fn create_ban() -> (StatusCode, Json<ApiResponse<()>>) {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiResponse::error(crate::i18n::t_simple(
"admin-ban-manager-not-configured",
))),
)
}
#[derive(Deserialize)]
pub struct UpdateQuotaRequest {
pub resource: String,
pub new_limit: u64,
#[serde(default)]
pub duration_secs: Option<u64>,
}
#[derive(Serialize)]
pub struct UpdateQuotaResponse {
pub success: bool,
pub expires_at: Option<u64>,
}
#[cfg(feature = "quota-control")]
pub async fn update_quota(
State(state): State<LimiteronState>,
Path(user_id): Path<String>,
Json(req): Json<UpdateQuotaRequest>,
) -> (StatusCode, Json<ApiResponse<UpdateQuotaResponse>>) {
match state.service().update_quota(&user_id, req).await {
Ok(resp) => (StatusCode::OK, Json(ApiResponse::ok(resp))),
Err(e) => (
service_error_status(&e),
Json(ApiResponse::error(service_error_message(&e))),
),
}
}
#[cfg(not(feature = "quota-control"))]
pub async fn update_quota(
Path(_user_id): Path<String>,
) -> (StatusCode, Json<ApiResponse<UpdateQuotaResponse>>) {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiResponse::error(crate::i18n::t_simple(
"admin-quota-controller-not-configured",
))),
)
}
#[derive(Serialize)]
pub struct CircuitBreakerStatus {
pub state: String,
pub failure_rate: f64,
pub slow_call_rate: f64,
}
#[cfg(feature = "circuit-breaker")]
pub async fn get_circuit_breaker_status(
State(state): State<LimiteronState>,
) -> (StatusCode, Json<ApiResponse<CircuitBreakerStatus>>) {
match state.service().circuit_breaker_status().await {
Ok(status) => (StatusCode::OK, Json(ApiResponse::ok(status))),
Err(e) => (
service_error_status(&e),
Json(ApiResponse::error(service_error_message(&e))),
),
}
}
#[cfg(not(feature = "circuit-breaker"))]
pub async fn get_circuit_breaker_status() -> (StatusCode, Json<ApiResponse<()>>) {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiResponse::error(crate::i18n::t_simple(
"admin-circuit-breaker-not-configured",
))),
)
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use crate::config::{
Action, ActionConfig, FlowControlConfig, GlobalConfig, LimiterConfig, Matcher, Rule,
};
use crate::governor::Governor;
use crate::storage::{BanStorage, MemoryBanStorage, MemoryStorage, Storage};
use std::sync::Arc;
fn make_valid_config() -> FlowControlConfig {
FlowControlConfig {
version: "0.1.0".to_string(),
global: GlobalConfig::default(),
rules: vec![Rule {
id: "test_rule".to_string(),
name: "Test Rule".to_string(),
priority: 100,
matchers: vec![Matcher::User {
user_ids: vec!["*".to_string()],
}],
limiters: vec![LimiterConfig::TokenBucket {
capacity: 100,
refill_rate: 10,
}],
action: ActionConfig {
on_exceed: Action::Reject,
ban: None,
},
}],
}
}
pub(crate) async fn make_governor() -> Governor {
let storage: Arc<dyn Storage> = Arc::new(MemoryStorage::new());
let ban_storage: Arc<dyn BanStorage> = Arc::new(MemoryBanStorage::new());
Governor::builder()
.with_config(make_valid_config())
.with_storage(storage)
.with_ban_storage(ban_storage)
.build()
.await
.expect("Governor build should succeed with valid config")
}
#[derive(Default)]
#[cfg(any(
feature = "ban-manager",
feature = "quota-control",
feature = "circuit-breaker"
))]
pub(crate) struct TestDeps {
#[cfg(feature = "ban-manager")]
pub(crate) ban_manager: Option<Arc<crate::BanManager>>,
#[cfg(feature = "quota-control")]
pub(crate) quota_controller: Option<Arc<crate::QuotaController>>,
#[cfg(feature = "circuit-breaker")]
pub(crate) circuit_breaker: Option<Arc<crate::CircuitBreaker>>,
#[cfg(feature = "prometheus")]
pub(crate) metrics: Option<Arc<crate::telemetry::Metrics>>,
}
#[cfg(any(
feature = "ban-manager",
feature = "quota-control",
feature = "circuit-breaker"
))]
pub(crate) fn make_state_with(governor: Arc<Governor>, deps: TestDeps) -> LimiteronState {
LimiteronState {
governor,
#[cfg(feature = "ban-manager")]
ban_manager: deps.ban_manager,
#[cfg(feature = "quota-control")]
quota_controller: deps.quota_controller,
#[cfg(feature = "circuit-breaker")]
circuit_breaker: deps.circuit_breaker,
#[cfg(feature = "prometheus")]
metrics: deps.metrics,
}
}
pub(crate) async fn make_state() -> LimiteronState {
LimiteronState {
governor: Arc::new(make_governor().await),
#[cfg(feature = "ban-manager")]
ban_manager: None,
#[cfg(feature = "quota-control")]
quota_controller: None,
#[cfg(feature = "circuit-breaker")]
circuit_breaker: None,
#[cfg(feature = "prometheus")]
metrics: None,
}
}
#[test]
fn test_api_response_ok_contains_data() {
let resp: ApiResponse<i32> = ApiResponse::ok(42);
assert!(resp.success);
assert_eq!(resp.message, "OK");
assert_eq!(resp.data, Some(42));
}
#[test]
fn test_api_response_error_has_no_data() {
let resp: ApiResponse<i32> = ApiResponse::error("something failed");
assert!(!resp.success);
assert_eq!(resp.message, "something failed");
assert_eq!(resp.data, None);
}
#[test]
fn test_api_response_error_accepts_string_and_str() {
let owned = String::from("owned error");
let resp1: ApiResponse<i32> = ApiResponse::error(owned);
assert_eq!(resp1.message, "owned error");
let resp2: ApiResponse<i32> = ApiResponse::error("borrowed error");
assert_eq!(resp2.message, "borrowed error");
}
#[tokio::test]
async fn test_get_status_empty_governor() {
let state = make_state().await;
let resp = get_status(State(state)).await;
assert!(resp.0.success);
let data = resp.0.data.unwrap();
assert_eq!(data.total_requests, 0);
assert_eq!(data.blocked_requests, 0);
assert_eq!(data.success_rate, 1.0);
}
#[cfg(feature = "ban-manager")]
#[tokio::test]
async fn test_delete_ban_no_manager() {
let state = make_state().await;
let req = UnbanRequest {
reason: None,
operator: None,
};
let resp = delete_ban(
State(state),
Extension(OperatorIdentity("tester".to_string())),
Path("192.168.1.1".to_string()),
Query(Default::default()),
Json(req),
)
.await;
assert!(!resp.1.0.success);
assert_eq!(resp.1.0.message, "Ban manager not configured");
}
#[cfg(feature = "ban-manager")]
#[tokio::test]
async fn test_delete_ban_with_manager_ip_target() {
use crate::BanManager;
let ban_manager = Arc::new(BanManager::new().await.unwrap());
let governor = Arc::new(make_governor().await);
let state = make_state_with(
governor,
TestDeps {
ban_manager: Some(ban_manager),
..Default::default()
},
);
let req = UnbanRequest {
reason: Some("test".to_string()),
operator: Some("tester".to_string()),
};
let resp = delete_ban(
State(state),
Extension(OperatorIdentity("tester".to_string())),
Path("192.168.1.1".to_string()),
Query(Default::default()),
Json(req),
)
.await;
assert!(!resp.1.0.success);
assert_eq!(resp.1.0.message, "Ban not found");
}
#[cfg(feature = "ban-manager")]
#[tokio::test]
async fn test_delete_ban_with_manager_userid_target() {
use crate::BanManager;
let ban_manager = Arc::new(BanManager::new().await.unwrap());
let governor = Arc::new(make_governor().await);
let state = make_state_with(
governor,
TestDeps {
ban_manager: Some(ban_manager),
..Default::default()
},
);
let req = UnbanRequest {
reason: None,
operator: None,
};
let resp = delete_ban(
State(state),
Extension(OperatorIdentity("tester".to_string())),
Path("user-123".to_string()),
Query(Default::default()),
Json(req),
)
.await;
assert!(!resp.1.0.success);
assert_eq!(resp.1.0.message, "Ban not found");
}
#[cfg(feature = "quota-control")]
#[tokio::test]
async fn test_update_quota_no_controller() {
let state = make_state().await;
let req = UpdateQuotaRequest {
resource: "api".to_string(),
new_limit: 0,
duration_secs: None,
};
let resp = update_quota(State(state), Path("user-1".to_string()), Json(req)).await;
assert!(!resp.1.0.success);
assert_eq!(resp.1.0.message, "Quota controller not configured");
}
#[cfg(feature = "quota-control")]
#[tokio::test]
async fn test_update_quota_unsupported_limit() {
use crate::QuotaController;
use crate::cache::CacheQuotaStorage;
use crate::quota::QuotaConfig;
use crate::storage::QuotaStorage;
use oxcache::backend::memory::DashMapMemoryBackend;
let storage: Arc<dyn QuotaStorage> = Arc::new(CacheQuotaStorage::new(Arc::new(
DashMapMemoryBackend::new(),
)));
let quota_controller = Arc::new(QuotaController::with_dependencies(
storage,
QuotaConfig::default(),
));
let governor = Arc::new(make_governor().await);
let state = make_state_with(
governor,
TestDeps {
quota_controller: Some(quota_controller),
..Default::default()
},
);
let req = UpdateQuotaRequest {
resource: "api".to_string(),
new_limit: 100,
duration_secs: None,
};
let resp = update_quota(State(state), Path("user-1".to_string()), Json(req)).await;
assert!(!resp.1.0.success);
assert!(resp.1.0.message.contains("not supported"));
}
#[cfg(feature = "circuit-breaker")]
#[tokio::test]
async fn test_get_circuit_breaker_status_no_cb() {
let state = make_state().await;
let resp = get_circuit_breaker_status(State(state)).await;
assert!(!resp.1.0.success);
assert_eq!(resp.1.0.message, "Circuit breaker not configured");
}
#[cfg(feature = "circuit-breaker")]
#[tokio::test]
async fn test_get_circuit_breaker_status_with_cb() {
use crate::circuit::{CircuitBreaker, CircuitBreakerConfig};
let cb = Arc::new(CircuitBreaker::with_dependencies(
CircuitBreakerConfig::default(),
));
let governor = Arc::new(make_governor().await);
let state = make_state_with(
governor,
TestDeps {
circuit_breaker: Some(cb),
..Default::default()
},
);
let resp = get_circuit_breaker_status(State(state)).await;
assert!(resp.1.0.success);
let data = resp.1.0.data.unwrap();
assert_eq!(data.failure_rate, 0.0);
assert_eq!(data.slow_call_rate, 0.0);
}
#[cfg(feature = "ban-manager")]
#[tokio::test]
async fn test_get_status_with_ban_manager_and_cb() {
use crate::BanManager;
use crate::circuit::{CircuitBreaker, CircuitBreakerConfig};
let ban_manager = Arc::new(BanManager::new().await.unwrap());
let cb = Arc::new(CircuitBreaker::with_dependencies(
CircuitBreakerConfig::default(),
));
let governor = Arc::new(make_governor().await);
let state = make_state_with(
governor,
TestDeps {
ban_manager: Some(ban_manager),
circuit_breaker: Some(cb),
..Default::default()
},
);
let resp = get_status(State(state)).await;
assert!(resp.0.success);
let data = resp.0.data.unwrap();
assert_eq!(data.active_bans, 0);
assert!(!data.circuit_breaker.is_empty());
}
#[cfg(feature = "ban-manager")]
#[tokio::test]
async fn test_delete_ban_success() {
use crate::BanManager;
use crate::ban::BanTarget;
use crate::storage::BanRecord;
let ban_manager = Arc::new(BanManager::new().await.unwrap());
let target = BanTarget::Ip("10.0.0.1".to_string());
let now = chrono::Utc::now();
ban_manager
.add_ban(BanRecord {
target: target.clone(),
ban_times: 1,
duration: std::time::Duration::from_secs(3600),
banned_at: now,
expires_at: now + chrono::Duration::seconds(3600),
is_manual: true,
reason: "test ban".to_string(),
})
.await
.unwrap();
let governor = Arc::new(make_governor().await);
let state = make_state_with(
governor,
TestDeps {
ban_manager: Some(ban_manager),
..Default::default()
},
);
let req = UnbanRequest {
reason: Some("manual unban".to_string()),
operator: Some("admin".to_string()),
};
let resp = delete_ban(
State(state),
Extension(OperatorIdentity("admin".to_string())),
Path("10.0.0.1".to_string()),
Query(Default::default()),
Json(req),
)
.await;
assert!(resp.1.0.success);
assert_eq!(resp.1.0.message, "manual unban");
}
#[cfg(feature = "ban-manager")]
#[tokio::test]
async fn test_delete_ban_mac_with_explicit_type() {
use crate::BanManager;
use crate::ban::BanTarget;
use crate::storage::BanRecord;
let ban_manager = Arc::new(BanManager::new().await.unwrap());
let target = BanTarget::Mac("00:1a:2b:3c:4d:5e".to_string());
let now = chrono::Utc::now();
ban_manager
.add_ban(BanRecord {
target: target.clone(),
ban_times: 1,
duration: std::time::Duration::from_secs(3600),
banned_at: now,
expires_at: now + chrono::Duration::seconds(3600),
is_manual: true,
reason: "mac ban".to_string(),
})
.await
.unwrap();
let governor = Arc::new(make_governor().await);
let state = make_state_with(
governor,
TestDeps {
ban_manager: Some(ban_manager),
..Default::default()
},
);
let req = UnbanRequest {
reason: Some("mac unban".to_string()),
operator: Some("admin".to_string()),
};
let query = Query(BanTargetQuery {
target_type: Some("mac".to_string()),
});
let resp = delete_ban(
State(state),
Extension(OperatorIdentity("admin".to_string())),
Path("00:1a:2b:3c:4d:5e".to_string()),
query,
Json(req),
)
.await;
assert!(resp.1.0.success);
assert_eq!(resp.1.0.message, "mac unban");
}
#[cfg(feature = "ban-manager")]
#[tokio::test]
async fn test_delete_ban_geo_with_explicit_type() {
use crate::BanManager;
use crate::ban::BanTarget;
use crate::storage::BanRecord;
let ban_manager = Arc::new(BanManager::new().await.unwrap());
let target = BanTarget::Geo {
country_code: "CN".to_string(),
};
let now = chrono::Utc::now();
ban_manager
.add_ban(BanRecord {
target: target.clone(),
ban_times: 1,
duration: std::time::Duration::from_secs(3600),
banned_at: now,
expires_at: now + chrono::Duration::seconds(3600),
is_manual: true,
reason: "geo ban".to_string(),
})
.await
.unwrap();
let governor = Arc::new(make_governor().await);
let state = make_state_with(
governor,
TestDeps {
ban_manager: Some(ban_manager),
..Default::default()
},
);
let req = UnbanRequest {
reason: Some("geo unban".to_string()),
operator: Some("admin".to_string()),
};
let query = Query(BanTargetQuery {
target_type: Some("geo".to_string()),
});
let resp = delete_ban(
State(state),
Extension(OperatorIdentity("admin".to_string())),
Path("CN".to_string()),
query,
Json(req),
)
.await;
assert!(resp.1.0.success);
assert_eq!(resp.1.0.message, "geo unban");
}
#[cfg(feature = "ban-manager")]
#[tokio::test]
async fn test_delete_ban_unsupported_target_type_returns_400() {
use crate::BanManager;
let ban_manager = Arc::new(BanManager::new().await.unwrap());
let governor = Arc::new(make_governor().await);
let state = make_state_with(
governor,
TestDeps {
ban_manager: Some(ban_manager),
..Default::default()
},
);
let req = UnbanRequest {
reason: None,
operator: None,
};
let query = Query(BanTargetQuery {
target_type: Some("foo".to_string()),
});
let (status, resp) = delete_ban(
State(state),
Extension(OperatorIdentity("tester".to_string())),
Path("1.2.3.4".to_string()),
query,
Json(req),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(!resp.0.success);
assert!(resp.0.message.contains("unsupported target type: foo"));
}
#[cfg(feature = "ban-manager")]
#[tokio::test]
async fn test_delete_ban_mac_without_type_falls_back_to_userid() {
use crate::BanManager;
use crate::ban::BanTarget;
use crate::storage::BanRecord;
let ban_manager = Arc::new(BanManager::new().await.unwrap());
let target = BanTarget::Mac("aa:bb:cc:dd:ee:ff".to_string());
let now = chrono::Utc::now();
ban_manager
.add_ban(BanRecord {
target: target.clone(),
ban_times: 1,
duration: std::time::Duration::from_secs(3600),
banned_at: now,
expires_at: now + chrono::Duration::seconds(3600),
is_manual: true,
reason: "mac ban".to_string(),
})
.await
.unwrap();
let governor = Arc::new(make_governor().await);
let state = make_state_with(
governor,
TestDeps {
ban_manager: Some(ban_manager),
..Default::default()
},
);
let req = UnbanRequest {
reason: None,
operator: None,
};
let (status, resp) = delete_ban(
State(state),
Extension(OperatorIdentity("tester".to_string())),
Path("aa:bb:cc:dd:ee:ff".to_string()),
Query(Default::default()),
Json(req),
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(resp.0.message, "Ban not found");
}
#[cfg(feature = "quota-control")]
#[tokio::test]
async fn test_update_quota_reset_success() {
use crate::QuotaController;
use crate::cache::CacheQuotaStorage;
use crate::quota::QuotaConfig;
use crate::storage::QuotaStorage;
use oxcache::backend::memory::DashMapMemoryBackend;
let storage: Arc<dyn QuotaStorage> = Arc::new(CacheQuotaStorage::new(Arc::new(
DashMapMemoryBackend::new(),
)));
let quota_controller = Arc::new(QuotaController::with_dependencies(
storage,
QuotaConfig::default(),
));
let governor = Arc::new(make_governor().await);
let state = make_state_with(
governor,
TestDeps {
quota_controller: Some(quota_controller),
..Default::default()
},
);
let req = UpdateQuotaRequest {
resource: "api".to_string(),
new_limit: 0,
duration_secs: Some(3600),
};
let resp = update_quota(State(state), Path("user-1".to_string()), Json(req)).await;
assert!(resp.1.0.success);
let data = resp.1.0.data.unwrap();
assert!(data.expires_at.is_some());
}
#[cfg(feature = "circuit-breaker")]
#[tokio::test]
async fn test_get_circuit_breaker_status_with_failures() {
use crate::circuit::{CircuitBreaker, CircuitBreakerConfig};
use crate::error::{LimiteronError, StorageError};
let cb = Arc::new(CircuitBreaker::with_dependencies(
CircuitBreakerConfig::default(),
));
let _ = cb
.execute(|| async {
Err::<(), LimiteronError>(LimiteronError::StorageError(
StorageError::ConnectionError("test".to_string()),
))
})
.await;
let governor = Arc::new(make_governor().await);
let state = make_state_with(
governor,
TestDeps {
circuit_breaker: Some(cb),
..Default::default()
},
);
let resp = get_circuit_breaker_status(State(state)).await;
assert!(resp.1.0.success);
let data = resp.1.0.data.unwrap();
assert!(data.failure_rate > 0.0);
}
fn t613_valid_config() -> crate::config::FlowControlConfig {
use crate::config::{Action, ActionConfig, LimiterConfig, Matcher, Rule};
crate::config::FlowControlConfig {
version: "1.0.1".to_string(),
global: crate::config::GlobalConfig::default(),
rules: vec![Rule {
id: "hot_rule".to_string(),
name: "Hot Rule".to_string(),
priority: 50,
matchers: vec![Matcher::User {
user_ids: vec!["*".to_string()],
}],
limiters: vec![LimiterConfig::TokenBucket {
capacity: 1000,
refill_rate: 100,
}],
action: ActionConfig {
on_exceed: Action::Reject,
ban: None,
},
}],
}
}
#[tokio::test]
async fn test_t613_apply_config_swaps_rules() {
let state = make_state().await;
let resp = apply_config(State(state.clone()), Json(t613_valid_config())).await;
assert_eq!(resp.0, StatusCode::OK, "{:?}", resp.1.0.message);
assert!(resp.1.0.success);
let data = resp.1.0.data.unwrap();
assert_eq!(data["applied"], serde_json::json!(true));
assert_eq!(data["new_version"], serde_json::json!("1.0.1"));
assert_eq!(data["rule_count"], serde_json::json!(1));
let snap = state.governor.introspect().await;
assert!(
snap.rules.iter().any(|r| r.id == "hot_rule"),
"hot-swapped rule must be visible in introspection"
);
let history = state.governor.get_config_history().await;
assert!(
history.iter().any(|rec| rec.new_version == "1.0.1"),
"apply_config must record config history"
);
}
#[tokio::test]
async fn test_t613_apply_config_rejects_invalid_and_rolls_back() {
let state = make_state().await;
let mut bad = t613_valid_config();
bad.rules.clear();
let resp = apply_config(State(state.clone()), Json(bad)).await;
assert_eq!(resp.0, StatusCode::BAD_REQUEST);
assert!(!resp.1.0.success);
let snap = state.governor.introspect().await;
assert!(
snap.rules.iter().any(|r| r.id == "test_rule"),
"invalid apply must keep old config (rollback semantics)"
);
}
#[tokio::test]
async fn test_t613_check_batch_decides_all_keys() {
let state = make_state().await;
let body = BatchCheckBody {
requests: vec![
BatchCheckItem {
user_id: Some("batch-user-1".to_string()),
ip: None,
path: Some("/api".to_string()),
method: Some("GET".to_string()),
},
BatchCheckItem {
user_id: Some("batch-user-2".to_string()),
ip: None,
path: Some("/api".to_string()),
method: Some("GET".to_string()),
},
],
};
let resp = check_batch(State(state), Json(body)).await;
assert_eq!(resp.0, StatusCode::OK);
assert!(resp.1.0.success);
let data = resp.1.0.data.unwrap();
let results = data["results"].as_array().unwrap();
assert_eq!(results.len(), 2, "one decision per key");
assert_eq!(results[0]["index"], serde_json::json!(0));
assert_eq!(results[1]["index"], serde_json::json!(1));
assert_eq!(results[0]["decision"], serde_json::json!("Allowed"));
}
#[tokio::test]
async fn test_t613_check_batch_empty_body_rejected() {
let state = make_state().await;
let body = BatchCheckBody { requests: vec![] };
let resp = check_batch(State(state), Json(body)).await;
assert_eq!(resp.0, StatusCode::BAD_REQUEST);
assert!(!resp.1.0.success);
}
#[tokio::test]
async fn test_t613_check_batch_over_limit_rejected() {
let state = make_state().await;
let body = BatchCheckBody {
requests: (0..=BATCH_MAX_ITEMS)
.map(|_| BatchCheckItem {
user_id: Some("u".to_string()),
ip: None,
path: None,
method: None,
})
.collect(),
};
let resp = check_batch(State(state), Json(body)).await;
assert_eq!(resp.0, StatusCode::BAD_REQUEST, "over-limit batch → 400");
}
#[tokio::test]
async fn test_t613_prefetch_tokens_grants_batch() {
let body = TokenPrefetchBody {
items: vec![
TokenPrefetchItem {
key: "pf-key-1".to_string(),
tokens: 5,
},
TokenPrefetchItem {
key: "pf-key-2".to_string(),
tokens: 5,
},
],
};
let resp = prefetch_tokens(Json(body)).await;
assert_eq!(resp.0, StatusCode::OK);
assert!(resp.1.0.success);
assert_eq!(resp.1.0.message, "2/2 granted");
let data = resp.1.0.data.unwrap();
let results = data.as_array().unwrap();
assert_eq!(results.len(), 2);
assert_eq!(results[0]["granted"], serde_json::json!(true));
}
#[tokio::test]
async fn test_t613_prefetch_tokens_empty_rejected() {
let body = TokenPrefetchBody { items: vec![] };
let resp = prefetch_tokens(Json(body)).await;
assert_eq!(resp.0, StatusCode::BAD_REQUEST);
}
}