tests: seed the throttle counters on the clock the routes actually read
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

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.
This commit is contained in:
2026-08-21 22:03:19 -04:00
parent b6152ec18b
commit bacedea8a3
+15 -6
View File
@@ -8,6 +8,8 @@ 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
@@ -90,8 +92,13 @@ async def test_login_starts_refusing(app):
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.
for i in range(ratelimit.ACCOUNT_LIMIT):
ratelimit.sign_in_by_account.record("someone@example.com", now=float(i))
#
# 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")
@@ -101,8 +108,9 @@ async def test_login_starts_refusing(app):
async def test_device_login_shares_the_account_counter(app):
client = app.test_client()
for i in range(ratelimit.ACCOUNT_LIMIT):
ratelimit.sign_in_by_account.record("someone@example.com", now=float(i))
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"},
@@ -114,8 +122,9 @@ async def test_device_login_shares_the_account_counter(app):
async def test_register_is_throttled_by_address(app):
client = app.test_client()
for i in range(ratelimit.REGISTER_LIMIT):
ratelimit.register_by_address.record("203.0.113.9", now=float(i))
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"},