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 = {