Deux systèmes d'authentification, une même session JWT :
- COORDINATION : login e-mail + mot de passe (bcrypt), POST .../auth/login ;
POST .../auth/change-password pour le compte courant
- RÉFÉRENTS / BÉNÉVOLES : lien magique inchangé, mais refusé pour un compte
qui a un mot de passe
- Account.password_hash (nullable) + migration 0002 ; propriété auth_method
("password" / "magic_link") exposée dans AccountRead
- app/auth/passwords.py : hash/verify bcrypt, longueur mini 10
- Création de compte : password requis pour COORDINATOR, interdit sinon
- seed : la coordination de démo a un mot de passe, affiché dans le résumé
- 12 tests (login ok/ko, séparation des deux systèmes, change-password,
validation à la création) ; 129 tests au total ; alembic check vert
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
158 lines
4.9 KiB
Python
158 lines
4.9 KiB
Python
"""Connexion par mot de passe (admins) et séparation des deux systèmes."""
|
|
|
|
from sqlmodel import select
|
|
|
|
from app.models import MagicLinkToken, Role
|
|
|
|
PASSWORD = "coordination-2026"
|
|
|
|
|
|
def _url(edition, suffix):
|
|
return f"/editions/{edition.id}/auth/{suffix}"
|
|
|
|
|
|
def test_login_with_correct_password(raw_client, edition, make_account):
|
|
make_account(Role.COORDINATOR, email="admin@e.fr", password=PASSWORD)
|
|
resp = raw_client.post(
|
|
_url(edition, "login"),
|
|
json={"email": "ADMIN@e.fr", "password": PASSWORD},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["token_type"] == "bearer"
|
|
assert body["account"]["role"] == "COORDINATOR"
|
|
assert body["account"]["auth_method"] == "password"
|
|
|
|
|
|
def test_login_rejects_wrong_password(raw_client, edition, make_account):
|
|
make_account(Role.COORDINATOR, email="admin@e.fr", password=PASSWORD)
|
|
resp = raw_client.post(
|
|
_url(edition, "login"),
|
|
json={"email": "admin@e.fr", "password": "faux"},
|
|
)
|
|
assert resp.status_code == 401
|
|
assert resp.json()["detail"]["code"] == "auth.invalid_credentials"
|
|
|
|
|
|
def test_login_rejects_unknown_email(raw_client, edition):
|
|
resp = raw_client.post(
|
|
_url(edition, "login"),
|
|
json={"email": "personne@e.fr", "password": PASSWORD},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
|
|
def test_login_fails_for_magic_link_account(raw_client, edition, make_account):
|
|
make_account(Role.LEAD, email="ref@e.fr") # sans mot de passe
|
|
resp = raw_client.post(
|
|
_url(edition, "login"),
|
|
json={"email": "ref@e.fr", "password": "peu importe"},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
|
|
def test_magic_link_ignored_for_password_account(
|
|
raw_client, session, edition, make_account
|
|
):
|
|
make_account(Role.COORDINATOR, email="admin@e.fr", password=PASSWORD)
|
|
resp = raw_client.post(
|
|
_url(edition, "magic-link"), json={"email": "admin@e.fr"}
|
|
)
|
|
assert resp.status_code == 202
|
|
assert session.exec(select(MagicLinkToken)).all() == []
|
|
|
|
|
|
def test_change_password(raw_client, edition, make_account):
|
|
make_account(Role.COORDINATOR, email="admin@e.fr", password=PASSWORD)
|
|
jwt = raw_client.post(
|
|
_url(edition, "login"),
|
|
json={"email": "admin@e.fr", "password": PASSWORD},
|
|
).json()["access_token"]
|
|
headers = {"Authorization": f"Bearer {jwt}"}
|
|
|
|
changed = raw_client.post(
|
|
_url(edition, "change-password"),
|
|
json={"current_password": PASSWORD, "new_password": "nouveau-mdp-2026"},
|
|
headers=headers,
|
|
)
|
|
assert changed.status_code == 204
|
|
|
|
assert (
|
|
raw_client.post(
|
|
_url(edition, "login"),
|
|
json={"email": "admin@e.fr", "password": PASSWORD},
|
|
).status_code
|
|
== 401
|
|
)
|
|
assert (
|
|
raw_client.post(
|
|
_url(edition, "login"),
|
|
json={"email": "admin@e.fr", "password": "nouveau-mdp-2026"},
|
|
).status_code
|
|
== 200
|
|
)
|
|
|
|
|
|
def test_change_password_rejects_wrong_current(
|
|
raw_client, edition, make_account
|
|
):
|
|
make_account(Role.COORDINATOR, email="admin@e.fr", password=PASSWORD)
|
|
jwt = raw_client.post(
|
|
_url(edition, "login"),
|
|
json={"email": "admin@e.fr", "password": PASSWORD},
|
|
).json()["access_token"]
|
|
resp = raw_client.post(
|
|
_url(edition, "change-password"),
|
|
json={"current_password": "faux", "new_password": "nouveau-mdp-2026"},
|
|
headers={"Authorization": f"Bearer {jwt}"},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Création de compte : mot de passe selon le rôle #
|
|
# --------------------------------------------------------------------------- #
|
|
def test_create_coordinator_requires_password(client, edition):
|
|
resp = client.post(
|
|
f"/editions/{edition.id}/accounts",
|
|
json={"email": "admin2@e.fr", "role": "COORDINATOR"},
|
|
)
|
|
assert resp.status_code == 422
|
|
assert resp.json()["detail"]["code"] == "account.password_required"
|
|
|
|
|
|
def test_create_coordinator_with_password_can_log_in(
|
|
client, raw_client, edition
|
|
):
|
|
created = client.post(
|
|
f"/editions/{edition.id}/accounts",
|
|
json={
|
|
"email": "admin2@e.fr",
|
|
"role": "COORDINATOR",
|
|
"password": "un-mot-de-passe-solide",
|
|
},
|
|
)
|
|
assert created.status_code == 201
|
|
assert created.json()["auth_method"] == "password"
|
|
|
|
assert (
|
|
raw_client.post(
|
|
f"/editions/{edition.id}/auth/login",
|
|
json={"email": "admin2@e.fr", "password": "un-mot-de-passe-solide"},
|
|
).status_code
|
|
== 200
|
|
)
|
|
|
|
|
|
def test_create_lead_rejects_password(client, edition):
|
|
resp = client.post(
|
|
f"/editions/{edition.id}/accounts",
|
|
json={
|
|
"email": "ref2@e.fr",
|
|
"role": "LEAD",
|
|
"password": "pas-permis",
|
|
},
|
|
)
|
|
assert resp.status_code == 422
|
|
assert resp.json()["detail"]["code"] == "account.password_not_allowed"
|