v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/
- crates/ld-engine : formule inertielle (parité avec la maquette, garde C), coquille / changement de fond, assistant d'édition à règles, composition d'un document sous vote permanent (adoptions, aperçus, rivales). - crates/trustin-access : qui voit, qui ouvre, qui gère les accès. - crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de connect.monnaie-libre.fr), invitations, entrée en vigueur automatique, signalements de contradiction, traductions éditoriales, publication git, taille de la toile lue sur la chaîne. - seeds/acte-engagement-certification.json : 33 articles, fr + en + es ; en vigueur : préambule, E1, E2 (seed a minima). - maquette/ : frontend, backend et docker v2 déplacés tels quels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
"""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
|
||||
|
||||
import app.models # noqa: F401 — registers all models with Base.metadata before create_all
|
||||
from app.database import init_db
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
async def _create_tables():
|
||||
"""Create DB tables once for this module.
|
||||
|
||||
ASGITransport does not trigger the FastAPI lifespan, so init_db() would
|
||||
never run. Tests that hit endpoints backed by the DB need the tables to
|
||||
exist beforehand.
|
||||
"""
|
||||
await init_db()
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user