use std::time::Duration;
use super::*;
fn at(limiter: &FixedWindowRateLimiter, offset: Duration) -> Instant {
limiter.base + offset
}
fn limiter(config: RateLimitConfig) -> FixedWindowRateLimiter {
FixedWindowRateLimiter::with_config(config)
}
#[test]
fn a_budget_is_spent_one_unit_per_allowed_attempt() {
let l = limiter(RateLimitConfig::default().with_device_user_code_budget(3, 0));
let now = at(&l, Duration::ZERO);
for i in 0..3 {
assert_eq!(
l.check_at(Attempt::DeviceUserCodeEntry, now),
RateLimitDecision::Allow,
"attempt {i} is inside the budget"
);
}
assert_eq!(
l.check_at(Attempt::DeviceUserCodeEntry, now),
RateLimitDecision::Deny
);
}
#[test]
fn failures_cost_ten_times_what_successes_cost() {
let successes = {
let l = limiter(RateLimitConfig::default());
let now = at(&l, Duration::ZERO);
let mut n = 0;
while l.check_at(Attempt::DeviceUserCodeEntry, now) == RateLimitDecision::Allow {
l.record_at(Attempt::DeviceUserCodeEntry, AttemptOutcome::Succeeded, now);
n += 1;
}
n
};
let failures = {
let l = limiter(RateLimitConfig::default());
let now = at(&l, Duration::ZERO);
let mut n = 0;
while l.check_at(Attempt::DeviceUserCodeEntry, now) == RateLimitDecision::Allow {
l.record_at(Attempt::DeviceUserCodeEntry, AttemptOutcome::Failed, now);
n += 1;
}
n
};
assert_eq!(
(successes, failures),
(DEFAULT_DEVICE_USER_CODE_CAPACITY, 20),
"the documented default is 200 correct entries a minute or 20 wrong ones"
);
}
#[test]
fn the_shipped_default_permits_twenty_wrong_user_codes_per_window() {
let cost_of_a_failure = ATTEMPT_COST + DEFAULT_DEVICE_USER_CODE_FAILURE_COST;
assert_eq!(
DEFAULT_DEVICE_USER_CODE_CAPACITY / cost_of_a_failure,
20,
"the module docs derive the 2^34.6 guessing odds from 20 wrong codes per 60s window"
);
let cost_of_a_failed_auth = ATTEMPT_COST + DEFAULT_CLIENT_AUTHENTICATION_FAILURE_COST;
assert_eq!(
DEFAULT_CLIENT_AUTHENTICATION_CAPACITY / cost_of_a_failed_auth,
30,
"the module docs derive the RFC 9700 s4.13 posture from 30 failed auths per client"
);
}
#[test]
fn a_successful_outcome_costs_nothing_beyond_the_attempt() {
let l = limiter(RateLimitConfig::default().with_device_user_code_budget(2, 1_000_000));
let now = at(&l, Duration::ZERO);
assert_eq!(
l.check_at(Attempt::DeviceUserCodeEntry, now),
RateLimitDecision::Allow
);
l.record_at(Attempt::DeviceUserCodeEntry, AttemptOutcome::Succeeded, now);
assert_eq!(
l.check_at(Attempt::DeviceUserCodeEntry, now),
RateLimitDecision::Allow,
"a success must not consume the failure penalty as well"
);
}
#[test]
fn the_budget_rolls_exactly_at_the_window_boundary() {
let window = Duration::from_secs(60);
let l = limiter(
RateLimitConfig::default()
.with_window(window)
.with_device_user_code_budget(1, 0),
);
assert_eq!(
l.check_at(Attempt::DeviceUserCodeEntry, at(&l, Duration::ZERO)),
RateLimitDecision::Allow
);
assert_eq!(
l.check_at(
Attempt::DeviceUserCodeEntry,
at(&l, window - Duration::from_nanos(1))
),
RateLimitDecision::Deny,
"one nanosecond before the boundary is still the same budget"
);
assert_eq!(
l.check_at(Attempt::DeviceUserCodeEntry, at(&l, window)),
RateLimitDecision::Allow,
"the boundary itself starts a fresh budget"
);
assert_eq!(
l.check_at(Attempt::DeviceUserCodeEntry, at(&l, window * 1_000)),
RateLimitDecision::Allow
);
}
#[test]
fn a_penalty_reported_after_the_roll_lands_in_the_new_window() {
let window = Duration::from_secs(60);
let l = limiter(
RateLimitConfig::default()
.with_window(window)
.with_device_user_code_budget(10, 10),
);
assert_eq!(
l.check_at(Attempt::DeviceUserCodeEntry, at(&l, Duration::ZERO)),
RateLimitDecision::Allow
);
l.record_at(
Attempt::DeviceUserCodeEntry,
AttemptOutcome::Failed,
at(&l, window),
);
assert_eq!(
l.check_at(Attempt::DeviceUserCodeEntry, at(&l, window)),
RateLimitDecision::Deny,
"the penalty was charged to the window it was reported in, not discarded"
);
}
#[test]
fn client_budgets_are_independent_of_each_other() {
let l = limiter(RateLimitConfig::default().with_client_authentication_budget(1, 0));
let now = at(&l, Duration::ZERO);
let a = Attempt::ClientAuthentication { client_id: "app-a" };
let b = Attempt::ClientAuthentication { client_id: "app-b" };
assert_eq!(l.check_at(a, now), RateLimitDecision::Allow);
assert_eq!(l.check_at(a, now), RateLimitDecision::Deny);
assert_eq!(
l.check_at(b, now),
RateLimitDecision::Allow,
"app-b's budget is its own"
);
}
#[test]
fn the_device_budget_is_shared_by_every_caller() {
let l = limiter(RateLimitConfig::default().with_device_user_code_budget(1, 0));
let now = at(&l, Duration::ZERO);
assert_eq!(
l.check_at(Attempt::DeviceUserCodeEntry, now),
RateLimitDecision::Allow
);
assert_eq!(
l.check_at(Attempt::DeviceUserCodeEntry, now),
RateLimitDecision::Deny
);
}
#[test]
fn the_tracked_client_map_never_exceeds_its_cap() {
let l = limiter(
RateLimitConfig::default()
.with_max_tracked_clients(4)
.with_client_authentication_budget(u64::MAX, 0),
);
let now = at(&l, Duration::ZERO);
for i in 0..10_000 {
let id = format!("sprayed-{i}");
l.check_at(Attempt::ClientAuthentication { client_id: &id }, now);
}
assert_eq!(l.tracked_clients(), 4);
}
#[test]
fn an_oversized_client_id_never_gets_an_entry_of_its_own() {
let l = limiter(RateLimitConfig::default());
let now = at(&l, Duration::ZERO);
let huge = "z".repeat(MAX_TRACKED_CLIENT_ID_LEN + 1);
let ok = "z".repeat(MAX_TRACKED_CLIENT_ID_LEN);
l.check_at(Attempt::ClientAuthentication { client_id: &huge }, now);
assert_eq!(l.tracked_clients(), 0, "too long to store");
l.check_at(Attempt::ClientAuthentication { client_id: &ok }, now);
assert_eq!(l.tracked_clients(), 1, "exactly at the cap is still stored");
}
#[test]
fn the_tracked_client_map_is_emptied_when_the_window_rolls() {
let window = Duration::from_secs(60);
let l = limiter(RateLimitConfig::default().with_window(window));
l.check_at(
Attempt::ClientAuthentication { client_id: "app-a" },
at(&l, Duration::ZERO),
);
assert_eq!(l.tracked_clients(), 1);
l.check_at(Attempt::DeviceUserCodeEntry, at(&l, window));
assert_eq!(
l.tracked_clients(),
0,
"the roll drops every key, which costs no information since every counter was being reset"
);
}
#[test]
fn identifiers_past_the_cap_share_one_budget_and_are_refused_together() {
let l = limiter(
RateLimitConfig::default()
.with_max_tracked_clients(1)
.with_client_authentication_budget(3, 0),
);
let now = at(&l, Duration::ZERO);
assert_eq!(
l.check_at(Attempt::ClientAuthentication { client_id: "first" }, now),
RateLimitDecision::Allow
);
for i in 0..3 {
let id = format!("overflow-{i}");
assert_eq!(
l.check_at(Attempt::ClientAuthentication { client_id: &id }, now),
RateLimitDecision::Allow,
"overflow attempt {i}"
);
}
assert_eq!(
l.check_at(
Attempt::ClientAuthentication {
client_id: "overflow-brand-new"
},
now
),
RateLimitDecision::Deny,
"the shared overflow budget is spent, so the spray throttles itself"
);
assert_eq!(
l.check_at(Attempt::ClientAuthentication { client_id: "first" }, now),
RateLimitDecision::Allow
);
}
#[test]
fn a_denied_flood_pins_the_counter_rather_than_overflowing_it() {
let l = limiter(RateLimitConfig::default().with_device_user_code_budget(1, u64::MAX));
let now = at(&l, Duration::ZERO);
assert_eq!(
l.check_at(Attempt::DeviceUserCodeEntry, now),
RateLimitDecision::Allow
);
for _ in 0..1_000 {
l.record_at(Attempt::DeviceUserCodeEntry, AttemptOutcome::Failed, now);
assert_eq!(
l.check_at(Attempt::DeviceUserCodeEntry, now),
RateLimitDecision::Deny
);
}
assert_eq!(l.lock().device_user_code, 1, "clamped at the capacity");
}
#[test]
fn a_zero_capacity_refuses_rather_than_admitting_everything() {
let l = limiter(RateLimitConfig::default().with_device_user_code_budget(0, 0));
assert_eq!(
l.check_at(Attempt::DeviceUserCodeEntry, at(&l, Duration::ZERO)),
RateLimitDecision::Deny
);
}
#[test]
fn a_zero_window_is_clamped_rather_than_dividing_by_zero() {
let l = limiter(RateLimitConfig::default().with_window(Duration::ZERO));
assert_eq!(l.config().window, MIN_WINDOW);
assert_eq!(l.window_index(at(&l, Duration::ZERO)), 0);
assert_eq!(l.window_index(at(&l, MIN_WINDOW)), 1);
let l = limiter(RateLimitConfig {
window: Duration::ZERO,
..RateLimitConfig::default()
});
assert_eq!(l.window_index(at(&l, MIN_WINDOW)), 1);
}
#[test]
fn the_window_index_saturates_rather_than_panicking() {
let mut l = limiter(RateLimitConfig::default());
assert_eq!(l.window_index(l.base), 0);
assert_eq!(l.window_index(at(&l, DEFAULT_WINDOW * 3)), 3);
let before_base = l.base;
l.base = before_base + DEFAULT_WINDOW * 3;
assert_eq!(
l.window_index(before_base),
0,
"an instant three windows BEFORE the base must land in window 0, not panic and not wrap \
to a far-future index that would hand out a fresh budget"
);
assert_eq!(
l.window_index(before_base + DEFAULT_WINDOW),
0,
"still before the base, so still window 0"
);
assert_eq!(
l.check_at(Attempt::DeviceUserCodeEntry, before_base),
RateLimitDecision::Allow
);
l.record_at(
Attempt::DeviceUserCodeEntry,
AttemptOutcome::Failed,
before_base,
);
}
#[test]
fn a_poisoned_lock_is_recovered_from_rather_than_propagated() {
let l = std::sync::Arc::new(limiter(RateLimitConfig::default()));
let poisoner = std::sync::Arc::clone(&l);
let _ = std::thread::spawn(move || {
let _guard = poisoner.lock();
panic!("poison the limiter's mutex");
})
.join();
assert_eq!(
l.check_at(Attempt::DeviceUserCodeEntry, at(&l, Duration::ZERO)),
RateLimitDecision::Allow,
"the limiter still answers after its mutex was poisoned"
);
}