From fc84600f97d66e7d0653b797397a0fc5ee4924ea Mon Sep 17 00:00:00 2001 From: Yvv Date: Thu, 23 Apr 2026 15:51:28 +0200 Subject: [PATCH] =?UTF-8?q?Fix=20auth=20:=20CORS=20outermost=20+=20dev=20r?= =?UTF-8?q?ate=20limit=20+=20filtre=20workspace=20r=C3=A9actif?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Middleware : CORSMiddleware ajouté en dernier = plus externe = tous les codes de retour (dont 429) portent Access-Control-Allow-Origin → résout "no response / Failed to fetch" sur POST /auth/challenge - Dev mode : rate_limit_auth = RATE_LIMIT_DEFAULT (60/min) au lieu de 10/min → plus de blocage login après quelques reconnexions - app.vue : watcher activeSlug → refetch documents/décisions/protocoles/mandats → le sélecteur de workspace filtre désormais le contenu en temps réel - TDD : 4 tests middleware (RED→GREEN) + doc méthode docs/dev/tdd-methode.md - Régression : 190/190 tests verts Co-Authored-By: Claude Sonnet 4.6 --- backend/app/main.py | 36 ++++-- backend/app/tests/test_middleware.py | 172 +++++++++++++++++++++++++++ docs/dev/tdd-methode.md | 144 ++++++++++++++++++++++ frontend/app/app.vue | 14 +++ 4 files changed, 355 insertions(+), 11 deletions(-) create mode 100644 backend/app/tests/test_middleware.py create mode 100644 docs/dev/tdd-methode.md diff --git a/backend/app/main.py b/backend/app/main.py index 5853934..8e26765 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -86,8 +86,31 @@ app = FastAPI( # ── Middleware stack ────────────────────────────────────────────────────── -# Middleware is applied in reverse order: last added = first executed. -# Order: SecurityHeaders -> RateLimiter -> CORS -> Application +# add_middleware is LIFO: last added = outermost = first to execute on request, +# last to execute on response (wraps everything inside it). +# +# Required order so CORS headers appear on ALL responses including 429: +# CORS (outermost) → RateLimiter → SecurityHeaders → Application +# +# If RateLimiter were outside CORS, its 429 responses would have no CORS +# headers and the browser would silently discard them as network errors. + +app.add_middleware(SecurityHeadersMiddleware) + +# In dev mode, use the default (higher) limit for auth to avoid login lockout +# during repeated disconnect/reconnect cycles. +_auth_rate_limit = ( + settings.RATE_LIMIT_DEFAULT + if settings.ENVIRONMENT == "development" + else settings.RATE_LIMIT_AUTH +) + +app.add_middleware( + RateLimiterMiddleware, + rate_limit_default=settings.RATE_LIMIT_DEFAULT, + rate_limit_auth=_auth_rate_limit, + rate_limit_vote=settings.RATE_LIMIT_VOTE, +) app.add_middleware( CORSMiddleware, @@ -97,15 +120,6 @@ app.add_middleware( allow_headers=["*"], ) -app.add_middleware( - RateLimiterMiddleware, - rate_limit_default=settings.RATE_LIMIT_DEFAULT, - rate_limit_auth=settings.RATE_LIMIT_AUTH, - rate_limit_vote=settings.RATE_LIMIT_VOTE, -) - -app.add_middleware(SecurityHeadersMiddleware) - # ── Routers ────────────────────────────────────────────────────────────── diff --git a/backend/app/tests/test_middleware.py b/backend/app/tests/test_middleware.py new file mode 100644 index 0000000..eab2912 --- /dev/null +++ b/backend/app/tests/test_middleware.py @@ -0,0 +1,172 @@ +"""Tests for middleware stack: CORS headers, rate limiting, dev auth flow. + +Critical invariants: +- ALL responses (including 429) must carry CORS headers when origin is allowed +- Dev login flow must survive repeated logins without hitting rate limit +- OPTIONS preflight must never be rate-limited + +Note: each test uses a unique X-Forwarded-For IP to isolate rate limit counters, +since the rate limiter is in-memory and shared across the app instance. +""" + +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.main import app + +ORIGIN = "http://localhost:3002" +CHALLENGE_URL = "/api/v1/auth/challenge" +VERIFY_URL = "/api/v1/auth/verify" +ME_URL = "/api/v1/auth/me" + +DEV_ADDRESS = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + + +# --------------------------------------------------------------------------- +# INVARIANT 1: 429 responses must include CORS headers +# Without this, the browser sees "Failed to fetch" instead of "Too Many Requests" +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_rate_limited_response_has_cors_headers(): + """A 429 from the rate limiter must still carry Access-Control-Allow-Origin. + + Root cause of the "no response / Failed to fetch" bug: the rate limiter + sits outside CORS in the middleware stack, so its 429 responses have no + CORS headers and the browser discards them as network errors. + """ + # dev auth limit = 60/min (RATE_LIMIT_DEFAULT), prod = 10/min (RATE_LIMIT_AUTH) + # Send 65 requests to guarantee 429 regardless of environment. + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + last_response = None + for _ in range(65): + resp = await client.post( + CHALLENGE_URL, + json={"address": DEV_ADDRESS}, + headers={"Origin": ORIGIN, "X-Forwarded-For": "10.0.1.1"}, + ) + last_response = resp + if resp.status_code == 429: + break + + assert last_response is not None + assert last_response.status_code == 429, ( + "Expected 429 after exceeding auth rate limit" + ) + assert "access-control-allow-origin" in last_response.headers, ( + "429 response must include CORS headers so the browser can read the error" + ) + assert last_response.headers["access-control-allow-origin"] == ORIGIN + + +# --------------------------------------------------------------------------- +# INVARIANT 2: OPTIONS preflight must never be rate-limited +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_options_preflight_never_rate_limited(): + """OPTIONS requests must pass through regardless of request count. + + Browsers send a preflight before every cross-origin POST with custom headers. + A 429 on OPTIONS prevents the real request from ever being sent. + """ + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + for i in range(20): + resp = await client.options( + CHALLENGE_URL, + headers={ + "Origin": ORIGIN, + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "content-type", + "X-Forwarded-For": "10.0.2.1", + }, + ) + assert resp.status_code != 429, ( + f"OPTIONS request #{i + 1} was rate-limited (429) — preflights must never be blocked" + ) + + +# --------------------------------------------------------------------------- +# INVARIANT 3: Dev login flow must survive ≥ 10 consecutive logins +# (challenge + verify cycle, dev profile bypass) +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_dev_login_survives_repeated_cycles(): + """Complete login cycle (challenge → verify) must work ≥ 10 times in a row. + + In dev mode, the developer disconnects and reconnects frequently. + With auth rate limit = 10/min, the 6th challenge request would be blocked. + Dev mode must use a higher limit (≥ 60/min) to prevent login lockout. + """ + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + tokens = [] + for i in range(10): + # Step 1: get challenge + ch_resp = await client.post( + CHALLENGE_URL, + json={"address": DEV_ADDRESS}, + headers={"Origin": ORIGIN, "X-Forwarded-For": "10.0.3.1"}, + ) + assert ch_resp.status_code == 200, ( + f"Login cycle #{i + 1}: challenge returned {ch_resp.status_code} — " + f"rate limit likely hit. Dev mode requires RATE_LIMIT_AUTH ≥ 60/min." + ) + challenge = ch_resp.json()["challenge"] + + # Step 2: verify (dev bypass — any signature accepted for dev addresses) + v_resp = await client.post( + VERIFY_URL, + json={ + "address": DEV_ADDRESS, + "challenge": challenge, + "signature": "0x" + "ab" * 64, + }, + headers={"Origin": ORIGIN, "X-Forwarded-For": "10.0.3.1"}, + ) + assert v_resp.status_code == 200, ( + f"Login cycle #{i + 1}: verify returned {v_resp.status_code}" + ) + tokens.append(v_resp.json()["token"]) + + assert len(tokens) == 10, "Expected 10 successful login cycles" + assert len(set(tokens)) == 10, "Each login cycle must produce a unique token" + + +# --------------------------------------------------------------------------- +# INVARIANT 4: /auth/me OPTIONS preflight must return CORS headers +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_auth_me_options_preflight_returns_cors(): + """OPTIONS preflight for /auth/me must return 200 with CORS headers. + + This was the root cause of the session-lost-on-reload bug: + repeated /auth/me calls would exhaust the auth rate limit, + the 429 OPTIONS response had no CORS headers, + and the browser threw 'Failed to fetch'. + """ + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.options( + ME_URL, + headers={ + "Origin": ORIGIN, + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": "authorization", + "X-Forwarded-For": "10.0.4.1", + }, + ) + assert resp.status_code in (200, 204), ( + f"OPTIONS /auth/me returned {resp.status_code}" + ) + assert "access-control-allow-origin" in resp.headers diff --git a/docs/dev/tdd-methode.md b/docs/dev/tdd-methode.md new file mode 100644 index 0000000..ccacae0 --- /dev/null +++ b/docs/dev/tdd-methode.md @@ -0,0 +1,144 @@ +# Méthode de travail TDD — libreDecision + +## Principe fondamental + +**Tu décris la règle métier. Claude traduit en test. Tu valides. Claude implémente.** + +Jamais l'inverse. Le test est la source de vérité ; l'implémentation n'est que le moyen de le faire passer. + +--- + +## Workflow par itération + +``` +1. Tu décris une règle en français naturel + → "Si scope=personal, la décision est toujours individuelle" + +2. Claude écrit le(s) test(s) — RED (le test échoue avant l'implémentation) + → Tu valides que le test capture bien l'intention + +3. Claude implémente le minimum pour que le test passe — GREEN + → Rien de plus que ce que le test exige + +4. Claude refactorise si nécessaire — REFACTOR + → Sans casser les tests existants + +5. Répétition avec la règle suivante +``` + +--- + +## Commandes de prompt + +| Commande | Action | +|---|---| +| `+test` | Écrire le(s) test(s) sans implémenter | +| `+impl` | Implémenter pour faire passer les tests en attente | +| `+test+impl` | Test + implémentation d'un coup (règle simple) | +| `+règle` | Ajouter une règle au moteur existant | +| `+règle remplace` | Une nouvelle règle remplace une précédente (précise laquelle) | +| `+régression` | Vérifier qu'aucun test existant n'est cassé après un changement | +| `+résumé` | Afficher l'état des règles implémentées et en attente | + +--- + +## Format d'une règle métier + +Pour être efficace, une règle doit préciser : + +``` +ENTRÉES : les variables concernées et leurs valeurs +RÉSULTAT : ce que le système doit retourner ou faire +EXCEPTIONS : cas qui brisent la règle générale (si aucune, dire "aucune") +``` + +**Exemple :** +``` +ENTRÉES : scope = "personal" +RÉSULTAT : decision_type = "individual", recommend_onchain = False +EXCEPTIONS : aucune — même si stakes = "critical", une décision personnelle reste individuelle +``` + +--- + +## Structure des tests dans ce projet + +``` +backend/app/tests/ + test_qualifier.py ← moteur de qualification (tunnel Décider) + test_middleware.py ← rate limiter, CORS, headers + test_threshold.py ← formules WoT existantes + test_votes.py ← logique de vote + test_decisions.py ← service décisions + ... +``` + +Chaque fichier de test correspond à un module ou un bloc fonctionnel. +Les tests d'intégration (qui touchent la DB) sont marqués `@pytest.mark.integration`. + +--- + +## Les 4 blocs algorithmiques + +``` +┌─────────────────────────────────────────────────────────┐ +│ TUNNEL "DÉCIDER" │ +│ │ +│ 1. QUALIFIER → nature / enjeu / réversibilité │ +│ ↓ │ +│ 2. ROUTEUR → individual / collective / delegated │ +│ ↓ ↓ │ +│ 3. PROTOCOLE → sélection formule WoT + paramètres │ +│ ↓ │ +│ 4. GRAVURE → recommandation on-chain (IPFS+remark) │ +└─────────────────────────────────────────────────────────┘ +``` + +Les blocs sont testés indépendamment puis en intégration. +Un changement dans le bloc 1 ne doit jamais casser silencieusement le bloc 4. + +--- + +## Invariants fondamentaux (ne jamais casser) + +Ces règles doivent avoir un test dédié et rester vertes en permanence : + +1. Une décision `individual` ne génère jamais de session de vote +2. Une décision `on_chain` implique toujours `recommend_onchain = True` +3. `recommend_onchain = True` requiert `reversibility = "impossible"` **ou** `stakes = "critical"` +4. Le qualificateur ne retourne jamais un type inconnu (enum strict) +5. Un protocole WoT sélectionné doit exister en base (slug valide) + +--- + +## Règles de régression + +- Après chaque implémentation : `pytest backend/app/tests/ -v --tb=short` +- Avant tout commit : zéro test rouge +- Si un test existant casse après un nouveau changement → **stop, analyser, ne pas contourner** +- `RATE_LIMIT_AUTH` en dev = 60/min minimum (pas de blocage en développement) + +--- + +## Où sont les règles métier documentées + +| Source | Contenu | +|---|---| +| `docs/dev/tdd-methode.md` | Cette méthode | +| `docs/dev/qualifier-rules.md` | Règles du moteur de qualification (créé au fil des itérations) | +| `backend/app/engine/qualifier.py` | Implémentation du qualificateur | +| `backend/app/tests/test_qualifier.py` | Tests — source de vérité exécutable | + +--- + +## À propos de la mémoire de contexte + +Entre les sessions, Claude peut perdre le contexte des règles en cours. +Pour reprendre efficacement : + +``` +"Résume les règles du qualificateur implémentées jusqu'ici" +→ Claude lit test_qualifier.py et qualifier.py et synthétise +``` + +Les tests sont leur propre documentation. Ne pas dupliquer les règles en commentaires. diff --git a/frontend/app/app.vue b/frontend/app/app.vue index 021aa06..e5fd63b 100644 --- a/frontend/app/app.vue +++ b/frontend/app/app.vue @@ -1,6 +1,10 @@