"""Défaut 3b — /api/search Flowdeck depuis la console agent-dev. Cause prouvée en déployé (flowdeck-dev, 2026-10-28) : sur une réponse GET /api/search, le CORSMiddleware global pose déjà ``Access-Control-Allow-Credentials: true`` (comportement Starlette avec ``allow_credentials=True``, même hors allowlist), et l'ancien middleware de recherche EMPILAIT ses en-têtes par-dessus — d'où, pour l'origine agent-dev, ``Allow-Credentials`` en double (« true, true » ≠ « true » pour le contrôle CORS du navigateur → fetch rejeté → « injoignable (CORS ou réseau) » dans la console, alors que TikTak et Kanbante, sans ce cumul, répondent avec des en-têtes uniques) et, pour une origine sœur déjà dans la allowlist globale, ``Allow-Origin`` en double (rejet certain). Le correctif : le middleware de recherche REMPLACE les en-têtes CORS existants au lieu de les empiler (même patron que app/recherche_cors.py de la console). Ces tests pilotent le middleware en ASGI pur, avec une application interne qui imite le CORSMiddleware global (un en-tête Allow-Credentials déjà posé, et, pour une sœur, Allow-Origin aussi). ``sister_origins`` est figé sur les listes officielles (les fonctions pures sont déjà couvertes par tests/test_recherche.py) pour garder le test hermétique (aucune résolution draco_urls, aucune base). """ import asyncio import pytest from app.middleware import recherche_cors as rc ORIGINES = rc.PROD_ORIGINS | rc.DEV_ORIGINS | rc.AGENT_ORIGINS def _lancer(middleware, scope): """Exécute le middleware ASGI et retourne les messages envoyés.""" recus = [] async def recevoir(): return {"type": "http.request", "body": b"", "more_body": False} async def envoyer(message): recus.append(message) asyncio.run(middleware(scope, recevoir, envoyer)) return recus def _application_interne(entetes): async def appli(scope, recevoir, envoyer): await envoyer({"type": "http.response.start", "status": 401, "headers": list(entetes)}) await envoyer({"type": "http.response.body", "body": b"{}"}) return appli def _scope(methode, origine): return {"type": "http", "method": methode, "path": "/api/search", "headers": [(b"origin", origine.encode("latin-1")), (b"host", b"flowdeck-dev.dracodev.net")]} def _compter(entetes, nom): return sum(1 for k, _v in entetes if k == nom.encode("latin-1")) @pytest.fixture(autouse=True) def _origines_figees(monkeypatch): monkeypatch.setattr(rc, "sister_origins", lambda *a, **k: ORIGINES) def test_get_agent_dev_un_seul_jeu_d_entetes(): """Cas du défaut : globale (ACAC seul) + recherche → un seul jeu.""" interne = _application_interne([ (b"content-type", b"application/json"), (b"access-control-allow-credentials", b"true")]) recus = _lancer(rc.RechercheCorsMiddleware(interne), _scope("GET", "https://agent-dev.dracodev.net")) entetes = recus[0]["headers"] assert _compter(entetes, "access-control-allow-credentials") == 1 assert _compter(entetes, "access-control-allow-origin") == 1 assert _compter(entetes, "vary") == 1 assert (b"access-control-allow-origin", b"https://agent-dev.dracodev.net") in entetes assert (b"content-type", b"application/json") in entetes assert recus[0]["status"] == 401 # le statut traverse inchangé def test_get_origine_soeur_pas_d_allow_origin_double(): """Sœur déjà dans la allowlist globale : pas de doublon non plus.""" interne = _application_interne([ (b"access-control-allow-origin", b"https://og-dev.dracodev.net"), (b"access-control-allow-credentials", b"true"), (b"vary", b"Origin")]) recus = _lancer(rc.RechercheCorsMiddleware(interne), _scope("GET", "https://og-dev.dracodev.net")) entetes = recus[0]["headers"] assert _compter(entetes, "access-control-allow-origin") == 1 assert _compter(entetes, "access-control-allow-credentials") == 1 assert _compter(entetes, "vary") == 1 def test_origine_non_autorisee_traverse_sans_modification(): """Une origine refusée ne reçoit aucun Allow-Origin ajouté.""" interne = _application_interne([ (b"access-control-allow-credentials", b"true")]) recus = _lancer(rc.RechercheCorsMiddleware(interne), _scope("GET", "https://example.com")) entetes = recus[0]["headers"] assert _compter(entetes, "access-control-allow-origin") == 0 assert entetes == [(b"access-control-allow-credentials", b"true")] def test_preflight_agent_dev_toujours_unique(): """Le preflight (court-circuit avant l'interne) reste propre.""" recus = _lancer(rc.RechercheCorsMiddleware(_application_interne([])), _scope("OPTIONS", "https://agent-dev.dracodev.net")) entetes = recus[0]["headers"] assert recus[0]["status"] == 204 assert _compter(entetes, "access-control-allow-origin") == 1 assert _compter(entetes, "access-control-allow-credentials") == 1