From 8cd5bfe821eb6790470a442ef6cef48b981a8c8c Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 29 Jun 2026 15:46:30 +0200 Subject: [PATCH] fix(csrf): supprimer prevent_initial_call=True sur _fill_csrf_inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avec prevent_initial_call=True, le callback ne s'exécutait pas lors de la chaîne initiale (_generate_csrf_token → csrf-token), laissant le champ csrf_token vide au premier chargement direct de /connexion → erreur 400. Ajoute des tests comportementaux avec CSRF activé (comme en production) et un test architectural qui vérifie que le callback reste appelable initialement. Corrige aussi les assertions de redirection post-login (/compte/abonnement). Co-Authored-By: Claude Sonnet 4.6 --- src/app.py | 1 - tests/auth/conftest.py | 28 +++++++++++++++++++++++++++ tests/auth/test_login.py | 31 ++++++++++++++++++++++++++++-- tests/test_csrf_architecture.py | 34 +++++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 3 deletions(-) diff --git a/src/app.py b/src/app.py index 64d848a..597c071 100644 --- a/src/app.py +++ b/src/app.py @@ -297,7 +297,6 @@ def _generate_csrf_token(*_): @callback( Output({"type": "csrf-input", "index": ALL}, "value"), Input("csrf-token", "data"), - prevent_initial_call=True, ) def _fill_csrf_inputs(token): return [token] * len(ctx.outputs_list) diff --git a/tests/auth/conftest.py b/tests/auth/conftest.py index 02134a0..b4d3ab1 100644 --- a/tests/auth/conftest.py +++ b/tests/auth/conftest.py @@ -33,6 +33,34 @@ def client(app): return app.test_client() +@pytest.fixture +def csrf_app(users_db_path, monkeypatch): + """App Flask avec protection CSRF activée, comme en production.""" + from flask import Flask + from flask_wtf.csrf import generate_csrf + + from src.auth.setup import init_auth + + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("LINKEDIN_CLIENT_ID", "test-client-id") + monkeypatch.setenv("LINKEDIN_CLIENT_SECRET", "test-client-secret") + monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050") + + app = Flask(__name__) + init_auth(app) + + @app.route("/_test/csrf") + def _test_csrf(): + return generate_csrf() + + yield app + + +@pytest.fixture +def csrf_client(csrf_app): + return csrf_app.test_client() + + @pytest.fixture def mail_outbox(app, monkeypatch): from src.auth import mailer diff --git a/tests/auth/test_login.py b/tests/auth/test_login.py index 33944f5..2b3d5c5 100644 --- a/tests/auth/test_login.py +++ b/tests/auth/test_login.py @@ -17,7 +17,7 @@ def test_login_success(client, users_db_path): data={"email": "a@b.c", "password": "password12"}, ) assert resp.status_code == 302 - assert resp.headers["Location"].endswith("/compte/admin") + assert resp.headers["Location"].endswith("/compte/abonnement") def test_login_wrong_password(client, users_db_path): @@ -63,7 +63,7 @@ def test_login_rejects_absolute_next(client, users_db_path): "next": "https://evil.com", }, ) - assert resp.headers["Location"].endswith("/compte/admin") + assert resp.headers["Location"].endswith("/compte/abonnement") def test_logout_clears_session(client, users_db_path): @@ -72,3 +72,30 @@ def test_logout_clears_session(client, users_db_path): resp = client.post("/auth/logout") assert resp.status_code == 302 assert resp.headers["Location"].endswith("/") + + +# --- Tests CSRF (protection active, comme en production) --- + + +def test_login_rejects_missing_csrf_token(csrf_client): + """POST /auth/login sans token CSRF → 400. + + Régression : avec prevent_initial_call=True sur _fill_csrf_inputs, le token + n'était pas injecté dans le formulaire lors du chargement initial de /connexion. + """ + resp = csrf_client.post( + "/auth/login", + data={"email": "a@b.c", "password": "password12"}, + ) + assert resp.status_code == 400 + + +def test_login_accepts_valid_csrf_token(csrf_client, users_db_path): + """POST /auth/login avec token CSRF valide → 302.""" + _make_verified_user() + token = csrf_client.get("/_test/csrf").data.decode() + resp = csrf_client.post( + "/auth/login", + data={"email": "a@b.c", "password": "password12", "csrf_token": token}, + ) + assert resp.status_code == 302 diff --git a/tests/test_csrf_architecture.py b/tests/test_csrf_architecture.py index 6c7f20f..17f8064 100644 --- a/tests/test_csrf_architecture.py +++ b/tests/test_csrf_architecture.py @@ -28,6 +28,40 @@ def test_csrf_token_store_in_main_layout(): ) +def test_fill_csrf_inputs_allows_initial_call(): + """_fill_csrf_inputs ne doit pas avoir prevent_initial_call=True. + + Avec prevent_initial_call=True, le callback n'est pas déclenché lors de la chaîne + initiale (_pages_location → _generate_csrf_token → csrf-token → _fill_csrf_inputs), + laissant le champ csrf_token vide → erreur 400 au premier chargement de /connexion. + """ + import src.app # noqa: F401 — enregistre les callbacks + + found = False + for cb_info in dash._callback.GLOBAL_CALLBACK_MAP.values(): + inputs = getattr(cb_info, "inputs", None) or cb_info.get("inputs", []) + for inp in inputs: + if hasattr(inp, "component_id"): + inp_id, inp_prop = inp.component_id, inp.component_property + else: + inp_id, inp_prop = inp.get("id"), inp.get("property") + + if inp_id == "csrf-token" and inp_prop == "data": + pic = getattr(cb_info, "prevent_initial_call", None) + if pic is None: + pic = cb_info.get("prevent_initial_call", False) + assert not pic, ( + "_fill_csrf_inputs a prevent_initial_call=True — le token CSRF " + "ne sera pas injecté lors du premier chargement de /connexion." + ) + found = True + + assert found, ( + "Callback avec Input('csrf-token', 'data') introuvable dans le registre Dash. " + "Vérifier que _fill_csrf_inputs est toujours enregistré dans src/app.py." + ) + + def test_no_page_specific_csrf_callback_outputs(): """Aucun callback CSRF ne doit cibler un ID string page-spécifique en Output.""" old_ids = {