mirror of https://github.com/synctv-org/synctv
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
266 lines
8.1 KiB
Rust
266 lines
8.1 KiB
Rust
//! Rate limiter tests
|
|
//!
|
|
//! Tests in-memory rate limiter behavior and Redis rate limiter with testcontainers.
|
|
//!
|
|
//! Run Docker tests: cargo test --test `rate_limiter_tests` -- --ignored
|
|
|
|
use std::sync::Arc;
|
|
use synctv_core::service::{RateLimitError, RateLimiter};
|
|
use synctv_core_testing::{ok, start_redis_client_manager, RedisContainer};
|
|
use tokio::sync::RwLock;
|
|
|
|
// In-memory rate limiter tests
|
|
|
|
#[tokio::test]
|
|
async fn test_in_memory_rate_limiter_allows_under_limit() {
|
|
let limiter = RateLimiter::local_only("test_allow:".to_string());
|
|
|
|
for i in 0..5 {
|
|
ok(
|
|
limiter.check_rate_limit("user:1:chat", 10, 1).await,
|
|
&format!("request {i} should succeed under limit"),
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_in_memory_rate_limiter_blocks_over_limit() {
|
|
let limiter = RateLimiter::local_only("test_block:".to_string());
|
|
let key = "user:block:chat";
|
|
|
|
// Exhaust the limit
|
|
for i in 0..5 {
|
|
ok(
|
|
limiter.check_rate_limit(key, 5, 1).await,
|
|
&format!("request {i} should succeed within limit"),
|
|
);
|
|
}
|
|
|
|
// Next request should be blocked
|
|
let result = limiter.check_rate_limit(key, 5, 1).await;
|
|
assert!(
|
|
matches!(result, Err(RateLimitError::RateLimitExceeded { .. })),
|
|
"6th request should be rate limited"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_in_memory_rate_limiter_window_expiry() {
|
|
let limiter = RateLimiter::local_only("test_expiry:".to_string());
|
|
let key = "user:expiry:chat";
|
|
|
|
// Exhaust the limit
|
|
for _ in 0..3 {
|
|
ok(
|
|
limiter.check_rate_limit(key, 3, 1).await,
|
|
"request should succeed before window is exhausted",
|
|
);
|
|
}
|
|
assert!(limiter.check_rate_limit(key, 3, 1).await.is_err());
|
|
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(1200)).await;
|
|
|
|
let result = limiter.check_rate_limit(key, 3, 1).await;
|
|
assert!(
|
|
result.is_ok(),
|
|
"Requests should succeed after window expiry"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_in_memory_independent_keys() {
|
|
let limiter = RateLimiter::local_only("test_indep:".to_string());
|
|
|
|
// Exhaust key1
|
|
for _ in 0..5 {
|
|
ok(
|
|
limiter.check_rate_limit("key1", 5, 1).await,
|
|
"key1 request should succeed before limit is exhausted",
|
|
);
|
|
}
|
|
assert!(limiter.check_rate_limit("key1", 5, 1).await.is_err());
|
|
|
|
// key2 should still work
|
|
assert!(limiter.check_rate_limit("key2", 5, 1).await.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_in_memory_sync_check() {
|
|
let limiter = RateLimiter::local_only("sync_test:".to_string());
|
|
|
|
for _ in 0..5 {
|
|
ok(
|
|
limiter.check_rate_limit_sync("key", 5, 1),
|
|
"sync request should succeed before limit is exhausted",
|
|
);
|
|
}
|
|
assert!(matches!(
|
|
limiter.check_rate_limit_sync("key", 5, 1),
|
|
Err(RateLimitError::RateLimitExceeded { .. })
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_in_memory_distributed_uses_governor() {
|
|
let limiter = RateLimiter::local_only("dist:".to_string());
|
|
|
|
// Without Redis, the async check should work using in-memory governor.
|
|
// Note: check_rate_limit_distributed (check_strict) intentionally fails
|
|
// closed without Redis. Use check_rate_limit (check) for in-memory fallback.
|
|
for i in 0..5 {
|
|
ok(
|
|
limiter.check_rate_limit("key", 5, 1).await,
|
|
&format!("request {i} should succeed via in-memory governor"),
|
|
);
|
|
}
|
|
|
|
let result = limiter.check_rate_limit("key", 5, 1).await;
|
|
assert!(
|
|
matches!(result, Err(RateLimitError::RateLimitExceeded { .. })),
|
|
"Request exceeding limit should be blocked via in-memory governor"
|
|
);
|
|
}
|
|
|
|
// Redis rate limiter tests (require Docker)
|
|
|
|
async fn create_redis_connection_manager() -> (redis::aio::ConnectionManager, RedisContainer) {
|
|
let (container, _client, conn) = start_redis_client_manager().await;
|
|
(conn, container)
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_redis_rate_limiter_allows_under_limit() {
|
|
let (conn, _container) = create_redis_connection_manager().await;
|
|
let limiter = RateLimiter::from_redis_runtime(
|
|
synctv_core::shared_runtime_from_conn(Some(Arc::new(RwLock::new(conn)))),
|
|
"redis_allow:".to_string(),
|
|
);
|
|
|
|
let key = "user:redis_allow:chat";
|
|
ok(
|
|
limiter.reset(key).await,
|
|
"Redis rate limit key should reset",
|
|
);
|
|
|
|
for i in 0..10 {
|
|
ok(
|
|
limiter.check_rate_limit(key, 10, 1).await,
|
|
&format!("Redis request {i} should succeed under limit"),
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_redis_rate_limiter_blocks_over_limit() {
|
|
let (conn, _container) = create_redis_connection_manager().await;
|
|
let limiter = RateLimiter::from_redis_runtime(
|
|
synctv_core::shared_runtime_from_conn(Some(Arc::new(RwLock::new(conn)))),
|
|
"redis_block:".to_string(),
|
|
);
|
|
|
|
let key = "user:redis_block:chat";
|
|
ok(
|
|
limiter.reset(key).await,
|
|
"Redis rate limit key should reset",
|
|
);
|
|
|
|
for i in 0..5 {
|
|
ok(
|
|
limiter.check_rate_limit(key, 5, 1).await,
|
|
&format!("Redis request {i} should succeed within limit"),
|
|
);
|
|
}
|
|
|
|
let result = limiter.check_rate_limit(key, 5, 1).await;
|
|
assert!(
|
|
matches!(result, Err(RateLimitError::RateLimitExceeded { .. })),
|
|
"6th request should be rate limited via Redis"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_redis_rate_limiter_concurrent_requests() {
|
|
let (conn, _container) = create_redis_connection_manager().await;
|
|
let limiter = RateLimiter::from_redis_runtime(
|
|
synctv_core::shared_runtime_from_conn(Some(Arc::new(RwLock::new(conn)))),
|
|
"redis_conc:".to_string(),
|
|
);
|
|
|
|
let key = "user:redis_conc:chat";
|
|
ok(
|
|
limiter.reset(key).await,
|
|
"Redis rate limit key should reset",
|
|
);
|
|
|
|
// Launch 20 concurrent requests with a limit of 10
|
|
let mut handles = Vec::new();
|
|
for _ in 0..20 {
|
|
let l = limiter.clone();
|
|
handles.push(tokio::spawn(
|
|
async move { l.check_rate_limit(key, 10, 1).await },
|
|
));
|
|
}
|
|
|
|
let results: Vec<_> = futures::future::join_all(handles)
|
|
.await
|
|
.into_iter()
|
|
.map(|r| ok(r, "concurrent rate-limit task should join"))
|
|
.collect();
|
|
|
|
let successes = results.iter().filter(|r| r.is_ok()).count();
|
|
let failures = results.iter().filter(|r| r.is_err()).count();
|
|
|
|
assert_eq!(
|
|
successes, 10,
|
|
"Only 10 of 20 concurrent requests should succeed"
|
|
);
|
|
assert_eq!(failures, 10, "10 requests should be rate limited");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_redis_rate_limiter_strict_enforcement() {
|
|
let (conn, _container) = create_redis_connection_manager().await;
|
|
let limiter = RateLimiter::from_redis_runtime(
|
|
synctv_core::shared_runtime_from_conn(Some(Arc::new(RwLock::new(conn)))),
|
|
"redis_strict:".to_string(),
|
|
);
|
|
|
|
let key = "user:redis_strict:auth";
|
|
ok(
|
|
limiter.reset(key).await,
|
|
"Redis rate limit key should reset",
|
|
);
|
|
|
|
// Strict check should work within limits
|
|
for i in 0..5 {
|
|
ok(
|
|
limiter.check_rate_limit_distributed(key, 5, 1).await,
|
|
&format!("strict request {i} should succeed within limit"),
|
|
);
|
|
}
|
|
|
|
let result = limiter.check_rate_limit_distributed(key, 5, 1).await;
|
|
assert!(
|
|
matches!(result, Err(RateLimitError::RateLimitExceeded { .. })),
|
|
"6th strict request should be rate limited via Redis"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_redis_rate_limiter_distributed_fails_closed_without_redis() {
|
|
// In-memory-only limiter should fail closed for distributed checks
|
|
// when Redis is not configured (security-critical operations)
|
|
let limiter = RateLimiter::local_only("redis_fc:".to_string());
|
|
|
|
// Should deny ALL requests when Redis is not configured (fail-closed)
|
|
let result = limiter.check_rate_limit_distributed("key", 10, 1).await;
|
|
assert!(
|
|
matches!(result, Err(RateLimitError::BackendUnavailable(_))),
|
|
"check_rate_limit_distributed should fail closed when Redis is not configured"
|
|
);
|
|
}
|