#![allow(clippy::unwrap_used)]
use fraiseql_auth::{AuthRateLimitConfig, KeyedRateLimiter};
const fn high_limit_config() -> AuthRateLimitConfig {
AuthRateLimitConfig {
enabled: true,
max_requests: 1_000_000,
window_secs: 3600,
}
}
#[test]
fn test_rate_limiter_handles_many_unique_keys() {
let limiter = KeyedRateLimiter::new(high_limit_config());
for i in 0..100_000 {
let key = format!("ip-{i}");
limiter.check(&key).unwrap();
}
assert_eq!(limiter.active_limiters(), 100_000);
}
#[test]
fn test_rate_limiter_memory_bounded_by_entry_count() {
let limiter = KeyedRateLimiter::new(high_limit_config());
let n = 10_000;
for i in 0..n {
limiter.check(&format!("user-{i}")).unwrap();
}
assert_eq!(limiter.active_limiters(), n);
}
#[test]
fn test_rate_limiter_cap_evicts_oldest_when_full() {
let limiter = KeyedRateLimiter::with_max_entries(high_limit_config(), 3);
limiter.check("ip-1").unwrap();
limiter.check("ip-2").unwrap();
limiter.check("ip-3").unwrap();
assert_eq!(limiter.active_limiters(), 3);
limiter.check("ip-4").expect("should succeed via eviction");
assert_eq!(limiter.active_limiters(), 3);
limiter.check("ip-1").expect("evicted key can re-enter");
}
#[test]
fn test_rate_limiter_cap_zero_disables_limit() {
let limiter = KeyedRateLimiter::with_max_entries(high_limit_config(), 0);
for i in 0..10_000 {
limiter.check(&format!("ip-{i}")).unwrap();
}
assert_eq!(limiter.active_limiters(), 10_000);
}
#[test]
fn test_rate_limiter_disabled_skips_tracking() {
let config = AuthRateLimitConfig {
enabled: false,
max_requests: 10,
window_secs: 60,
};
let limiter = KeyedRateLimiter::new(config);
for i in 0..10_000 {
limiter.check(&format!("key-{i}")).unwrap();
}
assert_eq!(limiter.active_limiters(), 0);
}
#[test]
fn test_rate_limiter_clear_reclaims_memory() {
let limiter = KeyedRateLimiter::new(high_limit_config());
for i in 0..5_000 {
limiter.check(&format!("ip-{i}")).unwrap();
}
assert_eq!(limiter.active_limiters(), 5_000);
limiter.clear();
assert_eq!(limiter.active_limiters(), 0);
}
#[test]
fn test_rate_limiter_strict_cap_on_sequential_overflow() {
let cap = 5;
let limiter = KeyedRateLimiter::with_max_entries(high_limit_config(), cap);
for i in 0..cap {
limiter.check(&format!("key-{i}")).unwrap();
}
assert_eq!(limiter.active_limiters(), cap, "limiter must fill to cap");
limiter.check("key-overflow").unwrap();
assert_eq!(
limiter.active_limiters(),
cap,
"len must never exceed max_entries on the insert path"
);
limiter.check("key-0").unwrap();
assert_eq!(limiter.active_limiters(), cap, "still at cap after re-insert");
}
#[test]
fn test_rate_limiter_strict_cap_under_concurrent_burst() {
use std::{
sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
},
thread,
};
let cap = 32;
let limiter = Arc::new(KeyedRateLimiter::with_max_entries(high_limit_config(), cap));
let stop = Arc::new(AtomicBool::new(false));
let observed_max = Arc::new(AtomicUsize::new(0));
let sampler_limiter = Arc::clone(&limiter);
let sampler_stop = Arc::clone(&stop);
let sampler_max = Arc::clone(&observed_max);
let sampler = thread::spawn(move || {
while !sampler_stop.load(Ordering::Relaxed) {
let len = sampler_limiter.active_limiters();
let mut cur = sampler_max.load(Ordering::Relaxed);
while len > cur {
match sampler_max.compare_exchange_weak(
cur,
len,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => break,
Err(actual) => cur = actual,
}
}
}
});
let threads = 8;
let inserts_per_thread = (cap + 100).div_ceil(threads);
let mut writers = Vec::new();
for t in 0..threads {
let writer_limiter = Arc::clone(&limiter);
writers.push(thread::spawn(move || {
for i in 0..inserts_per_thread {
let key = format!("t{t}-k{i}");
writer_limiter.check(&key).unwrap();
}
}));
}
for w in writers {
w.join().unwrap();
}
stop.store(true, Ordering::Relaxed);
sampler.join().unwrap();
assert_eq!(
limiter.active_limiters(),
cap,
"post-burst len must equal cap (eviction kicked in for every overflow insert)"
);
let high_water = observed_max.load(Ordering::Relaxed);
assert!(
high_water <= cap,
"active_limiters() observed {high_water} mid-burst but cap is {cap} — \
strict-cap invariant violated"
);
}