diff --git a/integration_tests/conftest.py b/integration_tests/conftest.py index e120148c..a3322598 100644 --- a/integration_tests/conftest.py +++ b/integration_tests/conftest.py @@ -186,7 +186,7 @@ def _dj_autoclear_mailbox() -> None: @pytest.fixture def link_from_mail(mailbox): - def _delegate(email, subject=None): + def _delegate(email: str, subject=None): for message in mailbox(email): if subject is None or subject in message['subject']: html = message.get_payload()[1].get_payload() diff --git a/integration_tests/test_parent.py b/integration_tests/test_parent.py index f10b4910..d33da636 100644 --- a/integration_tests/test_parent.py +++ b/integration_tests/test_parent.py @@ -7,6 +7,7 @@ from playwright.sync_api import Page, expect + @pytest.fixture def default_signup_fill_form(page: Page, apps): """opens the signup form and fills some default values. individual tests should overwrite as necessary""" @@ -41,23 +42,26 @@ def default_signup_fill_form(page: Page, apps): @pytest.fixture def signup(page: Page, apps, default_signup_fill_form): - suffix = ''.join(random.choice(string.digits) for i in range(4)) - email = f'parent{suffix}@localhost.local' + def delegate(): + suffix = ''.join(random.choice(string.digits) for i in range(4)) + email = f'parent{suffix}@localhost.local' - page.fill('#id_email', email) - page.fill('#id_email_again', email) - page.click('input[type="submit"]') + page.fill('#id_email', email) + page.fill('#id_email_again', email) + page.click('input[type="submit"]') - # check that the form was submitted - expect(page.locator('input[type="submit"]')).not_to_be_visible() - assert 'signup/done' in page.url + # check that the form was submitted + expect(page.locator('input[type="submit"]')).not_to_be_visible() + assert 'signup/done' in page.url - return email + return email + return delegate def test_parent_login(page, apps, signup, as_admin, link_from_mail, login_as): # confirm signup email - if link := link_from_mail(signup, 'Bevestiging'): + address = signup() + if link := link_from_mail(address, 'Bevestiging'): page.goto(link) else: pytest.fail('could not find validation link') @@ -69,7 +73,7 @@ def test_parent_login(page, apps, signup, as_admin, link_from_mail, login_as): as_admin.locator("button").get_by_text("Approve").click() # try to login via email - login_as(signup) + login_as(address) # check that login worked expect(page.get_by_text('Appointments')).to_be_visible() @@ -77,13 +81,14 @@ def test_parent_login(page, apps, signup, as_admin, link_from_mail, login_as): def test_parent_login_unapproved(signup, apps, page, link_from_mail, login_as): # confirm signup email - if link := link_from_mail(signup, 'Bevestiging'): + address = signup() + if link := link_from_mail(address, 'Bevestiging'): page.goto(link) else: pytest.fail('could not find validation link') # make sure that no login email has arrived - assert login_as(signup) is False + assert login_as(address) is False def test_parent_self_deactivate(page, participant, login_as): @@ -187,8 +192,6 @@ def test_signup_save_longer(page, apps, default_signup_fill_form): def test_signup_unborn(page, apps, default_signup_fill_form): - Signup = apps.lab.get_model('signups', 'Signup') - now = datetime.datetime.now() page.locator('#id_birth_date_year').select_option(str(now.year)) page.locator('#id_birth_date_month').select_option(now.strftime('%B')) @@ -198,3 +201,40 @@ def test_signup_unborn(page, apps, default_signup_fill_form): # check that the form was submitted assert page.locator('select.is-invalid').count() == 3 + + +def test_signup_confirmation_expired(page, apps, signup, as_admin, link_from_mail): + # confirm signup email + address = signup() + + Signup = apps.lab.get_model('signups', 'Signup') + s = Signup.objects.last() + s.created = timezone.now() - datetime.timedelta(hours=25) + s.save() + + if link := link_from_mail(address, 'Bevestiging'): + page.goto(link) + expect(page.get_by_text('niet meer geldig')).to_be_visible() + else: + pytest.fail('could not find validation link') + + s = Signup.objects.last() + assert s.email_verified is None + + +def test_parent_login_expired(participant, apps, page, link_from_mail, login_as): + MailAuth = apps.lab.get_model('mailauth', 'MailAuth') + + page.goto(apps.parent.url + 'auth/') + page.fill('input[name="email"]', participant.email) + page.locator('button').get_by_text('Send').click() + + mauth = MailAuth.objects.last() + mauth.expiry = timezone.now() - datetime.timedelta(seconds=1) + mauth.save() + + link = link_from_mail(participant.email, 'Link') + page.goto(link) + expect(page.get_by_text('verlopen')).to_be_visible() + + expect(page.get_by_text('Appointments')).not_to_be_visible() diff --git a/lab/mailauth/locale/nl/LC_MESSAGES/django.po b/lab/mailauth/locale/nl/LC_MESSAGES/django.po index cf904ecf..fb6eb554 100644 --- a/lab/mailauth/locale/nl/LC_MESSAGES/django.po +++ b/lab/mailauth/locale/nl/LC_MESSAGES/django.po @@ -21,3 +21,6 @@ msgstr "" #: mailauth/models.py:33 msgid "mailauth:send:subject" msgstr "Babylab voor Taalonderzoek: Link voor Babylabportaal" + +msgid "mailauth:error:expired" +msgstr "Helaas is deze link verlopen. U kunt opnieuw inloggen via BABYLABPORTAAL hieronder." \ No newline at end of file diff --git a/lab/mailauth/models.py b/lab/mailauth/models.py index 246d6387..e850004f 100644 --- a/lab/mailauth/models.py +++ b/lab/mailauth/models.py @@ -1,4 +1,6 @@ +import dataclasses from datetime import datetime +from enum import Enum, auto from secrets import token_urlsafe from typing import List, Optional, Tuple @@ -6,7 +8,7 @@ from cdh.mail.classes import TemplateEmail from django.conf import settings from django.db import models -from django.utils import translation +from django.utils import timezone, translation from django.utils.translation import gettext_lazy as _ from participants.models import Participant @@ -64,15 +66,28 @@ def create_mail_auth( return MailAuth.objects.create(expiry=expiry, email=email, participant=participant) -def try_authenticate(token: str) -> Tuple[Optional[MailAuth], List[Participant]]: +class MailAuthReason(Enum): + SUCCESS = auto() + NOT_FOUND = auto() + EXPIRED = auto() + + +@dataclasses.dataclass +class MailAuthResult: + reason: MailAuthReason + mauth: Optional[MailAuth] = None + possible_pps: List[Participant] = dataclasses.field(default_factory=list) + + +def try_authenticate(token: str) -> MailAuthResult: try: - mauth = MailAuth.objects.get( - link_token=token, - # link should not be too old - expiry__gte=datetime.now(), - ) + mauth = MailAuth.objects.get(link_token=token) except MailAuth.DoesNotExist: - return None, [] + return MailAuthResult(reason=MailAuthReason.NOT_FOUND) + + if mauth.expiry < timezone.now(): + # link should not be too old + return MailAuthResult(reason=MailAuthReason.EXPIRED) possible_pps = [] if not mauth.participant: @@ -91,7 +106,7 @@ def try_authenticate(token: str) -> Tuple[Optional[MailAuth], List[Participant]] mauth.session_token = token_urlsafe() mauth.save() - return mauth, possible_pps + return MailAuthResult(mauth=mauth, possible_pps=possible_pps, reason=MailAuthReason.SUCCESS) def lookup_session_token(token: str) -> Optional[Participant]: diff --git a/lab/mailauth/views.py b/lab/mailauth/views.py index 10207715..2c769353 100644 --- a/lab/mailauth/views.py +++ b/lab/mailauth/views.py @@ -1,12 +1,19 @@ -from datetime import datetime, timedelta +from datetime import timedelta +from django.utils import timezone, translation +from django.utils.translation import gettext as _ from rest_framework import exceptions, views from rest_framework.response import Response from participants.models import Participant from participants.serializers import ParticipantSerializer -from .models import create_mail_auth, resolve_participant, try_authenticate +from .models import ( + MailAuthReason, + create_mail_auth, + resolve_participant, + try_authenticate, +) class MailAuthView(views.APIView): @@ -15,15 +22,20 @@ def get(self, request, *args, **kwargs): return Response(dict()) # look for valid auth object for token - mauth, possible_pps = try_authenticate(kwargs["token"]) - if mauth is not None: + result = try_authenticate(kwargs["token"]) + if result.mauth is not None: # valid token return Response( dict( - session_token=mauth.session_token, - possible_pps=[ParticipantSerializer(pp).data for pp in possible_pps], + session_token=result.mauth.session_token, + possible_pps=[ParticipantSerializer(pp).data for pp in result.possible_pps], ) ) + + if result.reason == MailAuthReason.EXPIRED: + with translation.override("nl"): + return Response(dict(reason=_("mailauth:error:expired")), status=410) + raise exceptions.AuthenticationFailed() def post(self, request, *args, **kwargs): @@ -35,7 +47,7 @@ def post(self, request, *args, **kwargs): return Response(dict()) # email exists, generate token and send it - expiry = datetime.now() + timedelta(hours=24) + expiry = timezone.now() + timedelta(hours=24) mauth = create_mail_auth(expiry, email) # while there might be multiple participants matching the given email address, diff --git a/lab/signups/locale/nl/LC_MESSAGES/django.po b/lab/signups/locale/nl/LC_MESSAGES/django.po index 2a163033..691e9dcc 100644 --- a/lab/signups/locale/nl/LC_MESSAGES/django.po +++ b/lab/signups/locale/nl/LC_MESSAGES/django.po @@ -88,4 +88,10 @@ msgid "signups:messages:rejected" msgstr "" msgid "signups:mail:validation:subject" -msgstr "Babylab voor Taalonderzoek: Bevestiging e-mailadres" \ No newline at end of file +msgstr "Babylab voor Taalonderzoek: Bevestiging e-mailadres" + +msgid "signups:verify:error:expired" +msgstr "" +"
Deze link is niet meer geldig. Uw gegevens hebben wij daarom helaas moeten verwijderen.
" +"Wilt u uw kind toch graag aanmelden voor het Babylab voor Taalonderzoek, " +"dan verzoeken wij u vriendelijk om dit opnieuw te doen via AANMELDEN hieronder.
" diff --git a/lab/signups/views.py b/lab/signups/views.py index 5702c0cf..2b3ae383 100644 --- a/lab/signups/views.py +++ b/lab/signups/views.py @@ -1,9 +1,11 @@ +from datetime import timedelta + import braces.views as braces from django.contrib import messages from django.http.response import HttpResponse from django.shortcuts import redirect from django.urls import reverse_lazy -from django.utils import timezone +from django.utils import timezone, translation from django.utils.translation import gettext_lazy as _ from django.views.generic import DetailView, ListView from rest_framework import views @@ -82,6 +84,11 @@ def reject_signup(signup: Signup): class SignupVerifyView(views.APIView): def get(self, request, *args, **kwargs): signup = Signup.objects.get(link_token=kwargs["token"]) - signup.email_verified = timezone.now() - signup.save() - return Response(dict()) + if timezone.now() - signup.created > timedelta(days=1): + # expired + with translation.override("nl"): + return Response(dict(reason=_("signups:verify:error:expired")), status=410) + else: + signup.email_verified = timezone.now() + signup.save() + return Response(dict()) diff --git a/parent/mailauth/views.py b/parent/mailauth/views.py index 0c9365e7..5eaf4107 100644 --- a/parent/mailauth/views.py +++ b/parent/mailauth/views.py @@ -14,7 +14,7 @@ def link_verify(request, token): # upon successful authentication, the lab app should respond with a session token if not ok or "session_token" not in response: - messages.error(request, _("parent:error:login_failed")) + messages.error(request, response.get("reason", _("parent:error:login_failed"))) return redirect("home") request.session["token"] = response["session_token"] diff --git a/parent/parent/locale/nl/LC_MESSAGES/django.po b/parent/parent/locale/nl/LC_MESSAGES/django.po index 6aeba2fe..2207a838 100644 --- a/parent/parent/locale/nl/LC_MESSAGES/django.po +++ b/parent/parent/locale/nl/LC_MESSAGES/django.po @@ -396,8 +396,8 @@ msgstr "" "Het is niet gelukt om u uit te schirjven. Neem alstublieft contact met ons " "op via e-mail: babylab.ilslabs@uu.nl" -#~ msgid "parent:error:login_failed" -#~ msgstr "Inloggen is mislukt, probeer het later opnieuw" +msgid "parent:error:login_failed" +msgstr "Er is een fout opgetreden tijdens het inloggen" msgid "parent:appointment:canceled" msgstr "Geannuleerd" \ No newline at end of file diff --git a/parent/parent/views.py b/parent/parent/views.py index c5f6a254..5ed5cc5c 100644 --- a/parent/parent/views.py +++ b/parent/parent/views.py @@ -5,6 +5,7 @@ from cdh.rest import client as rest from django.contrib import messages from django.http.response import JsonResponse +from django.utils.safestring import mark_safe from django.shortcuts import redirect, render from django.urls import reverse_lazy from django.utils.translation import gettext_lazy as _ @@ -81,7 +82,7 @@ def signup_verify(request, token): ok, result = gateway(request, f"/gateway/signup/verify/{token}") if ok: return render(request, "signup_confirmed.html") - messages.error(request, _("parent:error:signup_verify")) + messages.error(request, mark_safe(result.get("reason", _("parent:error:signup_verify")))) return redirect("home")