feat: #85 T10 persistance etat (verrous stores, ratelimit SQLite) et cloture refonte
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
"""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
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Tests de non-régression — verrous des stores JSON (ROADMAP #85 T10a).
|
||||
|
||||
Sans verrou, les read-modify-write concurrents (créations de partages,
|
||||
révocations de jetons) perdent des mises à jour : deux threads lisent le
|
||||
même état, chacun écrit le sien, la première écriture est écrasée. Ces
|
||||
tests martèlent les stores depuis plusieurs threads et exigent un compte
|
||||
exact à la fin (aucune mise à jour perdue).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
N_THREADS = 8
|
||||
N_OPS = 25
|
||||
|
||||
|
||||
def test_concurrent_share_creations_lose_nothing(tmp_path, monkeypatch):
|
||||
"""N threads × N partages → le store final contient tout."""
|
||||
from backend import share as share_mod
|
||||
|
||||
monkeypatch.setattr(share_mod, "SHARES_FILE", tmp_path / "shares.json")
|
||||
|
||||
errors: list[BaseException] = []
|
||||
|
||||
def worker(n: int):
|
||||
try:
|
||||
for i in range(N_OPS):
|
||||
share_mod.create_share("V", f"doc-{n}-{i}.md", "tester")
|
||||
except BaseException as e: # pragma: no cover - diagnostic
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=worker, args=(n,)) for n in range(N_THREADS)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert not errors
|
||||
assert len(share_mod.list_shares()) == N_THREADS * N_OPS
|
||||
|
||||
|
||||
def test_concurrent_share_access_and_revoke(tmp_path, monkeypatch):
|
||||
"""Accès + révocations concurrents : compteurs et suppressions cohérents."""
|
||||
from backend import share as share_mod
|
||||
|
||||
monkeypatch.setattr(share_mod, "SHARES_FILE", tmp_path / "shares.json")
|
||||
tokens = [share_mod.create_share("V", f"doc-{i}.md", "tester")["token"] for i in range(20)]
|
||||
|
||||
def worker(n: int):
|
||||
share_mod.record_access(tokens[2 * n])
|
||||
share_mod.record_access(tokens[2 * n + 1])
|
||||
share_mod.revoke_share(tokens[2 * n])
|
||||
|
||||
threads = [threading.Thread(target=worker, args=(n,)) for n in range(10)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# Les 10 tokens pairs ont été révoqués ; les impairs subsistent avec
|
||||
# leurs compteurs d'accès (aucune écriture perdue).
|
||||
remaining = {s["token"] for s in share_mod.list_shares()}
|
||||
assert len(remaining) == 10
|
||||
assert all(share_mod.get_share_by_token(t)["access_count"] >= 1 for t in remaining)
|
||||
|
||||
|
||||
def test_concurrent_token_revokes_lose_nothing(tmp_path, monkeypatch):
|
||||
"""N threads × N révocations → toutes les JTIs sont persistées."""
|
||||
import json
|
||||
|
||||
from backend.auth import jwt_handler
|
||||
|
||||
revoked_file = tmp_path / "revoked_tokens.json"
|
||||
monkeypatch.setattr(jwt_handler, "REVOKED_TOKENS_FILE", revoked_file)
|
||||
monkeypatch.setattr(jwt_handler, "_revoked_map", {})
|
||||
monkeypatch.setattr(jwt_handler, "_revoked_loaded", True)
|
||||
|
||||
errors: list[BaseException] = []
|
||||
|
||||
def worker(n: int):
|
||||
try:
|
||||
for i in range(N_OPS):
|
||||
jwt_handler.revoke_token(f"jti-{n}-{i}")
|
||||
except BaseException as e: # pragma: no cover - diagnostic
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=worker, args=(n,)) for n in range(N_THREADS)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert not errors
|
||||
assert len(jwt_handler._revoked_map) == N_THREADS * N_OPS
|
||||
# Le fichier reflète l'état mémoire (aucune écriture perdue).
|
||||
on_disk = json.loads(revoked_file.read_text(encoding="utf-8"))
|
||||
assert len(on_disk) == N_THREADS * N_OPS
|
||||
assert jwt_handler.is_token_revoked("jti-0-0")
|
||||
assert not jwt_handler.is_token_revoked("jti-absent")
|
||||
|
||||
|
||||
def test_concurrent_webhook_and_tool_key_writes(tmp_path, monkeypatch):
|
||||
"""Créations de webhooks + clés d'outils concurrentes : rien de perdu."""
|
||||
from backend import webhooks as wh_mod
|
||||
from backend.tools import secrets as sec_mod
|
||||
|
||||
monkeypatch.setattr(wh_mod, "WEBHOOKS_FILE", tmp_path / "webhooks.json")
|
||||
monkeypatch.setattr(wh_mod, "WEBHOOK_SECRETS_FILE", tmp_path / "webhook_secrets.json")
|
||||
monkeypatch.setattr(sec_mod, "_keys_file", lambda: tmp_path / "api_keys.json")
|
||||
|
||||
def worker(n: int):
|
||||
for i in range(N_OPS):
|
||||
wh_mod.create_webhook(f"hook-{n}-{i}", "https://example.com/hook", ["file_created"])
|
||||
sec_mod.set_tool_key("OBSIGATE_GITHUB_TOKEN", f"tok-{n}-{i}")
|
||||
|
||||
threads = [threading.Thread(target=worker, args=(n,)) for n in range(N_THREADS)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert len(wh_mod.get_webhooks()) == N_THREADS * N_OPS
|
||||
Reference in New Issue
Block a user