Fix auth : CORS outermost + dev rate limit + filtre workspace réactif
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -86,8 +86,31 @@ app = FastAPI(
|
|||||||
|
|
||||||
|
|
||||||
# ── Middleware stack ──────────────────────────────────────────────────────
|
# ── Middleware stack ──────────────────────────────────────────────────────
|
||||||
# Middleware is applied in reverse order: last added = first executed.
|
# add_middleware is LIFO: last added = outermost = first to execute on request,
|
||||||
# Order: SecurityHeaders -> RateLimiter -> CORS -> Application
|
# 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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -97,15 +120,6 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
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 ──────────────────────────────────────────────────────────────
|
# ── Routers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
172
backend/app/tests/test_middleware.py
Normal file
172
backend/app/tests/test_middleware.py
Normal file
@@ -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
|
||||||
144
docs/dev/tdd-methode.md
Normal file
144
docs/dev/tdd-methode.md
Normal file
@@ -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.
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const orgsStore = useOrganizationsStore()
|
const orgsStore = useOrganizationsStore()
|
||||||
|
const documentsStore = useDocumentsStore()
|
||||||
|
const decisionsStore = useDecisionsStore()
|
||||||
|
const protocolsStore = useProtocolsStore()
|
||||||
|
const mandatesStore = useMandatesStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { initMood } = useMood()
|
const { initMood } = useMood()
|
||||||
|
|
||||||
@@ -48,6 +52,16 @@ watch(() => route.path, () => {
|
|||||||
mobileMenuOpen.value = false
|
mobileMenuOpen.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** Refetch all content stores when the active workspace changes. */
|
||||||
|
watch(() => orgsStore.activeSlug, (newSlug, oldSlug) => {
|
||||||
|
if (oldSlug !== null && newSlug !== null && newSlug !== oldSlug) {
|
||||||
|
documentsStore.fetchAll()
|
||||||
|
decisionsStore.fetchAll()
|
||||||
|
protocolsStore.fetchProtocols()
|
||||||
|
mandatesStore.fetchAll()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
/** WebSocket connection and notifications. */
|
/** WebSocket connection and notifications. */
|
||||||
const ws = useWebSocket()
|
const ws = useWebSocket()
|
||||||
const { setupWsNotifications } = useNotifications()
|
const { setupWsNotifications } = useNotifications()
|
||||||
|
|||||||
Reference in New Issue
Block a user