É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:
co-authored by
Claude Sonnet 5
parent
c9dda65361
commit
ced922f785
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user