Lien de connexion directe permanent dans le récapitulatif de planning
ci-cd / build-backend (push) Successful in 45s
ci-cd / build-frontend (push) Successful in 21s
ci-cd / deploy (push) Successful in 11s

{lien_connexion} devient un lien permanent (réutilisable, sans expiration,
signé HMAC, rien de stocké) au lieu d'un lien magique de 30 min à usage
unique ou d'un simple lien vers le site. Compte bénévole créé à l'envoi
s'il manque ; révocation par « Renouveler le lien de connexion » sur la
fiche (Account.login_link_version, migration 0031) ; jamais pour la
coordination.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-28 20:01:03 +01:00
co-authored by Claude Opus 5.5
parent 8d6781e6ce
commit 05cdb0276b
14 changed files with 316 additions and 16 deletions
+73 -2
View File
@@ -62,8 +62,9 @@ def test_magic_link_is_embedded_when_placeholder_present(
_url(edition), json={"body": "Connecte-toi : {lien_connexion}"}
)
assert resp.status_code == 200
assert "/connexion?token=" in plain_text(outbox[0])
assert len(session.exec(select(MagicLinkToken)).all()) == 1
assert "/connexion?token=pl." in plain_text(outbox[0])
# Lien permanent : rien de stocké, pas de jeton à usage unique.
assert session.exec(select(MagicLinkToken)).all() == []
def test_no_magic_link_token_without_placeholder(
@@ -129,3 +130,73 @@ def test_shifts_placeholder_is_a_table_by_day_with_the_referent(
text = plain_text(message)
assert "18:00–22:00 · Bar central · référent : Rita Ref · 06 11 22 33 44" in text
assert "09:00–12:00 · Parking · référent : à désigner" in text
def _link(message) -> str:
import re
return re.search(r"token=(\S+)", plain_text(message)).group(1)
def _verify(client, edition, token):
return client.post(f"/editions/{edition.id}/auth/verify", json={"token": token})
def test_login_link_is_permanent_and_reusable(client, edition, session, make_account, outbox):
ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
make_account(Role.VOLUNTEER, volunteer_id=ana.id, email="ana@e.fr")
client.post(_url(edition), json={"body": "{lien_connexion}"})
client.post(_url(edition), json={"body": "{lien_connexion}"})
first, second = (_link(m) for m in outbox)
# Le même lien dans chaque email, utilisable plusieurs fois.
assert first == second
for _ in range(3):
resp = _verify(client, edition, first)
assert resp.status_code == 200
assert resp.json()["account"]["volunteer_id"] == str(ana.id)
def test_reset_revokes_the_links_already_sent(client, edition, session, make_account, outbox):
ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
account = make_account(Role.VOLUNTEER, volunteer_id=ana.id, email="ana@e.fr")
client.post(_url(edition), json={"body": "{lien_connexion}"})
old = _link(outbox[0])
resp = client.post(f"/editions/{edition.id}/accounts/{account.id}/reset-login-link")
assert resp.status_code == 200
assert _verify(client, edition, old).status_code == 401
client.post(_url(edition), json={"body": "{lien_connexion}"})
new = _link(outbox[1])
assert new != old
assert _verify(client, edition, new).status_code == 200
def test_sending_the_link_gives_an_account_to_a_volunteer_without_one(
client, edition, session, outbox
):
from app.models import Account
ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
client.post(_url(edition), json={"body": "{lien_connexion}"})
(account,) = session.exec(select(Account).where(Account.volunteer_id == ana.id)).all()
assert account.role == Role.VOLUNTEER
assert _verify(client, edition, _link(outbox[0])).status_code == 200
def test_shared_address_gets_the_login_page_only(client, edition, session, make_account, outbox):
# Couple sur la même adresse : un seul compte possible pour cet email.
ana = make_volunteer(session, edition, first_name="Ana", email="foyer@e.fr")
make_account(Role.VOLUNTEER, volunteer_id=ana.id, email="foyer@e.fr")
bob = make_volunteer(session, edition, first_name="Bob", email="foyer@e.fr")
client.post(_url(edition), json={"volunteer_ids": [str(bob.id)], "body": "{lien_connexion}"})
assert "token=" not in plain_text(outbox[0])
def test_forged_or_coordinator_links_are_refused(client, edition, coordinator):
from app.auth.permanent_link import permanent_login_token
assert _verify(client, edition, permanent_login_token(coordinator)).status_code == 401
assert _verify(client, edition, "pl.nimportequoi.abc").status_code == 401
assert _verify(client, edition, f"pl.{coordinator.id.hex}.faux").status_code == 401