Merge HRT-62: IP-based rate limiting on /auth/login — validated CTO
- In-memory IP rate limiter: 5 attempts / 5min window - 15 min block on exceed, HTTP 429 + Retry-After header - Applied rate_limit_middleware on portal_server.py - Tests: TestLoginRateLimit added (conflict resolved: keep both test classes) Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -5,8 +5,11 @@ import json
|
||||
import requests
|
||||
import subprocess
|
||||
import db
|
||||
from middleware import rate_limit_middleware, access_log_middleware
|
||||
|
||||
app = Flask(__name__)
|
||||
rate_limit_middleware(app)
|
||||
access_log_middleware(app)
|
||||
|
||||
DASHBOARD_API_URL = "http://localhost:8791"
|
||||
COMBINED_API_URL = "http://localhost:8790"
|
||||
|
||||
@@ -10,3 +10,4 @@ markers =
|
||||
load: Tests de charge Locust
|
||||
security: Tests de sécurité
|
||||
smoke: Tests rapides de smoke
|
||||
integration: Tests d'intégration DB et pipeline ML
|
||||
|
||||
43
saas_auth.py
43
saas_auth.py
@@ -14,6 +14,18 @@ import time
|
||||
import json
|
||||
from functools import wraps
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
from threading import Lock
|
||||
|
||||
# ─── Rate limiting login ───────────────────────────────────────────────────────
|
||||
_login_attempts: dict = defaultdict(
|
||||
lambda: {"count": 0, "window_start": 0.0, "blocked_until": 0.0}
|
||||
)
|
||||
_login_lock = Lock()
|
||||
|
||||
LOGIN_RATE_MAX = 5 # max tentatives par fenêtre
|
||||
LOGIN_RATE_WINDOW = 300 # 5 minutes (en secondes)
|
||||
LOGIN_BLOCK_DURATION = 900 # 15 min de blocage après dépassement
|
||||
|
||||
# ─── Blacklist mots de passe faibles ─────────────────────────────────────────
|
||||
# HRT-63 — Validation mots de passe faibles
|
||||
@@ -300,6 +312,37 @@ def login():
|
||||
if not email or not password:
|
||||
return jsonify({"error": "Email et mot de passe requis."}), 400
|
||||
|
||||
# ── Rate limit par IP ────────────────────────────────────────
|
||||
ip = request.remote_addr or "unknown"
|
||||
now = time.time()
|
||||
|
||||
with _login_lock:
|
||||
bucket = _login_attempts[ip]
|
||||
# Lever le blocage si la durée est écoulée
|
||||
if now >= bucket["blocked_until"]:
|
||||
if now - bucket["window_start"] >= LOGIN_RATE_WINDOW:
|
||||
bucket["count"] = 0
|
||||
bucket["window_start"] = now
|
||||
bucket["count"] += 1
|
||||
count = bucket["count"]
|
||||
if count > LOGIN_RATE_MAX:
|
||||
bucket["blocked_until"] = now + LOGIN_BLOCK_DURATION
|
||||
retry_after = LOGIN_BLOCK_DURATION
|
||||
blocked = True
|
||||
else:
|
||||
retry_after = int(LOGIN_RATE_WINDOW - (now - bucket["window_start"]))
|
||||
blocked = False
|
||||
else:
|
||||
blocked = True
|
||||
retry_after = int(bucket["blocked_until"] - now)
|
||||
|
||||
if blocked:
|
||||
resp = jsonify({"error": "Trop de tentatives. Réessayez plus tard."})
|
||||
resp.status_code = 429
|
||||
resp.headers["Retry-After"] = str(retry_after)
|
||||
return resp
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
pw_hash = hash_password(password)
|
||||
conn = get_db()
|
||||
user = conn.execute(
|
||||
|
||||
@@ -141,7 +141,7 @@ class TestJWTAuthentication:
|
||||
"invalid_signature_here"
|
||||
)
|
||||
resp = requests.get(
|
||||
f"{BASE_URL}/api/races",
|
||||
f"{BASE_URL}/api/v1/predictions/today",
|
||||
headers={"Authorization": f"Bearer {expired_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
@@ -153,7 +153,7 @@ class TestJWTAuthentication:
|
||||
"""Un token JWT malformé doit être rejeté."""
|
||||
for bad_token in ["not.a.jwt", "Bearer", "null", "undefined", ""]:
|
||||
resp = requests.get(
|
||||
f"{BASE_URL}/api/races",
|
||||
f"{BASE_URL}/api/v1/predictions/today",
|
||||
headers={"Authorization": f"Bearer {bad_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
@@ -163,7 +163,7 @@ class TestJWTAuthentication:
|
||||
|
||||
def test_jwt_sans_token(self):
|
||||
"""Sans token, les routes protégées doivent retourner 401."""
|
||||
resp = requests.get(f"{BASE_URL}/api/export/csv", timeout=5)
|
||||
resp = requests.get(f"{BASE_URL}/api/v1/export/csv", timeout=5)
|
||||
assert resp.status_code in (401, 403), (
|
||||
f"Route protégée accessible sans token: status={resp.status_code}"
|
||||
)
|
||||
@@ -386,6 +386,53 @@ class TestWeakPasswordRejection:
|
||||
assert resp.status_code == 400, (
|
||||
f"Mot de passe sans lettre accepté: status={resp.status_code}"
|
||||
)
|
||||
# === Tests rate limiting login ===
|
||||
|
||||
|
||||
class TestLoginRateLimit:
|
||||
"""Tests rate limiting sur /api/v1/auth/login."""
|
||||
|
||||
TARGET_URL = (
|
||||
os.environ.get("APP_URL", "http://localhost:8792") + "/api/v1/auth/login"
|
||||
)
|
||||
|
||||
def test_login_brute_force_blocked_after_5_attempts(self):
|
||||
"""Après 5 tentatives, le 6ème appel doit retourner 429."""
|
||||
# Utiliser un email unique pour isoler le test
|
||||
email = f"ratelimit_test_{int(time.time())}@h3r7.tech"
|
||||
for i in range(5):
|
||||
resp = requests.post(
|
||||
self.TARGET_URL,
|
||||
json={"email": email, "password": "wrong_password"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code in (400, 401), (
|
||||
f"Tentative {i + 1}: status inattendu {resp.status_code}"
|
||||
)
|
||||
# La 6ème tentative doit être bloquée
|
||||
resp = requests.post(
|
||||
self.TARGET_URL,
|
||||
json={"email": email, "password": "wrong_password"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == 429, (
|
||||
f"Rate limit non appliqué après 5 tentatives: got {resp.status_code}"
|
||||
)
|
||||
assert "Retry-After" in resp.headers, "Header Retry-After manquant sur 429"
|
||||
|
||||
def test_login_429_has_retry_after_header(self):
|
||||
"""La réponse 429 doit inclure Retry-After."""
|
||||
email = f"ratelimit_test2_{int(time.time())}@h3r7.tech"
|
||||
for _ in range(6):
|
||||
requests.post(
|
||||
self.TARGET_URL, json={"email": email, "password": "x"}, timeout=5
|
||||
)
|
||||
resp = requests.post(
|
||||
self.TARGET_URL, json={"email": email, "password": "x"}, timeout=5
|
||||
)
|
||||
if resp.status_code == 429:
|
||||
assert "Retry-After" in resp.headers
|
||||
assert int(resp.headers["Retry-After"]) > 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
300
tests/test_ml_cache_integrity.py
Normal file
300
tests/test_ml_cache_integrity.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""
|
||||
test_ml_cache_integrity.py — Test d'intégration : zéro NULL dans ml_predictions_cache
|
||||
SaaS Turf Prédictions IA
|
||||
Ticket: HRT-43 (suite au fix HRT-41 — métadonnées manquantes dans le cache ML)
|
||||
|
||||
Ces tests vérifient que la table ml_predictions_cache ne contient aucune ligne
|
||||
avec des métadonnées NULL (hippodrome, race_label, heure) pour la date courante,
|
||||
après le job ML de 19h30.
|
||||
|
||||
Usage:
|
||||
pytest tests/test_ml_cache_integrity.py -v -m integration
|
||||
pytest tests/test_ml_cache_integrity.py -v -m integration --date 2026-04-26
|
||||
|
||||
Variables d'environnement:
|
||||
TURF_DB_PATH : chemin vers turf.db (défaut: /home/h3r7/turf_scraper/turf.db)
|
||||
TEST_DATE : date cible au format YYYY-MM-DD (défaut: date du jour)
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
import pytest
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Configuration
|
||||
# ============================================================
|
||||
|
||||
DEFAULT_DB_PATH = "/home/h3r7/turf_scraper/turf.db"
|
||||
DB_PATH = os.environ.get("TURF_DB_PATH", DEFAULT_DB_PATH)
|
||||
|
||||
|
||||
def _get_test_date() -> str:
|
||||
"""Retourne la date cible pour les tests (env TEST_DATE ou date du jour)."""
|
||||
env_date = os.environ.get("TEST_DATE", "")
|
||||
if env_date:
|
||||
try:
|
||||
datetime.strptime(env_date, "%Y-%m-%d")
|
||||
return env_date
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"TEST_DATE invalide : '{env_date}'. Format attendu : YYYY-MM-DD"
|
||||
)
|
||||
return date.today().isoformat()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Fixture : connexion DB en lecture seule
|
||||
# ============================================================
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def db_connection():
|
||||
"""
|
||||
Connexion SQLite en mode lecture seule (uri=True + ?mode=ro).
|
||||
Garantit qu'aucune modification accidentelle de la DB de prod n'est possible.
|
||||
"""
|
||||
db_path = Path(DB_PATH)
|
||||
if not db_path.exists():
|
||||
pytest.skip(
|
||||
f"Base de données introuvable : {DB_PATH}. "
|
||||
"Définir TURF_DB_PATH ou vérifier le chemin."
|
||||
)
|
||||
|
||||
uri = f"file:{db_path.as_posix()}?mode=ro"
|
||||
conn = sqlite3.connect(uri, uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
yield conn
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def target_date():
|
||||
"""Date cible pour les tests (date du jour ou TEST_DATE)."""
|
||||
return _get_test_date()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Tests d'intégration
|
||||
# ============================================================
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestMlCacheNullIntegrity:
|
||||
"""
|
||||
Vérifie qu'après le job ML de 19h30, la table ml_predictions_cache
|
||||
ne contient aucune métadonnée NULL pour la date courante.
|
||||
|
||||
Régression testée : HRT-41 (Fix #17 — métadonnées manquantes dans le cache ML)
|
||||
"""
|
||||
|
||||
def test_table_exists(self, db_connection):
|
||||
"""Vérifie que la table ml_predictions_cache existe dans la DB."""
|
||||
cursor = db_connection.execute(
|
||||
"SELECT name FROM sqlite_master "
|
||||
"WHERE type='table' AND name='ml_predictions_cache'"
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
assert row is not None, (
|
||||
"La table ml_predictions_cache est introuvable dans la base de données. "
|
||||
"Vérifier que le job ML a bien créé la table."
|
||||
)
|
||||
|
||||
def test_rows_exist_for_today(self, db_connection, target_date):
|
||||
"""
|
||||
Vérifie que des prédictions existent pour la date cible.
|
||||
|
||||
Ce test passe en skip si aucune ligne n'existe (ex: avant le job 19h30).
|
||||
Il échoue uniquement si le job a manifestement tourné mais a laissé 0 lignes.
|
||||
"""
|
||||
cursor = db_connection.execute(
|
||||
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
||||
(target_date,),
|
||||
)
|
||||
count = cursor.fetchone()["cnt"]
|
||||
|
||||
if count == 0:
|
||||
pytest.skip(
|
||||
f"Aucune prédiction en cache pour le {target_date}. "
|
||||
"Ce test doit être exécuté après le job ML de 19h30."
|
||||
)
|
||||
|
||||
def test_zero_null_hippodrome_today(self, db_connection, target_date):
|
||||
"""
|
||||
CRITÈRE D'ACCEPTATION PRINCIPAL (HRT-43) :
|
||||
Vérifie que COUNT(*) WHERE date = today AND hippodrome IS NULL = 0.
|
||||
|
||||
Régression directe du bug HRT-41 : le champ hippodrome était NULL
|
||||
pour toutes les prédictions du cache ML.
|
||||
"""
|
||||
# Vérifier si des données existent avant de tester les NULLs
|
||||
cursor_total = db_connection.execute(
|
||||
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
||||
(target_date,),
|
||||
)
|
||||
total = cursor_total.fetchone()["cnt"]
|
||||
if total == 0:
|
||||
pytest.skip(
|
||||
f"Aucune prédiction en cache pour le {target_date}. "
|
||||
"Lancer ce test après le job ML de 19h30."
|
||||
)
|
||||
|
||||
cursor = db_connection.execute(
|
||||
"SELECT COUNT(*) as cnt FROM ml_predictions_cache "
|
||||
"WHERE date = ? AND hippodrome IS NULL",
|
||||
(target_date,),
|
||||
)
|
||||
null_count = cursor.fetchone()["cnt"]
|
||||
|
||||
assert null_count == 0, (
|
||||
f"RÉGRESSION HRT-41 DÉTECTÉE : {null_count} ligne(s) avec hippodrome IS NULL "
|
||||
f"dans ml_predictions_cache pour le {target_date}. "
|
||||
"Le patch de métadonnées n'a pas été appliqué correctement."
|
||||
)
|
||||
|
||||
def test_zero_null_race_label_today(self, db_connection, target_date):
|
||||
"""
|
||||
Vérifie que COUNT(*) WHERE date = today AND race_label IS NULL = 0.
|
||||
|
||||
Complément du test hippodrome : vérifie que le libellé de course
|
||||
est bien renseigné pour toutes les prédictions.
|
||||
"""
|
||||
cursor_total = db_connection.execute(
|
||||
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
||||
(target_date,),
|
||||
)
|
||||
total = cursor_total.fetchone()["cnt"]
|
||||
if total == 0:
|
||||
pytest.skip(
|
||||
f"Aucune prédiction en cache pour le {target_date}. "
|
||||
"Lancer ce test après le job ML de 19h30."
|
||||
)
|
||||
|
||||
cursor = db_connection.execute(
|
||||
"SELECT COUNT(*) as cnt FROM ml_predictions_cache "
|
||||
"WHERE date = ? AND race_label IS NULL",
|
||||
(target_date,),
|
||||
)
|
||||
null_count = cursor.fetchone()["cnt"]
|
||||
|
||||
assert null_count == 0, (
|
||||
f"ANOMALIE : {null_count} ligne(s) avec race_label IS NULL "
|
||||
f"dans ml_predictions_cache pour le {target_date}. "
|
||||
"Vérifier le pipeline de patch de métadonnées."
|
||||
)
|
||||
|
||||
def test_zero_null_heure_today(self, db_connection, target_date):
|
||||
"""
|
||||
Vérifie que COUNT(*) WHERE date = today AND heure IS NULL = 0.
|
||||
|
||||
Vérifie que l'heure de course est bien renseignée pour toutes les prédictions.
|
||||
"""
|
||||
cursor_total = db_connection.execute(
|
||||
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
||||
(target_date,),
|
||||
)
|
||||
total = cursor_total.fetchone()["cnt"]
|
||||
if total == 0:
|
||||
pytest.skip(
|
||||
f"Aucune prédiction en cache pour le {target_date}. "
|
||||
"Lancer ce test après le job ML de 19h30."
|
||||
)
|
||||
|
||||
cursor = db_connection.execute(
|
||||
"SELECT COUNT(*) as cnt FROM ml_predictions_cache "
|
||||
"WHERE date = ? AND heure IS NULL",
|
||||
(target_date,),
|
||||
)
|
||||
null_count = cursor.fetchone()["cnt"]
|
||||
|
||||
assert null_count == 0, (
|
||||
f"ANOMALIE : {null_count} ligne(s) avec heure IS NULL "
|
||||
f"dans ml_predictions_cache pour le {target_date}. "
|
||||
"Vérifier le pipeline de patch de métadonnées."
|
||||
)
|
||||
|
||||
def test_full_metadata_coverage_today(self, db_connection, target_date):
|
||||
"""
|
||||
Test de couverture globale : aucune des trois colonnes critiques
|
||||
(hippodrome, race_label, heure) n'est NULL pour une même ligne.
|
||||
|
||||
Retourne les 5 premières lignes problématiques pour faciliter le débogage.
|
||||
"""
|
||||
cursor_total = db_connection.execute(
|
||||
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
||||
(target_date,),
|
||||
)
|
||||
total = cursor_total.fetchone()["cnt"]
|
||||
if total == 0:
|
||||
pytest.skip(
|
||||
f"Aucune prédiction en cache pour le {target_date}. "
|
||||
"Lancer ce test après le job ML de 19h30."
|
||||
)
|
||||
|
||||
cursor = db_connection.execute(
|
||||
"SELECT id, num_reunion, num_course, horse_name, hippodrome, race_label, heure "
|
||||
"FROM ml_predictions_cache "
|
||||
"WHERE date = ? "
|
||||
"AND (hippodrome IS NULL OR race_label IS NULL OR heure IS NULL) "
|
||||
"LIMIT 5",
|
||||
(target_date,),
|
||||
)
|
||||
bad_rows = cursor.fetchall()
|
||||
|
||||
assert len(bad_rows) == 0, (
|
||||
f"ANOMALIE : {len(bad_rows)} ligne(s) avec au moins une métadonnée NULL "
|
||||
f"(hippodrome, race_label ou heure) pour le {target_date}.\n"
|
||||
"Exemples de lignes affectées :\n"
|
||||
+ "\n".join(
|
||||
f" - id={r['id']} R{r['num_reunion']}C{r['num_course']} "
|
||||
f"{r['horse_name']} | hippodrome={r['hippodrome']!r} "
|
||||
f"race_label={r['race_label']!r} heure={r['heure']!r}"
|
||||
for r in bad_rows
|
||||
)
|
||||
)
|
||||
|
||||
def test_metadata_completeness_summary(self, db_connection, target_date):
|
||||
"""
|
||||
Résumé diagnostique : affiche les statistiques de complétude des métadonnées
|
||||
pour la date cible. Toujours en mode informatif (pas de assertion stricte),
|
||||
utile pour le monitoring et les logs CI.
|
||||
"""
|
||||
cursor = db_connection.execute(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN hippodrome IS NULL THEN 1 ELSE 0 END) as null_hippodrome,
|
||||
SUM(CASE WHEN race_label IS NULL THEN 1 ELSE 0 END) as null_race_label,
|
||||
SUM(CASE WHEN heure IS NULL THEN 1 ELSE 0 END) as null_heure,
|
||||
COUNT(DISTINCT hippodrome) as distinct_hippodromes,
|
||||
COUNT(DISTINCT race_label) as distinct_race_labels
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ?
|
||||
""",
|
||||
(target_date,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
total = row["total"]
|
||||
|
||||
if total == 0:
|
||||
pytest.skip(
|
||||
f"Aucune prédiction en cache pour le {target_date}. "
|
||||
"Lancer ce test après le job ML de 19h30."
|
||||
)
|
||||
|
||||
# Afficher les statistiques (visibles avec pytest -v -s)
|
||||
print(f"\n=== Statistiques ml_predictions_cache pour le {target_date} ===")
|
||||
print(f" Total lignes : {total}")
|
||||
print(f" NULL hippodrome : {row['null_hippodrome']}")
|
||||
print(f" NULL race_label : {row['null_race_label']}")
|
||||
print(f" NULL heure : {row['null_heure']}")
|
||||
print(f" Hippodromes distincts: {row['distinct_hippodromes']}")
|
||||
print(f" Race labels distincts: {row['distinct_race_labels']}")
|
||||
|
||||
# L'assertion ici reste stricte pour hippodrome (bug HRT-41 critique)
|
||||
assert row["null_hippodrome"] == 0, (
|
||||
f"RÉGRESSION HRT-41 : {row['null_hippodrome']}/{total} lignes "
|
||||
f"avec hippodrome IS NULL pour le {target_date}."
|
||||
)
|
||||
Reference in New Issue
Block a user