Files
ducoterra 42a4222949 phase: 81_login_rate_limit
All gates verified. The phase is complete — everything was already implemented by prior task runs; this pass verified each completion criterion end-to-end and found no defects.

**Phase 81 final verification report** (tasks 01–03 all present in `complete/`; no code changes needed this pass)

- Verified: `app/core/rate_limit.py` (stdlib sliding window, fail-open) + both login routes' 429 pre-check/record/reset wiring + all unit/integration pins present (11th-429, blocked-success-still-429, clean-counter reset, shared counter, autouse `clean_rate_limit` fixture documented).
- Live check (task 03): dev server + 11 rapid wrong logins → `401 ×10, 429`; 429 body carries `retry-after: 900` + generic detail; server restarted (per-process counter cleared by design).
- `uv run pytest tests/unit/test_rate_limit.py -v --no-cov` → 10 passed
- `uv run pytest --cov=app --cov-report=term-missing` → 1652 passed, **TOTAL 99%** (>90%; rate_limit.py 100%, auth.py 100%)
- `uv run pytest tests/e2e/test_smoke.py -v --no-cov` (isolation) → 3 passed
- `uv run ruff check . && uv run pyright` → All checks passed / 0 errors, 0 warnings
- Completion criteria: all met, except commit + phase-dir move — per harness rules I left all changes uncommitted in the working tree (harness commits atomically and moves the phase).
- Diff scope: exactly `app/core/rate_limit.py`, `app/api/auth.py`, `tests/unit/test_rate_limit.py`, `tests/integration/test_auth_api.py` + phase files; `pyproject.toml` / `uv.lock` / `frontend/` untouched.
- Deviations: none in code; commit/move deferred to harness as instructed.
- Next pending phase: `82_security_headers`.
2026-09-07 23:08:46 -04:00

150 lines
5.6 KiB
Python

"""Unit: the sliding-window failed sign-in limiter (phase 81, task 01).
Covers ``app.core.rate_limit`` — the per-client-IP sliding window that
both login routes share (the audit SEC-03 fix). Pins, in the task's
order:
* 9 failures → still allowed (``remaining_wait == 0``);
* the 10th failure → blocked (``remaining_wait > 0``, ``<= WINDOW_SECONDS``);
* independent IPs never share counters;
* window slide/expiry — a failure older than ``WINDOW_SECONDS`` stops
counting (both pre-seeded module state and a fake monotonic clock);
* ``remaining_wait`` is the WHOLE-window wait until the OLDEST in-window
failure expires (the ``Retry-After`` contract), not a per-failure age;
* ``reset`` (a success on either route) unblocks immediately;
* an unknown IP never blocks;
* FAIL-OPEN — a corrupted, non-deque state entry and a broken clock
both degrade to "allow": ``remaining_wait`` returns 0 and
``record_failure`` drops the count without raising. A limiter bug
must never lock the owner out.
"""
from __future__ import annotations
import time
from collections import deque
from collections.abc import Iterator
import pytest
from app.core import rate_limit as rl
@pytest.fixture(autouse=True)
def _clean_state() -> Iterator[None]:
"""The limiter is module-global: no counter may leak between tests."""
rl._failures.clear()
yield
rl._failures.clear()
def test_nine_failures_still_allowed() -> None:
"""Below the threshold: 9 in-window failures → no wait (the 10th
failure is the one that crosses the bar)."""
for _ in range(rl.MAX_FAILURES - 1):
rl.record_failure("ip")
assert rl.remaining_wait("ip") == 0
def test_tenth_failure_blocks_until_the_window_clears() -> None:
"""The 10th recorded failure crosses the threshold: the routes'
pre-check (the 11th attempt) sees a positive wait — bounded by the
whole window and integral for the ``Retry-After`` header."""
for _ in range(rl.MAX_FAILURES):
rl.record_failure("ip")
wait = rl.remaining_wait("ip")
assert isinstance(wait, int)
assert wait > 0
assert wait <= rl.WINDOW_SECONDS
def test_ips_are_independent() -> None:
"""One IP's failures never count toward another IP's window."""
for _ in range(rl.MAX_FAILURES):
rl.record_failure("ip")
assert rl.remaining_wait("ip") > 0
assert rl.remaining_wait("ip2") == 0
def test_failures_older_than_the_window_stop_counting_preseeded() -> None:
"""Reach into the module state: timestamps ``WINDOW_SECONDS + 1`` in
the past have slid out — 10 such 'failures' leave the IP unblocked."""
now = time.monotonic()
rl._failures["ip"] = deque([now - rl.WINDOW_SECONDS - 1.0] * rl.MAX_FAILURES)
assert rl.remaining_wait("ip") == 0
def test_window_slides_and_wait_is_measured_from_the_oldest(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Fake monotonic clock, 10 failures one second apart (t = 1000..1009):
* the wait is the WHOLE-window seconds until the OLDEST in-window
failure expires — ``ceil(900 - 9) == 891`` at t = 1009, not a
per-failure age;
* the wait counts down with the oldest (300 at t = 1600, clamped to
1 at t = 1899);
* the block lifts the moment the oldest failure slides out (t =
1900): 9 failures are then still in the window, yet the count is
back below the threshold.
"""
clock = {"t": 1000.0}
monkeypatch.setattr(time, "monotonic", lambda: clock["t"])
for i in range(rl.MAX_FAILURES):
clock["t"] = 1000.0 + i # failures at 1000, 1001, ..., 1009
rl.record_failure("ip")
assert rl.remaining_wait("ip") == 891 # ceil(900 - 9)
clock["t"] = 1600.0
assert rl.remaining_wait("ip") == 300 # oldest is 600 s old
clock["t"] = 1899.0
assert rl.remaining_wait("ip") == 1 # ceil(900 - 899), clamped >= 1
clock["t"] = 1900.0
assert rl.remaining_wait("ip") == 0 # oldest just expired → 9 left
def test_reset_unblocks_immediately() -> None:
"""A success on either route clears the IP's counter: the owner who
fat-fingered is not poisoned by their own earlier misses."""
for _ in range(rl.MAX_FAILURES):
rl.record_failure("ip")
assert rl.remaining_wait("ip") > 0
rl.reset("ip")
assert rl.remaining_wait("ip") == 0
def test_unknown_ip_never_blocks() -> None:
"""No failures recorded → no wait (the routes' pre-check fast path)."""
assert rl.remaining_wait("never-seen") == 0
def test_fail_open_on_corrupt_state_entry() -> None:
"""A non-deque entry in the module state (corruption):
``remaining_wait`` must return 0 (never block) and ``record_failure``
must drop the count WITHOUT raising (fail-open — a limiter bug never
locks the owner out). The IP stays unblocked afterwards."""
rl._failures["bad"] = object() # type: ignore[assignment]
assert rl.remaining_wait("bad") == 0
rl.record_failure("bad") # must not raise
assert rl.remaining_wait("bad") == 0
def test_fail_open_on_broken_clock(monkeypatch: pytest.MonkeyPatch) -> None:
"""A clock anomaly (monotonic raising) is equally fail-open: 0 wait
and a silent drop — the login route itself must never 500 because
of the limiter."""
def broken_clock() -> float:
raise ValueError("clock skew")
monkeypatch.setattr(time, "monotonic", broken_clock)
assert rl.remaining_wait("any") == 0
rl.record_failure("any") # must not raise
assert rl.remaining_wait("any") == 0
def test_reset_unknown_ip_is_a_noop() -> None:
"""No entry to drop: ``reset`` must not raise (the routes call it on
every success unconditionally)."""
rl.reset("ghost")
assert rl.remaining_wait("ghost") == 0