144 lines
4.9 KiB
Python
144 lines
4.9 KiB
Python
"""Tests — rate-limit SQLite optionnel (ROADMAP #85 T10b).
|
|
|
|
Le store mémoire reste le défaut (comportement inchangé) ; si
|
|
``OBSIGATE_RATELIMIT_DB`` pointe vers un fichier SQLite, les compteurs y
|
|
sont persistés (partagés entre workers/processus, conservés au redémarrage)
|
|
avec une sémantique identique (fenêtre glissante, budgets IP + compte,
|
|
reset au succès).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
|
|
|
|
def _use_db(monkeypatch, tmp_path):
|
|
monkeypatch.setenv("OBSIGATE_RATELIMIT_DB", str(tmp_path / "ratelimit.db"))
|
|
|
|
|
|
def test_memory_default_unchanged(monkeypatch):
|
|
"""Sans la variable d'env : le store mémoire historique est utilisé."""
|
|
from backend import ratelimit
|
|
|
|
monkeypatch.delenv("OBSIGATE_RATELIMIT_DB", raising=False)
|
|
assert ratelimit._db_path() is None
|
|
ip = "10.9.9.1"
|
|
ratelimit._ip_attempts.pop(ip, None)
|
|
assert not ratelimit.is_rate_limited(ip)
|
|
ratelimit.record_failure(ip)
|
|
assert not ratelimit.is_rate_limited(ip)
|
|
ratelimit.record_success(ip)
|
|
assert not ratelimit.is_rate_limited(ip)
|
|
|
|
|
|
def test_sqlite_failures_and_limit(monkeypatch, tmp_path):
|
|
"""Budget IP : N échecs → limité ; succès → reset (SQLite)."""
|
|
from backend import ratelimit
|
|
|
|
_use_db(monkeypatch, tmp_path)
|
|
monkeypatch.setattr(ratelimit, "MAX_ATTEMPTS", 3)
|
|
ip = "10.8.8.1"
|
|
assert not ratelimit.is_rate_limited(ip)
|
|
ratelimit.record_failure(ip)
|
|
ratelimit.record_failure(ip)
|
|
assert not ratelimit.is_rate_limited(ip)
|
|
failures, remaining = ratelimit.record_failure(ip)
|
|
assert (failures, remaining) == (3, 0)
|
|
assert ratelimit.is_rate_limited(ip)
|
|
ratelimit.record_success(ip)
|
|
assert not ratelimit.is_rate_limited(ip)
|
|
|
|
|
|
def test_sqlite_account_budget_case_insensitive(monkeypatch, tmp_path):
|
|
"""Budget par compte : insensible à la casse, indépendant des IP."""
|
|
from backend import ratelimit
|
|
|
|
_use_db(monkeypatch, tmp_path)
|
|
monkeypatch.setattr(ratelimit, "ACCOUNT_MAX_ATTEMPTS", 2)
|
|
assert not ratelimit.is_account_rate_limited("Alice")
|
|
ratelimit.record_account_failure("alice")
|
|
assert not ratelimit.is_account_rate_limited("ALICE")
|
|
ratelimit.record_account_failure("ALICE")
|
|
assert ratelimit.is_account_rate_limited("alice")
|
|
# Le budget IP n'est pas affecté par le budget compte.
|
|
assert not ratelimit.is_rate_limited("1.2.3.4")
|
|
ratelimit.record_account_success("alice")
|
|
assert not ratelimit.is_account_rate_limited("alice")
|
|
|
|
|
|
def test_sqlite_window_expiry(monkeypatch, tmp_path):
|
|
"""Les tentatives hors fenêtre ne comptent plus (SQLite)."""
|
|
from backend import ratelimit
|
|
|
|
_use_db(monkeypatch, tmp_path)
|
|
monkeypatch.setattr(ratelimit, "MAX_ATTEMPTS", 2)
|
|
monkeypatch.setattr(ratelimit, "WINDOW_SECONDS", 1)
|
|
ip = "10.7.7.1"
|
|
ratelimit.record_failure(ip)
|
|
ratelimit.record_failure(ip)
|
|
assert ratelimit.is_rate_limited(ip)
|
|
time.sleep(1.1)
|
|
assert not ratelimit.is_rate_limited(ip)
|
|
|
|
|
|
def test_sqlite_persists_across_restart(monkeypatch, tmp_path):
|
|
"""Les compteurs survivent au redémarrage (même fichier)."""
|
|
import os
|
|
|
|
from backend import ratelimit
|
|
|
|
db = tmp_path / "ratelimit.db"
|
|
monkeypatch.setenv("OBSIGATE_RATELIMIT_DB", str(db))
|
|
monkeypatch.setattr(ratelimit, "MAX_ATTEMPTS", 5)
|
|
for _ in range(3):
|
|
ratelimit.record_failure("10.6.6.6")
|
|
assert os.path.exists(db)
|
|
# "Redémarrage" : le module relit le même fichier (connexions courtes).
|
|
assert ratelimit.get_status("10.6.6.6")["failures"] == 3
|
|
with sqlite3.connect(str(db)) as conn:
|
|
(rows,) = conn.execute("SELECT COUNT(*) FROM attempts").fetchone()
|
|
assert rows == 3
|
|
|
|
|
|
def test_sqlite_get_status_shapes(monkeypatch, tmp_path):
|
|
"""`get_status` garde les mêmes formes qu'en mémoire."""
|
|
from backend import ratelimit
|
|
|
|
_use_db(monkeypatch, tmp_path)
|
|
ratelimit.record_failure("10.5.5.5")
|
|
ratelimit.record_account_failure("bob")
|
|
per_ip = ratelimit.get_status("10.5.5.5")
|
|
assert per_ip == {
|
|
"ip": "10.5.5.5",
|
|
"failures": 1,
|
|
"max": ratelimit.MAX_ATTEMPTS,
|
|
"limited": False,
|
|
"window_seconds": ratelimit.WINDOW_SECONDS,
|
|
}
|
|
glob = ratelimit.get_status()
|
|
assert glob["tracked_ips"] == 1
|
|
assert glob["tracked_accounts"] == 1
|
|
assert glob["limited_ips"] == 0
|
|
assert glob["max_attempts"] == ratelimit.MAX_ATTEMPTS
|
|
|
|
|
|
def test_sqlite_concurrent_writes(monkeypatch, tmp_path):
|
|
"""Écritures concurrentes : aucun échec compté perdu (SQLite/WAL)."""
|
|
from backend import ratelimit
|
|
|
|
_use_db(monkeypatch, tmp_path)
|
|
monkeypatch.setattr(ratelimit, "MAX_ATTEMPTS", 10_000)
|
|
|
|
def worker(n: int):
|
|
for _ in range(25):
|
|
ratelimit.record_failure("10.4.4.4")
|
|
|
|
threads = [threading.Thread(target=worker, args=(n,)) for n in range(8)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
assert ratelimit.get_status("10.4.4.4")["failures"] == 200
|