Files
thoughtsync/tests/test_ratelimit.py
T
bvandeusen bacedea8a3
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 13s
CI & Build / Build & push image (push) Successful in 19s
tests: seed the throttle counters on the clock the routes actually read
The three route tests stamped their pre-loaded hits at t=0..9 through the
injected clock, then called a route that reads `time.monotonic()`. Against a
trailing window those hits are fifteen minutes stale on arrival, so they were
pruned before they could refuse anything, the request carried on to the database
that this suite doesn't have, and the assertion read `500 == 429`.

The window's own tests keep the injected clock — they pass the same one to both
sides, which is what makes them deterministic and instant. Only the tests that
hand off to a route need the real one.
2026-08-21 22:03:19 -04:00

149 lines
5.4 KiB
Python

"""The credential throttle. DB-free, like the rest of this suite.
The window itself is exercised directly with an injected clock, so nothing here
sleeps: a 15-minute window tested in real time is a test nobody runs twice.
The routes are exercised only as far as they get WITHOUT a database — a throttled
request returns 429 before any session is opened, which is the whole point of
checking the limit before the password. The happy path can't be reached here and is
not pretended at.
"""
import time
import pytest
from thoughtsync import ratelimit
from thoughtsync.app import create_app
from thoughtsync.ratelimit import SlidingWindow
@pytest.fixture(autouse=True)
def _clean_counters():
ratelimit.reset_all()
yield
ratelimit.reset_all()
@pytest.fixture
def app():
return create_app()
def test_under_the_limit_is_not_blocked():
w = SlidingWindow(limit=3, window_s=60)
for i in range(3):
assert w.retry_after("k", now=i) is None
w.record("k", now=i)
assert w.retry_after("k", now=3) is not None
def test_window_slides_rather_than_resetting():
w = SlidingWindow(limit=2, window_s=60)
w.record("k", now=0)
w.record("k", now=30)
assert w.retry_after("k", now=31) is not None
# The 0s hit falls out at t=60, which frees exactly one slot — the 30s hit is
# still inside the window, so this is a slide and not a reset.
assert w.retry_after("k", now=61) is None
w.record("k", now=61)
assert w.retry_after("k", now=62) is not None
def test_retry_after_points_past_the_oldest_hit():
w = SlidingWindow(limit=1, window_s=100)
w.record("k", now=10)
wait = w.retry_after("k", now=40)
# The hit at t=10 leaves the window at t=110, i.e. 70s away. Rounded up, never
# under-reported — a client that waits exactly this long must not be refused
# again.
assert wait is not None
assert 70 <= wait <= 72
assert w.retry_after("k", now=40 + wait) is None
def test_keys_are_counted_separately():
w = SlidingWindow(limit=1, window_s=60)
w.record("a", now=0)
assert w.retry_after("a", now=1) is not None
assert w.retry_after("b", now=1) is None
def test_forget_clears_one_key():
w = SlidingWindow(limit=1, window_s=60)
w.record("a", now=0)
w.record("b", now=0)
w.forget("a")
assert w.retry_after("a", now=1) is None
assert w.retry_after("b", now=1) is not None
def test_bucket_count_is_bounded(monkeypatch):
# An attacker rotating a forged X-Forwarded-For must not be able to grow this
# dict without limit — the limiter cannot become the exhaustion it prevents.
monkeypatch.setattr(ratelimit, "MAX_BUCKETS", 8)
w = SlidingWindow(limit=5, window_s=60)
for i in range(50):
w.record(f"addr-{i}", now=i)
assert len(w._hits) <= 8
async def test_login_starts_refusing(app):
client = app.test_client()
body = {"email": "someone@example.com", "password": "wrong-password"}
# Pre-load the account's counter to its limit rather than posting that many
# times: every real attempt would need a database to reach the password check.
#
# On the REAL clock, not an injected one. The window is trailing, so hits stamped
# at t=0..9 are fifteen minutes stale the moment the route reads
# `time.monotonic()` and get pruned before they can refuse anything.
now = time.monotonic()
for _ in range(ratelimit.ACCOUNT_LIMIT):
ratelimit.sign_in_by_account.record("someone@example.com", now=now)
resp = await client.post("/api/auth/login", json=body)
assert resp.status_code == 429
assert resp.headers.get("Retry-After")
# The refusal says nothing about whether that account exists.
assert "someone@example.com" not in (await resp.get_data(as_text=True))
async def test_device_login_shares_the_account_counter(app):
client = app.test_client()
now = time.monotonic()
for _ in range(ratelimit.ACCOUNT_LIMIT):
ratelimit.sign_in_by_account.record("someone@example.com", now=now)
resp = await client.post(
"/api/auth/device-login",
json={"email": "someone@example.com", "password": "wrong-password"},
)
# Same budget as /login — otherwise guessing just moves to the route that hands
# out a long-lived bearer token.
assert resp.status_code == 429
async def test_register_is_throttled_by_address(app):
client = app.test_client()
now = time.monotonic()
for _ in range(ratelimit.REGISTER_LIMIT):
ratelimit.register_by_address.record("203.0.113.9", now=now)
resp = await client.post(
"/api/auth/register",
json={"email": "new@example.com", "password": "a-long-enough-password"},
headers={"X-Forwarded-For": "203.0.113.9"},
)
assert resp.status_code == 429
async def test_client_address_prefers_the_forwarded_client(app):
# Behind a reverse proxy, remote_addr is the PROXY for every request on earth —
# keying on it would rate-limit the entire internet as one caller. The leftmost
# X-Forwarded-For entry is the original client.
async with app.test_request_context("/", headers={"X-Forwarded-For": "198.51.100.4, 10.0.0.1"}):
assert ratelimit.client_address() == "198.51.100.4"
async def test_client_address_falls_back_to_the_peer(app):
async with app.test_request_context("/"):
# No proxy header: whatever the peer address is, it must be a usable key
# rather than an empty string sharing one bucket with everyone.
assert ratelimit.client_address()