Round 5's correctness/security pass came back clean; what was left was text that said more than the code does. - The register-path notify comment (and its test) claimed the admin "is still told". The call fires, but on a no-SMTP instance _send drops it — which is the open half of the problem, not something this branch fixed. Both now say so. - The release notes tell admins what to actually do: an account whose address was whitelisted in advance has no pending request to approve, so the whitelist box is what activates it. That instruction only existed in the roadmap and in chores, which the dialog does not render. - "That one line is the whole production change" now accounts for the test-only RateLimiter.reset() that also ships. - The rate-limiter test claimed to pin that blocked attempts are not counted, on an assertion that held either way. Replaced with one that drives the clock across the window boundary and fails if hammering extends it.
66 lines
2.3 KiB
Python
66 lines
2.3 KiB
Python
"""The sliding-window limiter behind /auth/register, password-login, password-reset and demo.
|
|
|
|
Pure logic, no DB. `reset()` in particular is only ever called by a test fixture (conftest clears
|
|
the module-level limiters between tests), so without a test here a refactor could turn it into a
|
|
no-op and nothing would notice until an unrelated test started failing with a 429.
|
|
"""
|
|
|
|
from app import ratelimit
|
|
from app.ratelimit import RateLimiter
|
|
|
|
|
|
def test_allow_trips_at_the_cap_and_stays_tripped():
|
|
lim = RateLimiter(max_events=2, window_seconds=300)
|
|
|
|
assert lim.allow("ip") is True
|
|
assert lim.allow("ip") is True
|
|
assert lim.allow("ip") is False
|
|
assert lim.allow("ip") is False
|
|
|
|
|
|
def test_hammering_while_blocked_does_not_push_the_window_forward(monkeypatch):
|
|
# A blocked attempt must not be recorded, otherwise someone who keeps retrying holds their own
|
|
# window open forever — an indefinite lockout for anyone sharing that IP. Only observable across
|
|
# the window boundary, so drive the clock: two attempts fill the cap, ten blocked retries land
|
|
# just before it expires, and the caller must still be let back in on the far side.
|
|
clock = {"t": 1000.0}
|
|
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: clock["t"])
|
|
lim = RateLimiter(max_events=2, window_seconds=60)
|
|
|
|
assert lim.allow("ip") is True
|
|
assert lim.allow("ip") is True
|
|
for _ in range(10):
|
|
clock["t"] += 5 # still inside the window; every one of these is refused
|
|
assert lim.allow("ip") is False
|
|
|
|
clock["t"] = 1000.0 + 61 # past the window of the two attempts that actually counted
|
|
assert lim.allow("ip") is True
|
|
|
|
|
|
def test_keys_are_independent():
|
|
lim = RateLimiter(max_events=1, window_seconds=300)
|
|
|
|
assert lim.allow("a") is True
|
|
assert lim.allow("a") is False
|
|
assert lim.allow("b") is True
|
|
|
|
|
|
def test_reset_clears_every_key():
|
|
lim = RateLimiter(max_events=1, window_seconds=300)
|
|
lim.allow("a")
|
|
lim.allow("b")
|
|
assert lim.allow("a") is False
|
|
|
|
lim.reset()
|
|
|
|
assert lim.allow("a") is True
|
|
assert lim.allow("b") is True
|
|
|
|
|
|
def test_expired_attempts_fall_out_of_the_window():
|
|
# A zero-length window means every previous attempt is already outside it.
|
|
lim = RateLimiter(max_events=1, window_seconds=0)
|
|
|
|
assert lim.allow("ip") is True
|
|
assert lim.allow("ip") is True
|