"""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