use axum::{
Extension, Json,
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
use super::routes::OperatorIdentity;
use super::server::AppState;
#[cfg(feature = "ban-manager")]
use crate::ban::{BanFilter, BanTarget};
#[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<AppState>) -> 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 = "monitoring")]
pub async fn metrics(State(state): State<AppState>) -> 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 = "monitoring"))]
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<AppState>) -> Json<ApiResponse<SystemStatus>> {
let stats = state.governor.stats().await;
let blocked = stats
.rejected_requests
.saturating_add(stats.banned_requests);
let total = stats.total_requests;
let success_rate = if total > 0 {
(total.saturating_sub(blocked)) as f64 / total as f64
} else {
1.0
};
#[cfg(feature = "ban-manager")]
let active_bans: usize = if let Some(ref bm) = state.ban_manager {
bm.list_bans(BanFilter {
active_only: true,
..Default::default()
})
.await
.map(|v| v.len())
.unwrap_or(0)
} else {
0
};
#[cfg(feature = "circuit-breaker")]
let cb_state = if let Some(ref cb) = state.circuit_breaker {
cb.get_state().await.to_string()
} else {
"disabled".to_string()
};
Json(ApiResponse::ok(SystemStatus {
total_requests: total,
blocked_requests: blocked,
success_rate,
#[cfg(feature = "ban-manager")]
active_bans,
#[cfg(feature = "circuit-breaker")]
circuit_breaker: cb_state,
}))
}
pub async fn introspect(State(state): State<AppState>) -> Json<serde_json::Value> {
let snapshot = state.governor.introspect().await;
let mut body = serde_json::to_value(&snapshot).unwrap_or_else(|_| serde_json::json!({}));
#[cfg(feature = "ban-manager")]
if let Some(ref bm) = state.ban_manager {
if let Ok(bans) = bm
.list_bans(BanFilter {
active_only: true,
..Default::default()
})
.await
{
let items: Vec<serde_json::Value> = bans
.iter()
.map(|b| {
serde_json::json!({
"target": b.target,
"ban_times": b.ban_times,
"is_manual": b.is_manual,
"reason": b.reason,
"expires_at": b.expires_at.to_rfc3339(),
})
})
.collect();
body["active_bans"] = serde_json::Value::Array(items);
}
}
#[cfg(feature = "circuit-breaker")]
if let Some(ref cb) = state.circuit_breaker {
body["circuit_breaker_state"] = serde_json::json!(cb.get_state().await.to_string());
}
Json(body)
}
const BATCH_MAX_ITEMS: usize = 1000;
pub async fn apply_config(
State(state): State<AppState>,
Json(config): Json<crate::config::FlowControlConfig>,
) -> (StatusCode, Json<ApiResponse<serde_json::Value>>) {
match state.governor.apply_config(config).await {
Ok(report) => {
let data = serde_json::to_value(&report).unwrap_or_else(|_| serde_json::json!({}));
(
StatusCode::OK,
Json(ApiResponse {
success: true,
message: "config applied".to_string(),
data: Some(data),
}),
)
}
Err(e) => (
StatusCode::BAD_REQUEST,
Json(ApiResponse::error(format!("config rejected: {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<AppState>,
Json(body): Json<BatchCheckBody>,
) -> (StatusCode, Json<ApiResponse<serde_json::Value>>) {
if body.requests.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(ApiResponse::error(
"batch check requires at least one request",
)),
);
}
if body.requests.len() > BATCH_MAX_ITEMS {
return (
StatusCode::BAD_REQUEST,
Json(ApiResponse::error(format!(
"batch check limited to {BATCH_MAX_ITEMS} requests per call"
))),
);
}
let mut results = Vec::with_capacity(body.requests.len());
for (index, item) in body.requests.into_iter().enumerate() {
let mut ctx = crate::matchers::RequestContext::new();
if let Some(user_id) = item.user_id {
ctx.headers.insert("x-user-id".to_string(), user_id);
}
ctx.ip = item.ip.clone();
ctx.client_ip = item.ip;
ctx.path = item.path.unwrap_or_default();
ctx.method = item.method.unwrap_or_else(|| "GET".to_string());
match state.governor.check(&ctx).await {
Ok(decision) => {
let (kind, allowed) = match &decision {
crate::error::Decision::Allowed(_) => ("Allowed", true),
crate::error::Decision::Rejected(_) => ("Rejected", false),
crate::error::Decision::Banned(_) => ("Banned", false),
};
results.push(serde_json::json!({
"index": index,
"allowed": allowed,
"decision": kind,
}));
}
Err(e) => {
results.push(serde_json::json!({
"index": index,
"allowed": false,
"decision": "Error",
"error": e.to_string(),
}));
}
}
}
let allowed_count = results.iter().filter(|r| r["allowed"] == true).count();
(
StatusCode::OK,
Json(ApiResponse {
success: true,
message: format!("{allowed_count}/{} allowed", results.len()),
data: Some(serde_json::json!({ "results": results })),
}),
)
}
#[derive(Deserialize)]
pub struct TokenPrefetchItem {
pub key: String,
pub tokens: u64,
}
#[derive(Deserialize)]
pub struct TokenPrefetchBody {
pub items: Vec<TokenPrefetchItem>,
}
static TOKEN_PREFETCHER: std::sync::OnceLock<crate::limiters::BatchTokenPrefetcher> =
std::sync::OnceLock::new();
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>>) {
if body.items.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(ApiResponse::error(
"token prefetch requires at least one item",
)),
);
}
if body.items.len() > BATCH_MAX_ITEMS {
return (
StatusCode::BAD_REQUEST,
Json(ApiResponse::error(format!(
"token prefetch limited to {BATCH_MAX_ITEMS} items per call"
))),
);
}
let pairs: Vec<(String, u64)> = body.items.into_iter().map(|i| (i.key, i.tokens)).collect();
let results = token_prefetcher().prefetch_batch(&pairs).await;
let granted_count = results.iter().filter(|r| r.granted).count();
(
StatusCode::OK,
Json(ApiResponse {
success: true,
message: format!("{granted_count}/{} granted", results.len()),
data: Some(serde_json::to_value(&results).unwrap_or_else(|_| serde_json::json!([]))),
}),
)
}
#[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<AppState>,
Extension(operator): Extension<OperatorIdentity>,
Path(target): Path<String>,
Query(query): Query<BanTargetQuery>,
Json(req): Json<UnbanRequest>,
) -> (StatusCode, Json<ApiResponse<()>>) {
let Some(ref ban_manager) = state.ban_manager else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiResponse::error("Ban manager not configured")),
);
};
let ban_target = match query.target_type.as_deref() {
Some("ip") => BanTarget::Ip(target),
Some("user") => BanTarget::UserId(target),
Some("mac") => BanTarget::Mac(target),
Some("geo") => BanTarget::Geo {
country_code: target,
},
Some("cidr") => BanTarget::Cidr(target),
Some(other) => {
return (
StatusCode::BAD_REQUEST,
Json(ApiResponse::error(format!(
"unsupported target type: {}",
other
))),
);
}
None => {
if target.parse::<std::net::IpAddr>().is_ok() {
BanTarget::Ip(target)
} else {
BanTarget::UserId(target)
}
}
};
match ban_manager.delete_ban(&ban_target, operator.0).await {
Ok(true) => (
StatusCode::OK,
Json(ApiResponse {
success: true,
message: req.reason.unwrap_or_else(|| "Ban removed".to_string()),
data: Some(()),
}),
),
Ok(false) => (
StatusCode::NOT_FOUND,
Json(ApiResponse::error("Ban not found")),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiResponse::error(format!("Failed to remove ban: {}", 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("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<AppState>,
Extension(operator): Extension<OperatorIdentity>,
Json(req): Json<CreateBanRequest>,
) -> (StatusCode, Json<ApiResponse<BanResponse>>) {
use crate::ban::BanSource;
use std::time::Duration;
let Some(ref ban_manager) = state.ban_manager else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiResponse::error("Ban manager not configured")),
);
};
let source = BanSource::Manual {
operator: operator.0,
};
let duration = req.duration_secs.map(Duration::from_secs);
match ban_manager
.create_ban(
req.target,
req.reason,
source,
serde_json::json!({"source": "http-api"}),
duration,
)
.await
{
Ok(detail) => (
StatusCode::CREATED,
Json(ApiResponse::ok(BanResponse {
id: detail.id,
ban_times: detail.ban_times,
expires_at: detail.expires_at.timestamp(),
is_manual: detail.is_manual,
})),
),
Err(e) => {
let status = match &e {
crate::error::LimiteronError::ValidationError(_) => StatusCode::BAD_REQUEST,
crate::error::LimiteronError::AuthorizationError(_) => StatusCode::FORBIDDEN,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, Json(ApiResponse::error(format!("{}", e))))
}
}
}
#[cfg(not(feature = "ban-manager"))]
pub async fn create_ban() -> (StatusCode, Json<ApiResponse<()>>) {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiResponse::error("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<AppState>,
Path(tenant_id): Path<String>,
Json(req): Json<UpdateQuotaRequest>,
) -> (StatusCode, Json<ApiResponse<UpdateQuotaResponse>>) {
let Some(ref quota_controller) = state.quota_controller else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiResponse::error("Quota controller not configured")),
);
};
if req.new_limit == 0 {
match quota_controller
.reset_quota(&tenant_id, &req.resource)
.await
{
Ok(_) => (
StatusCode::OK,
Json(ApiResponse::ok(UpdateQuotaResponse {
success: true,
expires_at: req.duration_secs.map(|d| {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|t| t.as_secs() + d)
.unwrap_or(0)
}),
})),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiResponse::error(format!("Failed to reset quota: {}", e))),
),
}
} else {
(
StatusCode::BAD_REQUEST,
Json(ApiResponse::error(
"Per-tenant quota limit update is not supported; use global QuotaConfig update_config instead",
)),
)
}
}
#[cfg(not(feature = "quota-control"))]
pub async fn update_quota(
Path(_tenant_id): Path<String>,
) -> (StatusCode, Json<ApiResponse<UpdateQuotaResponse>>) {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiResponse::error("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<AppState>,
) -> (StatusCode, Json<ApiResponse<CircuitBreakerStatus>>) {
if let Some(ref cb) = state.circuit_breaker {
let stats = cb.get_stats().await;
let total = stats.total_calls as f64;
let failure_rate = if total > 0.0 {
stats.failure_count as f64 / total
} else {
0.0
};
(
StatusCode::OK,
Json(ApiResponse::ok(CircuitBreakerStatus {
state: stats.state.to_string(),
failure_rate,
slow_call_rate: 0.0,
})),
)
} else {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiResponse::error("Circuit breaker not configured")),
)
}
}
#[cfg(not(feature = "circuit-breaker"))]
pub async fn get_circuit_breaker_status() -> (StatusCode, Json<ApiResponse<()>>) {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiResponse::error("Circuit breaker not configured")),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::admin::AppState;
use crate::admin::{make_governor, make_state};
use std::sync::Arc;
#[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 = AppState {
governor,
ban_manager: Some(ban_manager),
#[cfg(feature = "quota-control")]
quota_controller: None,
#[cfg(feature = "circuit-breaker")]
circuit_breaker: None,
#[cfg(feature = "monitoring")]
metrics: None,
};
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 = AppState {
governor,
ban_manager: Some(ban_manager),
#[cfg(feature = "quota-control")]
quota_controller: None,
#[cfg(feature = "circuit-breaker")]
circuit_breaker: None,
#[cfg(feature = "monitoring")]
metrics: None,
};
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("tenant-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 = AppState {
governor,
#[cfg(feature = "ban-manager")]
ban_manager: None,
quota_controller: Some(quota_controller),
#[cfg(feature = "circuit-breaker")]
circuit_breaker: None,
#[cfg(feature = "monitoring")]
metrics: None,
};
let req = UpdateQuotaRequest {
resource: "api".to_string(),
new_limit: 100,
duration_secs: None,
};
let resp = update_quota(State(state), Path("tenant-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 = AppState {
governor,
#[cfg(feature = "ban-manager")]
ban_manager: None,
#[cfg(feature = "quota-control")]
quota_controller: None,
circuit_breaker: Some(cb),
#[cfg(feature = "monitoring")]
metrics: None,
};
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 = AppState {
governor,
ban_manager: Some(ban_manager),
#[cfg(feature = "quota-control")]
quota_controller: None,
circuit_breaker: Some(cb),
#[cfg(feature = "monitoring")]
metrics: None,
};
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 = AppState {
governor,
ban_manager: Some(ban_manager),
#[cfg(feature = "quota-control")]
quota_controller: None,
#[cfg(feature = "circuit-breaker")]
circuit_breaker: None,
#[cfg(feature = "monitoring")]
metrics: None,
};
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 = AppState {
governor,
ban_manager: Some(ban_manager),
#[cfg(feature = "quota-control")]
quota_controller: None,
#[cfg(feature = "circuit-breaker")]
circuit_breaker: None,
#[cfg(feature = "monitoring")]
metrics: None,
};
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 = AppState {
governor,
ban_manager: Some(ban_manager),
#[cfg(feature = "quota-control")]
quota_controller: None,
#[cfg(feature = "circuit-breaker")]
circuit_breaker: None,
#[cfg(feature = "monitoring")]
metrics: None,
};
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 = AppState {
governor,
ban_manager: Some(ban_manager),
#[cfg(feature = "quota-control")]
quota_controller: None,
#[cfg(feature = "circuit-breaker")]
circuit_breaker: None,
#[cfg(feature = "monitoring")]
metrics: None,
};
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 = AppState {
governor,
ban_manager: Some(ban_manager),
#[cfg(feature = "quota-control")]
quota_controller: None,
#[cfg(feature = "circuit-breaker")]
circuit_breaker: None,
#[cfg(feature = "monitoring")]
metrics: None,
};
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 = AppState {
governor,
#[cfg(feature = "ban-manager")]
ban_manager: None,
quota_controller: Some(quota_controller),
#[cfg(feature = "circuit-breaker")]
circuit_breaker: None,
#[cfg(feature = "monitoring")]
metrics: None,
};
let req = UpdateQuotaRequest {
resource: "api".to_string(),
new_limit: 0,
duration_secs: Some(3600),
};
let resp = update_quota(State(state), Path("tenant-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 = AppState {
governor,
#[cfg(feature = "ban-manager")]
ban_manager: None,
#[cfg(feature = "quota-control")]
quota_controller: None,
circuit_breaker: Some(cb),
#[cfg(feature = "monitoring")]
metrics: None,
};
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);
}
}