Fixes in tests

Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
Alexey 2026-03-23 16:39:49 +03:00
parent 2d69b9d0ae
commit 8cfaab9320
No known key found for this signature in database
3 changed files with 93 additions and 7 deletions

View File

@ -669,3 +669,7 @@ mod relay_quota_extended_attack_surface_security_tests;
#[cfg(test)] #[cfg(test)]
#[path = "tests/relay_watchdog_delta_security_tests.rs"] #[path = "tests/relay_watchdog_delta_security_tests.rs"]
mod relay_watchdog_delta_security_tests; mod relay_watchdog_delta_security_tests;
#[cfg(test)]
#[path = "tests/relay_atomic_quota_invariant_tests.rs"]
mod relay_atomic_quota_invariant_tests;

View File

@ -29,6 +29,11 @@ async fn read_available<R: AsyncRead + Unpin>(reader: &mut R, budget: Duration)
total total
} }
fn preload_user_quota(stats: &Stats, user: &str, bytes: u64) {
let user_stats = stats.get_or_create_user_stats_handle(user);
stats.quota_charge_post_write(user_stats.as_ref(), bytes);
}
#[tokio::test] #[tokio::test]
async fn integration_full_duplex_exact_budget_then_hard_cutoff() { async fn integration_full_duplex_exact_budget_then_hard_cutoff() {
let stats = Arc::new(Stats::new()); let stats = Arc::new(Stats::new());
@ -102,14 +107,14 @@ async fn integration_full_duplex_exact_budget_then_hard_cutoff() {
relay_result, relay_result,
Err(ProxyError::DataQuotaExceeded { ref user }) if user == "quota-full-duplex-boundary-user" Err(ProxyError::DataQuotaExceeded { ref user }) if user == "quota-full-duplex-boundary-user"
)); ));
assert!(stats.get_user_total_octets(user) <= 10); assert!(stats.get_user_quota_used(user) <= 10);
} }
#[tokio::test] #[tokio::test]
async fn negative_preloaded_quota_blocks_both_directions_immediately() { async fn negative_preloaded_quota_blocks_both_directions_immediately() {
let stats = Arc::new(Stats::new()); let stats = Arc::new(Stats::new());
let user = "quota-preloaded-cutoff-user"; let user = "quota-preloaded-cutoff-user";
stats.add_user_octets_from(user, 5); preload_user_quota(stats.as_ref(), user, 5);
let (mut client_peer, relay_client) = duplex(2048); let (mut client_peer, relay_client) = duplex(2048);
let (relay_server, mut server_peer) = duplex(2048); let (relay_server, mut server_peer) = duplex(2048);
@ -154,7 +159,7 @@ async fn negative_preloaded_quota_blocks_both_directions_immediately() {
relay_result, relay_result,
Err(ProxyError::DataQuotaExceeded { .. }) Err(ProxyError::DataQuotaExceeded { .. })
)); ));
assert!(stats.get_user_total_octets(user) <= 5); assert!(stats.get_user_quota_used(user) <= 5);
} }
#[tokio::test] #[tokio::test]
@ -212,7 +217,7 @@ async fn edge_quota_one_bidirectional_race_allows_at_most_one_forwarded_octet()
relay_result, relay_result,
Err(ProxyError::DataQuotaExceeded { .. }) Err(ProxyError::DataQuotaExceeded { .. })
)); ));
assert!(stats.get_user_total_octets(user) <= 1); assert!(stats.get_user_quota_used(user) <= 1);
} }
#[tokio::test] #[tokio::test]
@ -277,7 +282,7 @@ async fn adversarial_blackhat_alternating_fragmented_jitter_never_overshoots_glo
delivered_to_server + delivered_to_client <= quota as usize, delivered_to_server + delivered_to_client <= quota as usize,
"combined forwarded bytes must never exceed configured quota" "combined forwarded bytes must never exceed configured quota"
); );
assert!(stats.get_user_total_octets(user) <= quota); assert!(stats.get_user_quota_used(user) <= quota);
} }
#[tokio::test] #[tokio::test]
@ -356,7 +361,7 @@ async fn light_fuzz_randomized_schedule_preserves_quota_and_forwarded_byte_invar
"fuzz case {case}: forwarded bytes must not exceed quota" "fuzz case {case}: forwarded bytes must not exceed quota"
); );
assert!( assert!(
stats.get_user_total_octets(&user) <= quota, stats.get_user_quota_used(&user) <= quota,
"fuzz case {case}: accounted bytes must not exceed quota" "fuzz case {case}: accounted bytes must not exceed quota"
); );
} }
@ -451,7 +456,7 @@ async fn stress_multi_relay_same_user_mixed_direction_jitter_respects_global_quo
} }
assert!( assert!(
stats.get_user_total_octets(user) <= quota, stats.get_user_quota_used(user) <= quota,
"global per-user quota must hold under concurrent mixed-direction relay stress" "global per-user quota must hold under concurrent mixed-direction relay stress"
); );
assert!( assert!(

View File

@ -2580,6 +2580,56 @@ mod tests {
assert_eq!(user_stats.quota_used(), limit); assert_eq!(user_stats.quota_used(), limit);
} }
#[test]
fn test_quota_reserve_200x_1k_reaches_100k_without_overshoot() {
let user_stats = Arc::new(UserStats::default());
let successes = Arc::new(AtomicU64::new(0));
let failures = Arc::new(AtomicU64::new(0));
let attempts = 200usize;
let reserve_bytes = 1_024u64;
let limit = 100 * 1_024u64;
let mut workers = Vec::with_capacity(attempts);
for _ in 0..attempts {
let user_stats = user_stats.clone();
let successes = successes.clone();
let failures = failures.clone();
workers.push(std::thread::spawn(move || {
loop {
match user_stats.quota_try_reserve(reserve_bytes, limit) {
Ok(_) => {
successes.fetch_add(1, Ordering::Relaxed);
return;
}
Err(QuotaReserveError::LimitExceeded) => {
failures.fetch_add(1, Ordering::Relaxed);
return;
}
Err(QuotaReserveError::Contended) => {
std::hint::spin_loop();
}
}
}
}));
}
for worker in workers {
worker.join().expect("reservation worker must finish");
}
assert_eq!(
successes.load(Ordering::Relaxed),
100,
"exactly 100 reservations of 1 KiB must fit into a 100 KiB quota"
);
assert_eq!(
failures.load(Ordering::Relaxed),
100,
"remaining workers must fail once quota is fully reserved"
);
assert_eq!(user_stats.quota_used(), limit);
}
#[test] #[test]
fn test_quota_used_is_authoritative_and_independent_from_octets_telemetry() { fn test_quota_used_is_authoritative_and_independent_from_octets_telemetry() {
let stats = Stats::new(); let stats = Stats::new();
@ -2594,6 +2644,33 @@ mod tests {
assert_eq!(stats.get_user_total_octets(user), 5); assert_eq!(stats.get_user_total_octets(user), 5);
assert_eq!(stats.get_user_quota_used(user), 7); assert_eq!(stats.get_user_quota_used(user), 7);
} }
#[test]
fn test_cached_handle_survives_map_cleanup_until_last_drop() {
let stats = Stats::new();
let user = "quota-handle-lifetime-user";
let user_stats = stats.get_or_create_user_stats_handle(user);
let weak = Arc::downgrade(&user_stats);
stats.user_stats.remove(user);
assert!(
stats.user_stats.get(user).is_none(),
"map cleanup should remove idle entry"
);
assert!(
weak.upgrade().is_some(),
"cached handle must keep user stats object alive after map removal"
);
stats.quota_charge_post_write(user_stats.as_ref(), 3);
assert_eq!(user_stats.quota_used(), 3);
drop(user_stats);
assert!(
weak.upgrade().is_none(),
"user stats object must be dropped after the last cached handle is released"
);
}
} }
#[cfg(test)] #[cfg(test)]