Files
ObsiGate/tests/test_ratelimit_store.py

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