allocate / backend /tests /test_security.py
studio-d-o's picture
Upload folder using huggingface_hub
ae688c7 verified
Raw
History Blame Contribute Delete
37.6 kB
"""Suite de tests sécurité / vie privée — Allo Chat.
Lancer depuis backend/ : ../.venv/bin/python -m pytest tests/ -v
Couvre les exigences issues des revues (interne + Gemini) avec les VRAIES routes :
énumération, signatures de photos, brute-force OTP, rate-limiting, purge EXIF,
absence de PII dans les réponses publiques, droit à l'oubli.
"""
from __future__ import annotations
import io
import os
import tempfile
# Environnement de test — AVANT l'import de l'app.
os.environ["CHATS_EMBEDDER"] = "baseline"
os.environ["CHATS_DETECT"] = "0"
os.environ["CHATS_VERIFY_ANIMAL"] = "0" # images de test synthétiques (pas d'animal réel)
os.environ["CHATS_DEV_OTP"] = "1"
os.environ["CHATS_ADMIN_KEY"] = "test-admin-key"
os.environ.pop("CHATS_RESEND_KEY", None)
os.environ["DATABASE_URL"] = f"sqlite:///{tempfile.mkdtemp()}/test.db"
import pytest # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from PIL import Image # noqa: E402
from app.main import app # noqa: E402
ADMIN = {"X-Admin-Key": "test-admin-key"}
# --- Reproduit la contrainte VARCHAR(n) de Postgres dans les tests SQLite -------
# SQLite IGNORE les longueurs de colonnes ; Postgres (Neon) les applique et lève
# StringDataRightTruncation (→ 500). C'est ce qui a masqué le bug du téléphone
# chiffré (~150 car.) stocké dans une colonne VARCHAR(32). On installe un garde
# qui simule la contrainte en test → toute régression de troncation est attrapée
# en CI (même principe que le PRAGMA foreign_keys=ON pour les clés étrangères).
from sqlalchemy import String as _String, event as _event # noqa: E402
from sqlalchemy.orm import Session as _SASession # noqa: E402
def _enforce_varchar_lengths(session, flush_context, instances): # noqa: ANN001
for obj in list(session.new) + list(session.dirty):
table = getattr(type(obj), "__table__", None)
if table is None:
continue
for col in table.columns:
length = getattr(col.type, "length", None)
if isinstance(col.type, _String) and length:
val = getattr(obj, col.key, None)
if isinstance(val, str) and len(val) > length:
raise ValueError(
f"VARCHAR overflow simulé (Postgres) : {table.name}.{col.key} "
f"= {len(val)} caractères > {length}")
_event.listen(_SASession, "before_flush", _enforce_varchar_lengths)
def jpg(color=(200, 120, 40), exif: bool = False) -> bytes:
img = Image.new("RGB", (220, 220), color)
buf = io.BytesIO()
if exif:
ex = img.getexif()
ex[0x010F] = "TestCam GPS leak" # Make
ex[0x0110] = "ModelX" # Model
img.save(buf, "JPEG", exif=ex.tobytes())
else:
img.save(buf, "JPEG")
return buf.getvalue()
def ip(n: int) -> dict:
"""IP distincte par test → buckets de rate-limit isolés."""
return {"X-Forwarded-For": f"10.0.0.{n}"}
@pytest.fixture(scope="module")
def client():
with TestClient(app) as c:
yield c
def sight(client, post_resp):
"""POST /sightings est ASYNCHRONE → récupère le résultat analysé via le sondage.
En test, la tâche d'arrière-plan s'achève avant le retour du POST (TestClient
attend tout le cycle ASGI), donc le 1er sondage renvoie déjà « ready »."""
d = post_resp.json()
tok = d.get("result_token")
if not tok:
return d # pas async (erreur amont, ou réponse déjà complète)
sid = d["sighting_id"]
for _ in range(50):
rr = client.get(f"/sightings/{sid}/result", params={"token": tok})
if rr.status_code == 200 and rr.json().get("status") != "analyzing":
return rr.json()
return d
# --- Énumération / listing ---------------------------------------------------
def test_no_public_listing(client):
assert client.get("/cats", headers=ip(1)).status_code in (403, 404, 405)
assert client.get("/cats?limit=10&offset=0", headers=ip(1)).status_code in (403, 404, 405)
r = client.get("/cats/", headers=ip(1), follow_redirects=True)
assert r.status_code in (403, 404, 405)
def test_photo_enumeration_blocked(client):
r = client.post("/cats", data={"name": "Enum", "email": "enum@t.ca"},
files=[("photos", ("a.jpg", jpg(), "image/jpeg"))], headers=ip(2))
assert r.status_code == 200
ov = client.get("/admin/api/overview", headers={**ADMIN, **ip(2)}).json()
url = ov["cats"][0]["photos"][0]
assert "?s=" in url, "les URLs de photos doivent être signées"
assert client.get(url, headers=ip(2)).status_code == 200
base = url.split("?")[0]
assert client.get(base, headers=ip(2)).status_code == 403
assert client.get(base + "?s=deadbeef00000000", headers=ip(2)).status_code == 403
def test_admin_requires_key(client):
assert client.get("/admin/api/overview", headers=ip(3)).status_code == 403
assert client.get("/admin/api/overview",
headers={"X-Admin-Key": "faux", **ip(3)}).status_code == 403
def test_admin_email_otp_flow(client):
"""L'accès admin exige un code (envoyé à l'email admin), pas une clé tapée."""
rc = client.post("/admin/request-code", headers=ip(12)).json()
assert rc["dev_code"], "code admin attendu en mode dev"
assert client.post("/admin/verify-code", data={"code": "000000"},
headers=ip(12)).status_code == 400
# H4 — anti email-bombing : re-demander un code dans les 45 s ne réenvoie rien
# (message neutre, dev_code=None) et N'INVALIDE PAS le code déjà émis. Un essai
# raté (attempts < max) ne brûle pas l'OTP → le premier code reste valide.
again = client.post("/admin/request-code", headers=ip(12)).json()
assert again["dev_code"] is None, "aucun nouveau code pendant le cooldown"
v = client.post("/admin/verify-code", data={"code": rc["dev_code"]}, headers=ip(12))
assert v.status_code == 200
# C2 : un TOKEN DE SESSION opaque est remis, jamais la clé admin brute.
key = v.json()["session_token"]
assert client.get("/admin/api/overview",
headers={"X-Admin-Key": key, **ip(12)}).status_code == 200
# code à usage unique
assert client.post("/admin/verify-code", data={"code": rc["dev_code"]},
headers=ip(12)).status_code in (400, 429)
# --- OTP : brute force + rate limiting ---------------------------------------
def test_otp_brute_force_locked(client):
email = "brute@t.ca"
client.post("/cats", data={"name": "B", "email": email}, headers=ip(4))
rc = client.post("/cats/request-code", data={"email": email}, headers=ip(4)).json()
assert rc["dev_code"]
codes = []
for _ in range(6):
r = client.post("/cats/verify-code",
data={"email": email, "code": "000000"}, headers=ip(4))
codes.append(r.status_code)
assert all(c in (400, 429) for c in codes)
assert 429 in codes, "verrouillage attendu après tentatives répétées"
# même le BON code est mort après le verrouillage (OTP détruit)
r = client.post("/cats/verify-code",
data={"email": email, "code": rc["dev_code"]}, headers=ip(4))
assert r.status_code in (400, 429)
def test_request_code_rate_limited(client):
for i in range(5):
assert client.post("/cats/request-code", data={"email": "rl@t.ca"},
headers=ip(5)).status_code == 200
assert client.post("/cats/request-code", data={"email": "rl@t.ca"},
headers=ip(5)).status_code == 429
def test_otp_single_use(client):
email = "single@t.ca"
client.post("/cats", data={"name": "S", "email": email}, headers=ip(6))
code = client.post("/cats/request-code", data={"email": email},
headers=ip(6)).json()["dev_code"]
assert client.post("/cats/verify-code", data={"email": email, "code": code},
headers=ip(6)).status_code == 200
assert client.post("/cats/verify-code", data={"email": email, "code": code},
headers=ip(6)).status_code in (400, 429)
# --- Vie privée ---------------------------------------------------------------
def test_exif_stripped_on_upload(client):
"""Les métadonnées EXIF (GPS, appareil…) doivent être purgées au stockage."""
raw = jpg(exif=True)
assert Image.open(io.BytesIO(raw)).getexif(), "le fichier de test doit avoir un EXIF"
client.post("/cats", data={"name": "Exif", "email": "exif@t.ca"},
files=[("photos", ("e.jpg", raw, "image/jpeg"))], headers=ip(7))
ov = client.get("/admin/api/overview", headers={**ADMIN, **ip(7)}).json()
cat = next(c for c in ov["cats"] if c["name"] == "Exif")
img = client.get(cat["photos"][0], headers=ip(7))
stored = Image.open(io.BytesIO(img.content))
assert not dict(stored.getexif()), "EXIF non purgé sur l'image stockée"
def test_no_low_confidence_candidates(client):
"""CHOIX PRODUIT : on n'affiche JAMAIS un candidat sous 50 % de ressemblance.
En dessous, la « correspondance » est trop incertaine — elle donne de faux
espoirs et décrédibilise l'IA. Mieux vaut « aucune correspondance »."""
from app import config
assert config.CANDIDATE_THRESHOLD >= 0.50, (
"le seuil d'affichage des candidats ne doit pas redescendre sous 50 %")
# Deux chats de couleurs opposées : le signalement ne doit rien proposer
# plutôt que de sortir un candidat faible.
client.post("/cats", data={"name": "LowConf", "email": "low@t.ca"},
files=[("photos", ("l.jpg", jpg((255, 0, 0)), "image/jpeg"))],
headers=ip(40))
sd = sight(client, client.post(
"/sightings", files=[("photos", ("m.jpg", jpg((0, 0, 255)), "image/jpeg"))],
headers=ip(40)))
for m in sd.get("matches", []):
assert float(m["confidence"]) >= 0.50, (
f"candidat sous 50 % renvoyé au trouveur : {m['confidence']}")
def test_finder_response_has_no_pii(client):
client.post("/cats", data={"name": "Pii", "email": "secret@t.ca",
"phone": "514-000-0000", "last_seen_lat": 45.5,
"last_seen_lng": -73.5},
files=[("photos", ("p.jpg", jpg((10, 200, 80)), "image/jpeg"))],
headers=ip(8))
sd = sight(client, client.post("/sightings",
files=[("photos", ("q.jpg", jpg((12, 198, 82)), "image/jpeg"))],
headers=ip(8)))
assert sd["matches"], "un candidat est attendu (même couleur, baseline)"
m = next(m for m in sd["matches"] if m["name"] == "Pii")
leaked = {"email", "phone", "contact_info", "last_seen_lat", "last_seen_lng",
"claim_token"} & set(m.keys())
assert not leaked, f"PII exposée au trouveur : {leaked}"
# Audit MIT #10 — la vue trouveur ne doit pas fuiter les attributs internes
# de détection (boîte, méthode) des photos du propriétaire ; seul un résumé
# lisible du pelage est toléré.
for ph in m.get("photos", []):
attrs = ph.get("attributes") or {}
assert "detection" not in attrs and "box" not in attrs, \
f"attributs de détection fuités au trouveur : {attrs}"
assert set(attrs.keys()) <= {"summary"}, \
f"attributs inattendus exposés au trouveur : {attrs}"
def test_candidate_description_redacts_contact(client):
"""La description montrée au trouveur ne doit pas exposer un téléphone/courriel
glissé en texte libre par le propriétaire (promesse de confidentialité)."""
desc = "Tortie timide. Contactez-moi au 514-555-1234 ou jean@exemple.com"
client.post("/cats", data={"name": "Redact", "email": "redact@t.ca",
"description": desc},
files=[("photos", ("r.jpg", jpg((40, 160, 210)), "image/jpeg"))],
headers=ip(20))
sd = sight(client, client.post("/sightings",
files=[("photos", ("s.jpg", jpg((42, 158, 208)), "image/jpeg"))],
headers=ip(20)))
m = next((m for m in sd.get("matches", []) if m["name"] == "Redact"), None)
assert m, "un candidat est attendu (même couleur, baseline)"
d = m.get("description") or ""
assert "514-555-1234" not in d and "jean@exemple.com" not in d, \
f"contact non masqué dans la description : {d!r}"
assert "[masqué]" in d, "le masquage doit laisser une trace explicite"
def test_candidate_color_pattern_redacts_contact(client):
"""Audit MIT #11 — color/pattern montrés au trouveur ne doivent pas exposer
un contact glissé dans ces champs libres par le propriétaire."""
client.post("/cats", data={"name": "ColorPii", "email": "cp@t.ca",
"color": "noir, joignable au 514-555-9876",
"pattern": "écrivez à cp@exemple.com"},
files=[("photos", ("c.jpg", jpg((33, 99, 180)), "image/jpeg"))],
headers=ip(40))
sd = sight(client, client.post("/sightings",
files=[("photos", ("d.jpg", jpg((35, 97, 178)), "image/jpeg"))],
headers=ip(40)))
m = next((m for m in sd.get("matches", []) if m["name"] == "ColorPii"), None)
assert m, "un candidat est attendu (même couleur, baseline)"
blob = f"{m.get('color') or ''} {m.get('pattern') or ''}"
assert "514-555-9876" not in blob and "cp@exemple.com" not in blob, \
f"contact non masqué dans color/pattern : {blob!r}"
assert "[masqué]" in blob, "le masquage doit laisser une trace explicite"
def test_match_radius_is_bounded(client):
"""Audit MIT #4 — un radius_km démesuré ne doit pas neutraliser le filtre géo
(oracle visuel sur toute la base). Deux chats éloignés de ~900 km ne doivent
pas matcher même avec un rayon de 100000 km (clampé à MAX_MATCH_RADIUS_KM)."""
# Chat perdu à Montréal.
client.post("/cats", data={"name": "FarCat", "email": "far@t.ca",
"last_seen_lat": 45.50, "last_seen_lng": -73.56},
files=[("photos", ("f.jpg", jpg((70, 130, 190)), "image/jpeg"))],
headers=ip(41))
# Signalement à Toronto (~500 km) avec un rayon absurde.
sd = sight(client, client.post("/sightings",
data={"lat": 43.65, "lng": -79.38, "radius_km": 100000},
files=[("photos", ("g.jpg", jpg((72, 128, 188)), "image/jpeg"))],
headers=ip(41)))
names = [m["name"] for m in sd.get("matches", [])]
assert "FarCat" not in names, \
"le rayon démesuré aurait dû être clampé → chat hors zone écarté"
def test_sighting_async_result_token_required(client):
"""L'analyse d'un signalement est ASYNCHRONE : le POST répond tout de suite
(status=analyzing + result_token) ; le sondage du résultat EXIGE le bon token
(empêche un tiers de lire les correspondances d'autrui par énumération d'id)."""
r = client.post("/sightings",
files=[("photos", ("a.jpg", jpg((100, 50, 25)), "image/jpeg"))],
headers=ip(58))
d = r.json()
assert d["status"] == "analyzing" and d["result_token"], "le POST doit répondre sans attendre l'IA"
sid = d["sighting_id"]
assert client.get(f"/sightings/{sid}/result",
params={"token": "z" * 24}).status_code == 403, "mauvais token → 403"
rr = client.get(f"/sightings/{sid}/result", params={"token": d["result_token"]})
assert rr.status_code == 200 and rr.json()["status"] == "ready", "bon token → résultat prêt"
def test_security_headers_present(client):
r = client.get("/health", headers=ip(21))
assert r.headers.get("X-Content-Type-Options") == "nosniff"
assert r.headers.get("X-Frame-Options") == "DENY"
assert r.headers.get("Referrer-Policy") == "no-referrer"
def test_admin_dashboard_escapes_html(client):
"""La page admin ne doit pas interpoler de données brutes : elle doit
contenir le helper d'échappement (anti-XSS stocké)."""
html = client.get("/admin", headers=ip(22)).text
assert "const esc=" in html, "helper d'échappement HTML attendu dans la page admin"
def test_bad_file_is_not_500(client):
r = client.post("/sightings",
files=[("photos", ("x.jpg", b"pas une image", "image/jpeg"))],
headers=ip(9))
assert r.status_code < 500
def test_training_requires_consent(client):
r = client.post("/training/photos",
files=[("photos", ("t.jpg", jpg((90, 90, 90)), "image/jpeg"))],
headers=ip(10))
assert r.status_code == 400
r = client.post("/training/photos", data={"consent": "1"},
files=[("photos", ("t.jpg", jpg((90, 90, 90)), "image/jpeg"))],
headers=ip(10))
assert r.status_code == 200
# --- Anti-abus : leads, uploads ----------------------------------------------
def test_lead_only_on_real_candidate(client):
"""On ne peut pas déposer un lead sur un cat_id qui n'est PAS candidat du signalement."""
# un chat existe (id réel), mais on crée un signalement avec une couleur opposée
client.post("/cats", data={"name": "Far", "email": "far@t.ca"},
files=[("photos", ("f.jpg", jpg((255, 0, 0)), "image/jpeg"))], headers=ip(12))
sd = sight(client, client.post("/sightings",
files=[("photos", ("d.jpg", jpg((0, 0, 255)), "image/jpeg"))],
headers=ip(12)))
# C4 : un lead cible un token signé sighting×cat, pas un cat_id brut. Un token
# bidon (ou pour un chat non-candidat) doit être refusé.
r = client.post(f"/sightings/{sd['sighting_id']}/lead",
json={"candidate_token": "bogus-token", "finder_contact": "x"},
headers=ip(12))
assert r.status_code == 403, "lead sur token non-candidat doit être refusé"
def test_lead_dedup_no_double_notify(client):
"""Audit MIT #7/#9 — un lead identique rejoué (double-clic, token déterministe)
ne crée pas un second signalement ni une seconde notification au propriétaire."""
client.post("/cats", data={"name": "Dedup", "email": "dedup@t.ca"},
files=[("photos", ("a.jpg", jpg((20, 200, 90)), "image/jpeg"))],
headers=ip(42))
sd = sight(client, client.post("/sightings",
files=[("photos", ("b.jpg", jpg((22, 198, 92)), "image/jpeg"))],
headers=ip(42)))
m = next((m for m in sd.get("matches", []) if m["name"] == "Dedup"), None)
assert m, "un candidat est attendu (même couleur, baseline)"
sid = sd["sighting_id"]
payload = {"candidate_token": m["candidate_token"],
"finder_contact": "trouveur@x.ca", "message": "vu ici"}
r1 = client.post(f"/sightings/{sid}/lead", json=payload, headers=ip(42))
r2 = client.post(f"/sightings/{sid}/lead", json=payload, headers=ip(42))
assert r1.status_code == 200 and r2.status_code == 200
assert r1.json()["ref"] == r2.json()["ref"], \
"le lead rejoué doit être idempotent (même réf, pas de doublon)"
def test_lead_message_length_bounded(client):
"""Audit MIT #7 — le message du trouveur (transmis par courriel au proprio)
est borné en longueur (anti-charge utile / flood)."""
client.post("/cats", data={"name": "LenCat", "email": "len@t.ca"},
files=[("photos", ("a.jpg", jpg((60, 60, 200)), "image/jpeg"))],
headers=ip(43))
sd = sight(client, client.post("/sightings",
files=[("photos", ("b.jpg", jpg((62, 62, 198)), "image/jpeg"))],
headers=ip(43)))
m = next((m for m in sd.get("matches", []) if m["name"] == "LenCat"), None)
assert m, "un candidat est attendu (même couleur, baseline)"
r = client.post(f"/sightings/{sd['sighting_id']}/lead",
json={"candidate_token": m["candidate_token"],
"message": "x" * 5000}, headers=ip(43))
assert r.status_code == 422, "un message de 5000 caractères doit être rejeté (max 1000)"
# --- Régression : upload multi-images + colonnes Postgres (bug 500 « enc1: » phone) ---
def test_phone_column_is_unbounded_text(client):
"""Verrou structurel : le téléphone est CHIFFRÉ (~150 car.) → la colonne doit
être Text/illimitée. VARCHAR(32) faisait planter l'INSERT sur Postgres (500)."""
from app.models import Cat
length = getattr(Cat.__table__.c.phone.type, "length", None)
assert not length or length >= 200, \
f"cats.phone doit être Text/illimité (valeur chiffrée ~150 car.) — length={length}"
def test_declare_cat_with_phone_and_multiple_photos(client):
"""LE scénario du bug : déclaration AVEC téléphone + plusieurs photos. Le
téléphone chiffré dépassait VARCHAR(32) → 500. Avec la colonne Text + le garde
de longueur ci-dessus, l'insert doit réussir et toutes les photos être prises."""
files = [("photos", (f"p{i}.jpg", jpg((40 + i * 10, 120, 200 - i * 10)), "image/jpeg"))
for i in range(5)]
r = client.post("/cats",
data={"name": "Multi", "email": "multi@t.ca", "phone": "514-555-0188"},
files=files, headers=ip(50))
assert r.status_code == 200, r.text
assert r.json()["photos_count"] == 5
def test_sighting_with_multiple_photos(client):
files = [("photos", (f"s{i}.jpg", jpg((10, 150 + i * 8, 60)), "image/jpeg"))
for i in range(5)]
r = client.post("/sightings", files=files, headers=ip(51))
assert r.status_code == 200, r.text
def test_poster_with_multiple_photos(client):
files = [("photos", (f"a{i}.jpg", jpg((200 - i * 5, 90, 120)), "image/jpeg"))
for i in range(5)]
r = client.post("/cats", data={"name": "Affiche", "source": "poster"},
files=files, headers=ip(52))
assert r.status_code == 200, r.text
assert r.json()["photos_count"] == 5
def test_long_freetext_inputs_do_not_500(client):
"""Une saisie libre très longue (name/color/pattern/breed) ne doit pas faire
500 sur Postgres : create_cat tronque à la taille de colonne (_clip)."""
r = client.post("/cats",
data={"name": "N" * 400, "email": "long@t.ca",
"color": "C" * 500, "pattern": "P" * 500, "breed": "B" * 500},
files=[("photos", ("x.jpg", jpg((9, 9, 9)), "image/jpeg"))],
headers=ip(53))
assert r.status_code == 200, r.text
def test_sighting_long_reporter_email_clamped(client):
"""Audit : reporter_email (Form libre) → reporter_contact VARCHAR(255). Une
saisie >255 car. doit être tronquée (clamp), pas faire 500 sur Postgres."""
r = client.post("/sightings", data={"reporter_email": "a" * 400},
files=[("photos", ("s.jpg", jpg((11, 12, 13)), "image/jpeg"))],
headers=ip(56))
assert r.status_code == 200, r.text
def test_training_long_label_group_clamped(client):
"""Audit : label_group (Form libre) → VARCHAR(120). Saisie >120 car. tronquée."""
r = client.post("/training/photos",
data={"consent": "1", "label_group": "g" * 400},
files=[("photos", ("t.jpg", jpg((90, 90, 90)), "image/jpeg"))],
headers=ip(57))
assert r.status_code == 200, r.text
def test_upload_skips_invalid_photo_keeps_valid(client):
"""Audit multi-images : une photo illisible parmi d'autres est SAUTÉE (pas de
500, pas d'abandon de tout l'envoi) ; la fiche garde les photos valides."""
files = [
("photos", ("ok.jpg", jpg((10, 20, 30)), "image/jpeg")),
("photos", ("bad.jpg", b"pas une image", "image/jpeg")),
("photos", ("ok2.jpg", jpg((30, 20, 10)), "image/jpeg")),
]
r = client.post("/cats", data={"name": "Tol", "email": "tol@t.ca"},
files=files, headers=ip(54))
assert r.status_code == 200, r.text
assert r.json()["photos_count"] == 2, "la photo illisible doit être sautée, les 2 valides gardées"
def test_upload_all_invalid_photos_rejected(client):
"""Audit multi-images : si AUCUNE photo n'est exploitable → 422 explicite
(jamais une fiche vide ni une 500)."""
r = client.post("/cats", data={"name": "Bad", "email": "bad@t.ca"},
files=[("photos", ("b.jpg", b"nope pas une image", "image/jpeg"))],
headers=ip(55))
assert r.status_code == 422, r.text
def test_too_many_photos_rejected(client):
files = [("photos", (f"{i}.jpg", jpg(), "image/jpeg")) for i in range(20)]
r = client.post("/cats", data={"name": "Many", "email": "many@t.ca"},
files=files, headers=ip(13))
assert r.status_code == 413
def test_oversized_upload_rejected(client):
big = b"\xff\xd8\xff" + b"0" * (16 * 1024 * 1024) # > 15 Mo
r = client.post("/cats", data={"name": "Big", "email": "big@t.ca"},
files=[("photos", ("b.jpg", big, "image/jpeg"))], headers=ip(14))
assert r.status_code == 413
def test_email_cooldown(client):
"""Deux demandes de code rapprochées pour le même email → pas de 2e envoi."""
r1 = client.post("/cats/request-code", data={"email": "cool@t.ca"}, headers=ip(15)).json()
r2 = client.post("/cats/request-code", data={"email": "cool@t.ca"}, headers=ip(16)).json()
assert r1.get("dev_code") and not r2.get("dev_code"), "2e demande rapide ne doit pas renvoyer un nouveau code"
# --- Droit à l'oubli ----------------------------------------------------------
def test_right_to_be_forgotten(client):
r = client.post("/cats", data={"name": "Oubli", "email": "oubli@t.ca"},
files=[("photos", ("o.jpg", jpg((250, 30, 30)), "image/jpeg"))],
headers=ip(11))
token = r.json()["claim_token"]
sd = sight(client, client.post("/sightings",
files=[("photos", ("o2.jpg", jpg((248, 32, 33)), "image/jpeg"))],
headers=ip(11)))
target = next((m for m in sd.get("matches", []) if m["name"] == "Oubli"), None)
if target:
client.post(f"/sightings/{sd['sighting_id']}/lead",
json={"candidate_token": target["candidate_token"], "finder_contact": "x"},
headers=ip(11))
photo_url = target["photos"][0]["url"]
r = client.post("/cats/track/delete", data={"token": token}, headers=ip(11))
assert r.status_code == 200 and r.json()["deleted"]
# Audit #3 : le claim_token passe par l'en-tête X-Track-Token (plus en query).
assert client.get("/cats/track",
headers={"X-Track-Token": token, **ip(11)}).status_code == 404
if target:
assert client.get(photo_url, headers=ip(11)).status_code == 404, \
"les photos doivent disparaître avec la fiche"
# --- Correctifs de l'audit confidentialité/sécurité (2026-06) ----------------
def test_training_oversized_upload_rejected(client):
"""Audit #1 : /training/photos doit borner la taille comme les autres uploads."""
big = b"\xff\xd8\xff" + b"0" * (16 * 1024 * 1024) # > 15 Mo
r = client.post("/training/photos",
data={"consent": "1"},
files=[("photos", ("b.jpg", big, "image/jpeg"))], headers=ip(40))
assert r.status_code == 413
def test_training_data_retention_purges_old_photos(client):
"""Loi 25 : les photos d'entraînement trop vieilles sont purgées, et les
TrainingLabel qui les référencent sont supprimés D'ABORD (FK NO ACTION →
sinon violation FK sur Postgres ; PRAGMA foreign_keys=ON le simule en test)."""
import datetime as _dt
from app.database import SessionLocal
from app import retention, models
db = SessionLocal()
try:
old = _dt.datetime.now(_dt.timezone.utc) - _dt.timedelta(days=900)
pa = models.TrainingPhoto(filename="ra.jpg", content_type="image/jpeg",
embedding="[0.1]", created_at=old)
pb = models.TrainingPhoto(filename="rb.jpg", content_type="image/jpeg",
embedding="[0.2]", created_at=old)
db.add_all([pa, pb]); db.flush()
db.add(models.TrainingLabel(photo_a_id=pa.id, photo_b_id=pb.id, verdict="same"))
db.commit()
pa_id, pb_id = pa.id, pb.id
orig = retention.TRAINING_DAYS
retention.TRAINING_DAYS = 730 # 900 j > 730 → purge (et non désactivé)
try:
counts = retention.purge(db) # ne doit PAS lever (labels supprimés d'abord)
finally:
retention.TRAINING_DAYS = orig
assert counts.get("training_photos", 0) >= 2
assert db.get(models.TrainingPhoto, pa_id) is None
assert db.get(models.TrainingPhoto, pb_id) is None
assert db.query(models.TrainingLabel).count() == 0, "le label référençant doit avoir été purgé"
finally:
db.close()
def test_feedback_requires_admin(client):
"""Audit #6 : /feedback (oracle d'énumération) est réservé à l'admin."""
r = client.post("/feedback",
json={"photo_a_id": 1, "photo_b_id": 2, "verdict": "same"},
headers=ip(41))
assert r.status_code == 403
def test_event_lookup_requires_admin(client):
"""Audit : la recherche par référence ne doit jamais être publique."""
r = client.get("/admin/api/event/VU-000001", headers=ip(42))
assert r.status_code == 403
def test_health_does_not_leak_db_host(client):
"""Audit #21 : /health n'expose pas l'hôte Neon ni le backend ML."""
d = client.get("/health", headers=ip(43)).json()
assert d["status"] == "ok"
assert "@" not in d.get("db", ""), "l'hôte de la base ne doit pas fuiter"
assert "embedder" not in d
def test_description_redacts_url_and_address(client):
"""Audit #14 : URL et adresse civique masquées dans la description publique."""
desc = "Vu au 123 rue Saint-Denis. Infos sur http://mon-site.example/chat"
client.post("/cats", data={"name": "RedactURL", "email": "ru@t.ca",
"description": desc},
files=[("photos", ("r.jpg", jpg((90, 200, 90)), "image/jpeg"))],
headers=ip(44))
sd = sight(client, client.post("/sightings",
files=[("photos", ("s.jpg", jpg((92, 198, 92)), "image/jpeg"))],
headers=ip(44)))
m = next((m for m in sd.get("matches", []) if m["name"] == "RedactURL"), None)
assert m, "candidat attendu"
d = m.get("description") or ""
assert "mon-site.example" not in d and "123 rue" not in d, \
f"URL/adresse non masquée : {d!r}"
def test_forget_with_shared_sighting_multi_leads(client):
"""Régression (audit round 3) : un signalement partagé portant des leads vers
DEUX chats candidats ne doit pas bloquer la suppression de l'un (FK Postgres).
Avec PRAGMA foreign_keys=ON, ce test échouerait sur l'ancien delete_my_cat."""
# deux chats de couleur quasi identique → tous deux candidats du même signalement
ra = client.post("/cats", data={"name": "Jumeau A", "email": "ja@t.ca"},
files=[("photos", ("a.jpg", jpg((180, 90, 40)), "image/jpeg"))],
headers=ip(50))
client.post("/cats", data={"name": "Jumeau B", "email": "jb@t.ca"},
files=[("photos", ("b.jpg", jpg((181, 91, 41)), "image/jpeg"))],
headers=ip(50))
token_a = ra.json()["claim_token"]
sd = sight(client, client.post("/sightings",
files=[("photos", ("s.jpg", jpg((180, 90, 40)), "image/jpeg"))],
headers=ip(50)))
sid = sd["sighting_id"]
# déposer un lead sur CHAQUE candidat proposé (même signalement)
for m in sd.get("matches", []):
client.post(f"/sightings/{sid}/lead",
json={"candidate_token": m["candidate_token"], "finder_contact": "x"},
headers=ip(50))
# supprimer le chat A : ne doit PAS lever d'IntegrityError malgré le lead vers B
r = client.post("/cats/track/delete", data={"token": token_a}, headers=ip(50))
assert r.status_code == 200 and r.json()["deleted"], \
"la suppression doit réussir même avec un signalement partagé multi-leads"
def test_photos_encrypted_at_rest(client):
"""Option A : la photo est CHIFFRÉE en base (pas de JPEG en clair) mais se sert
déchiffrée via l'URL signée."""
from app import crypto, models
from app.database import SessionLocal
r = client.post("/cats", data={"name": "Crypt", "email": "crypt@t.ca",
"phone": "514-555-7777"},
files=[("photos", ("c.jpg", jpg((30, 60, 120)), "image/jpeg"))],
headers=ip(60))
assert r.status_code == 200
db = SessionLocal()
try:
cat = db.query(models.Cat).filter(models.Cat.email == "crypt@t.ca").first()
blob = cat.photos[0].data
assert blob.startswith(b"enc1:"), "le blob stocké doit être chiffré"
assert b"\xff\xd8\xff" not in blob[:8], "pas d'en-tête JPEG en clair"
assert crypto.decrypt_bytes(blob)[:3] == b"\xff\xd8\xff", "déchiffré = JPEG"
# téléphone chiffré en base, déchiffrable
assert cat.phone.startswith("enc1:") and crypto.decrypt_str(cat.phone) == "5145557777"
# contact_info ne contient PAS le téléphone en clair
assert "5145557777" not in (cat.contact_info or "")
finally:
db.close()
# la photo se sert bien déchiffrée
ov = client.get("/admin/api/overview", headers={**ADMIN, **ip(60)}).json()
cat_o = next(c for c in ov["cats"] if c["name"] == "Crypt")
img = client.get(cat_o["photos"][0], headers=ip(60))
assert img.status_code == 200 and img.content[:3] == b"\xff\xd8\xff"
assert cat_o["phone"] == "5145557777", "l'admin voit le téléphone déchiffré"
def test_retention_purge(client):
"""Purge de rétention (Loi 25) : fiche inactive + OTP expiré supprimés,
fiche récente conservée."""
from datetime import datetime, timezone, timedelta
from app import models, retention
from app.database import SessionLocal
old = datetime.now(timezone.utc) - timedelta(days=400)
cid_old = client.post("/cats", data={"name": "Vieux", "email": "vieux@t.ca"},
files=[("photos", ("v.jpg", jpg((11, 22, 33)), "image/jpeg"))],
headers=ip(70)).json()["id"]
cid_new = client.post("/cats", data={"name": "Recent", "email": "recent@t.ca"},
files=[("photos", ("r.jpg", jpg((44, 55, 66)), "image/jpeg"))],
headers=ip(70)).json()["id"]
db = SessionLocal()
try:
db.query(models.Cat).filter(models.Cat.id == cid_old).update(
{models.Cat.last_activity_at: old}, synchronize_session=False)
db.add(models.EmailOTP(email="exp@t.ca", code_hash="x",
expires_at=datetime.now(timezone.utc) - timedelta(hours=1)))
db.commit()
res = retention.purge(db)
finally:
db.close()
assert res["cats"] >= 1 and res["otps"] >= 1
db = SessionLocal()
try:
assert db.get(models.Cat, cid_old) is None, "fiche inactive doit être purgée"
assert db.get(models.Cat, cid_new) is not None, "fiche récente doit rester"
finally:
db.close()
def test_purge_requires_admin(client):
assert client.post("/admin/api/purge", headers=ip(71)).status_code == 403
def test_daily_image_quota(client):
"""Anti-flood : pas plus de N images/jour/IP (ici N=2 pour le test)."""
from app import ratelimit
orig = ratelimit._DAILY_IMAGE_LIMIT
ratelimit._DAILY_IMAGE_LIMIT = 2
try:
h = ip(80)
codes = []
for i in range(3):
r = client.post("/cats", data={"name": f"Q{i}", "email": "q@t.ca"},
files=[("photos", (f"{i}.jpg", jpg(), "image/jpeg"))], headers=h)
codes.append(r.status_code)
assert codes[0] == 200 and codes[1] == 200
assert codes[2] == 429, f"la 3e photo dépasse le quota de 2/jour : {codes}"
finally:
ratelimit._DAILY_IMAGE_LIMIT = orig
def test_animal_gate_disabled_in_tests():
"""En test la vérif animale est OFF → has_animal renvoie None (fail-open),
sans charger de modèle."""
from app.ml import detection
assert detection.has_animal(Image.new("RGB", (40, 40), (9, 9, 9))) is None
def test_color_compatibility():
"""Vérifie que des chats aux attributs de couleur polarisés subissent une pénalité."""
import json
from app import models
from app.matching import _color_compatibility
p_black = models.Photo(
attributes=json.dumps({"brightness": 0.15, "dominant_colors": [["noir", 0.9]]})
)
p_white = models.Photo(
attributes=json.dumps({"brightness": 0.85, "dominant_colors": [["blanc", 0.9]]})
)
p_similar = models.Photo(
attributes=json.dumps({"brightness": 0.80, "dominant_colors": [["blanc", 0.8]]})
)
factor_diff = _color_compatibility([p_black], [p_white])
factor_same = _color_compatibility([p_white], [p_similar])
assert factor_diff == 0.75, f"Expected 0.75 penalty for black vs white cat, got {factor_diff}"
assert factor_same == 1.0, f"Expected 1.0 for similar cats, got {factor_same}"