Étape 7 : authentification par lien magique + JWT + rôles

- app/auth/ : jetons (lien magique aléatoire haché SHA-256, JWT HS256),
  dépendances get_current_account / current_account_in_edition / require_roles
  + require_coordinator / require_staff, et ensure_can_write_position/_shift
- Endpoints app/api/auth.py : POST .../auth/magic-link (202 constant, pas de
  divulgation), POST .../auth/verify (JWT, jeton à usage unique), GET /auth/me
- Envoi d'email stubé : le lien est journalisé (logger app.auth, identifiants
  seulement, aucune donnée nominative)
- app/api/accounts.py : CRUD des comptes réservé à la COORDINATION
- Rôles appliqués sur toute l'API :
  COORDINATION accès complet ; RÉFÉRENT lecture globale + écriture sur ses
  seuls postes/créneaux/affectations ; BÉNÉVOLE sa fiche et ses affectations,
  aucune écriture
- imports.py aligné sur la dépendance get_edition (404 avant 403)
- Secret JWT porté à >= 32 octets (config, .env.example, docker-compose)
- seed : crée un compte VOLUNTEER pour chaque bénévole avec email (104 comptes)
- conftest : fixtures make_account / coordinator / as_account / raw_client ;
  la suite existante s'exécute en COORDINATION
- 13 tests d'authentification + de rôles ; 119 tests au total ; alembic check
  toujours vert (Account / MagicLinkToken déjà dans la migration 0001)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
AdamThomas
2026-09-09 23:22:35 +02:00
co-authored by Claude Sonnet 5
parent c9dda65361
commit ced922f785
24 changed files with 971 additions and 50 deletions
+60 -9
View File
@@ -1,14 +1,16 @@
import datetime as dt
import itertools
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, SQLModel, create_engine, select
from app import models # noqa: F401 (enregistre les tables sur SQLModel.metadata)
from app.auth.deps import get_current_account
from app.config import get_settings
from app.db import get_session
from app.main import app
from app.models import DayPart, Edition
from app.models import Account, DayPart, Edition, Role
@pytest.fixture(scope="session")
@@ -36,14 +38,6 @@ def session(engine):
connection.close()
@pytest.fixture
def client(session):
app.dependency_overrides[get_session] = lambda: session
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
@pytest.fixture
def edition(session) -> Edition:
"""Édition de 4 jours avec 3 demi-journées paramétrées."""
@@ -83,3 +77,60 @@ def day_parts(session, edition) -> dict[str, DayPart]:
select(DayPart).where(DayPart.edition_id == edition.id)
)
return {p.code: p for p in parts}
@pytest.fixture
def make_account(session, edition):
"""Fabrique de comptes rattachés à l'édition de test."""
counter = itertools.count()
def _make(
role: Role, *, volunteer_id=None, email: str | None = None
) -> Account:
account = Account(
edition_id=edition.id,
role=role,
email=email or f"{role.value.lower()}{next(counter)}@test",
volunteer_id=volunteer_id,
)
session.add(account)
session.commit()
session.refresh(account)
return account
return _make
@pytest.fixture
def coordinator(make_account) -> Account:
return make_account(Role.COORDINATOR)
@pytest.fixture
def as_account(session):
"""Fabrique de clients HTTP authentifiés. Fixe l'identité **active** de
l'app : le dernier appel l'emporte. Pour rejouer un autre rôle après en
avoir simulé un, rappeler `as_account(...)`."""
def _make(account: Account) -> TestClient:
app.dependency_overrides[get_session] = lambda: session
app.dependency_overrides[get_current_account] = lambda: account
return TestClient(app)
yield _make
app.dependency_overrides.clear()
@pytest.fixture
def client(coordinator, as_account) -> TestClient:
"""Client authentifié en COORDINATION (rôle par défaut des tests)."""
return as_account(coordinator)
@pytest.fixture
def raw_client(session):
"""Client sans authentification simulée (pour tester le lien magique)."""
app.dependency_overrides[get_session] = lambda: session
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
+99
View File
@@ -0,0 +1,99 @@
import datetime as dt
import logging
from sqlmodel import select
from app.auth.tokens import hash_token
from app.models import MagicLinkToken, Role, local_now
def _url(edition, suffix):
return f"/editions/{edition.id}/auth/{suffix}"
def test_magic_link_unknown_email_is_accepted_without_token(
raw_client, session, edition
):
resp = raw_client.post(
_url(edition, "magic-link"), json={"email": "personne@example.fr"}
)
assert resp.status_code == 202
assert session.exec(select(MagicLinkToken)).all() == []
def test_full_flow_magic_link_then_jwt_then_me(
raw_client, session, edition, make_account, caplog
):
make_account(Role.COORDINATOR, email="coord@example.fr")
with caplog.at_level(logging.INFO, logger="app.auth"):
resp = raw_client.post(
_url(edition, "magic-link"), json={"email": "COORD@example.fr"}
)
assert resp.status_code == 202
log_line = next(m for m in caplog.messages if "STUB lien magique" in m)
raw_token = log_line.split("jeton=")[-1].strip()
verified = raw_client.post(
_url(edition, "verify"), json={"token": raw_token}
)
assert verified.status_code == 200
body = verified.json()
assert body["token_type"] == "bearer"
assert body["account"]["role"] == "COORDINATOR"
me = raw_client.get(
"/auth/me",
headers={"Authorization": f"Bearer {body['access_token']}"},
)
assert me.status_code == 200
assert me.json()["email"] == "coord@example.fr"
def test_verify_is_single_use(raw_client, session, edition, make_account):
account = make_account(Role.VOLUNTEER, email="v@example.fr")
session.add(
MagicLinkToken(
account_id=account.id,
token_hash=hash_token("jeton-connu"),
expires_at=local_now() + dt.timedelta(minutes=10),
)
)
session.commit()
first = raw_client.post(_url(edition, "verify"), json={"token": "jeton-connu"})
assert first.status_code == 200
second = raw_client.post(
_url(edition, "verify"), json={"token": "jeton-connu"}
)
assert second.status_code == 401
assert second.json()["detail"]["code"] == "auth.invalid_token"
def test_verify_rejects_expired_token(raw_client, session, edition, make_account):
account = make_account(Role.VOLUNTEER, email="v@example.fr")
session.add(
MagicLinkToken(
account_id=account.id,
token_hash=hash_token("perime"),
expires_at=local_now() - dt.timedelta(minutes=1),
)
)
session.commit()
resp = raw_client.post(_url(edition, "verify"), json={"token": "perime"})
assert resp.status_code == 401
def test_verify_rejects_unknown_token(raw_client, edition):
resp = raw_client.post(_url(edition, "verify"), json={"token": "n'importe"})
assert resp.status_code == 401
def test_me_requires_a_valid_bearer(raw_client):
assert raw_client.get("/auth/me").status_code == 401
assert (
raw_client.get(
"/auth/me", headers={"Authorization": "Bearer pas-un-jwt"}
).status_code
== 401
)
+253
View File
@@ -0,0 +1,253 @@
"""Application des rôles : COORDINATION / RÉFÉRENT (LEAD) / BÉNÉVOLE."""
from app.models import Assignment, Role
from tests.factories import (
dt_at,
make_position,
make_shift,
make_volunteer,
)
def _e(edition, suffix=""):
return f"/editions/{edition.id}{suffix}"
def test_all_endpoints_require_authentication(raw_client, edition):
assert raw_client.get(_e(edition, "/volunteers")).status_code == 401
assert raw_client.get(_e(edition, "/matrix")).status_code == 401
assert raw_client.post(_e(edition, "/positions"), json={}).status_code == 401
def test_account_from_another_edition_is_forbidden(
session, as_account, edition, make_account
):
import datetime as dt
from app.models import Edition
other = Edition(
year=2027, start_date=dt.date(2027, 7, 1), end_date=dt.date(2027, 7, 4)
)
session.add(other)
session.commit()
session.refresh(other)
from app.models import Account
stranger = Account(
edition_id=other.id, email="x@e.fr", role=Role.COORDINATOR
)
session.add(stranger)
session.commit()
session.refresh(stranger)
client = as_account(stranger)
assert client.get(_e(edition, "/volunteers")).status_code == 403
# --------------------------------------------------------------------------- #
# BÉNÉVOLE #
# --------------------------------------------------------------------------- #
def test_volunteer_scope(session, edition, make_account, as_account):
me = make_volunteer(session, edition, email="me@e.fr")
other = make_volunteer(session, edition, email="other@e.fr")
account = make_account(Role.VOLUNTEER, volunteer_id=me.id, email="me@e.fr")
client = as_account(account)
assert client.get(_e(edition, "/volunteers")).status_code == 403
assert client.get(_e(edition, f"/volunteers/{me.id}")).status_code == 200
assert client.get(_e(edition, f"/volunteers/{other.id}")).status_code == 403
assert client.get(_e(edition, "/matrix")).status_code == 403
assert (
client.post(
_e(edition, "/volunteers"),
json={"first_name": "A", "last_name": "B"},
).status_code
== 403
)
def test_volunteer_sees_only_own_assignments(
session, edition, make_account, as_account
):
me = make_volunteer(session, edition, email="me@e.fr")
other = make_volunteer(session, edition, email="other@e.fr")
position = make_position(session, edition)
shift = make_shift(
session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12)
)
session.add_all(
[
Assignment(
edition_id=edition.id, volunteer_id=me.id, shift_id=shift.id
),
]
)
session.commit()
account = make_account(Role.VOLUNTEER, volunteer_id=me.id, email="me@e.fr")
client = as_account(account)
mine = client.get(_e(edition, "/assignments"))
assert mine.status_code == 200
assert all(a["volunteer_id"] == str(me.id) for a in mine.json())
assert (
client.get(
_e(edition, "/assignments"),
params={"volunteer_id": str(other.id)},
).status_code
== 403
)
# --------------------------------------------------------------------------- #
# RÉFÉRENT (LEAD) #
# --------------------------------------------------------------------------- #
def test_lead_write_is_limited_to_own_positions(
session, edition, make_account, as_account
):
lead = make_account(Role.LEAD, email="lead@e.fr")
mine = make_position(session, edition, name="Bar")
mine.lead_account_id = lead.id
session.add(mine)
session.commit()
theirs = make_position(session, edition, name="Régie")
client = as_account(lead)
# Lecture sur toute l'édition.
assert client.get(_e(edition, "/matrix")).status_code == 200
assert client.get(_e(edition, "/positions")).status_code == 200
# Création de poste réservée à la coordination.
assert (
client.post(
_e(edition, "/positions"),
json={"name": "N", "category": "BAR"},
).status_code
== 403
)
# PATCH : son poste oui, celui d'un autre non.
assert (
client.patch(
_e(edition, f"/positions/{mine.id}"), json={"location": "Chapiteau"}
).status_code
== 200
)
assert (
client.patch(
_e(edition, f"/positions/{theirs.id}"), json={"location": "X"}
).status_code
== 403
)
# Créneau : sur son poste oui, sur celui d'un autre non.
ok = client.post(
_e(edition, "/shifts"),
json={
"position_id": str(mine.id),
"starts_at": "2026-07-10T09:00:00",
"ends_at": "2026-07-10T12:00:00",
},
)
assert ok.status_code == 201
ko = client.post(
_e(edition, "/shifts"),
json={
"position_id": str(theirs.id),
"starts_at": "2026-07-10T09:00:00",
"ends_at": "2026-07-10T12:00:00",
},
)
assert ko.status_code == 403
# Ni bénévoles ni import.
assert (
client.post(
_e(edition, "/volunteers"),
json={"first_name": "A", "last_name": "B"},
).status_code
== 403
)
assert (
client.post(
_e(edition, "/import/analyze"),
files={"file": ("x.csv", b"a;b\n1;2\n", "text/csv")},
).status_code
== 403
)
def test_lead_assignment_scope(session, edition, make_account, as_account):
lead = make_account(Role.LEAD, email="lead@e.fr")
mine = make_position(session, edition, name="Bar")
mine.lead_account_id = lead.id
session.add(mine)
session.commit()
theirs = make_position(session, edition, name="Accueil")
my_shift = make_shift(
session, edition, mine, start=dt_at(10, 9), end=dt_at(10, 12)
)
their_shift = make_shift(
session, edition, theirs, start=dt_at(11, 9), end=dt_at(11, 12)
)
volunteer = make_volunteer(session, edition, email="v@e.fr")
client = as_account(lead)
ok = client.post(
_e(edition, "/assignments"),
json={
"volunteer_id": str(volunteer.id),
"shift_id": str(my_shift.id),
},
)
assert ok.status_code == 201
ko = client.post(
_e(edition, "/assignments"),
json={
"volunteer_id": str(volunteer.id),
"shift_id": str(their_shift.id),
},
)
assert ko.status_code == 403
# --------------------------------------------------------------------------- #
# Comptes (coordination uniquement) #
# --------------------------------------------------------------------------- #
def test_account_management_is_coordinator_only(
session, edition, coordinator, make_account, as_account
):
coord_client = as_account(coordinator)
created = coord_client.post(
_e(edition, "/accounts"),
json={
"email": "nouveau.referent@e.fr",
"role": "LEAD",
"volunteer_id": None,
},
)
assert created.status_code == 201
account_id = created.json()["id"]
assert coord_client.get(_e(edition, "/accounts")).status_code == 200
lead_client = as_account(make_account(Role.LEAD, email="lead@e.fr"))
assert lead_client.get(_e(edition, "/accounts")).status_code == 403
assert (
lead_client.delete(
_e(edition, f"/accounts/{account_id}")
).status_code
== 403
)
# `as_account` fixe l'identité active : on repasse en COORDINATION.
coord_client = as_account(coordinator)
assert (
coord_client.delete(
_e(edition, f"/accounts/{account_id}")
).status_code
== 204
)