diff --git a/itou/utils/mocks/rdv_insertion.py b/itou/utils/mocks/rdv_insertion.py index 68215d956c7..3d7871dc6b9 100644 --- a/itou/utils/mocks/rdv_insertion.py +++ b/itou/utils/mocks/rdv_insertion.py @@ -82,3 +82,152 @@ "Erreur inconnue", ], } + + +RDV_INSERTION_WEBHOOK_INVITATION_HEADERS = { + "Host": "localhost", + "Accept": "application/json", + "Content-Type": "application/json", + "X-Rdvi-Signature": "9ccf14d92f839a383ad27177e9ff4fd346b4d2295a36e842378fca3486cd5152", +} + + +RDV_INSERTION_WEBHOOK_INVITATION_BODY = { + "data": { + "id": 4806, + "user": { + "id": 3432, + "uid": None, + "role": "demandeur", + "email": "tech@inclusion.beta.gouv.fr", + "title": "madame", + "address": "102 Quai de Jemmapes, 75010 Paris 10ème", + "last_name": "Test", + "birth_date": "1969-05-01", + "birth_name": None, + "created_at": "2024-08-07T17:01:30.719+02:00", + "first_name": "Jeanne", + "phone_number": None, + "france_travail_id": None, + "affiliation_number": None, + "rights_opening_date": None, + "rdv_solidarites_user_id": 5527, + "carnet_de_bord_carnet_id": None, + }, + "format": "email", + "clicked": True, + "created_at": "2024-08-15T19:23:08.107+02:00", + "delivered_at": "2024-08-16T08:17:08+02:00", + "motif_category": {"id": 16, "name": "Entretien SIAE", "short_name": "siae_interview"}, + "delivery_status": None, + "rdv_with_referents": False, + }, + "meta": {"event": "updated", "model": "Invitation", "timestamp": "2024-08-15 19:23:12 +0200"}, +} + + +RDV_INSERTION_WEBHOOK_APPOINTMENT_HEADERS = { + "Host": "localhost", + "Accept": "application/json", + "Content-Type": "application/json", + "X-Rdvi-Signature": "1504bcba3bd89bd6ff409b9b80463c5ebf120665e3978be7d11a39cb18a4d189", +} + + +RDV_INSERTION_WEBHOOK_APPOINTMENT_BODY = { + "data": { + "id": 1261, + "lieu": { + "name": "PDI", + "address": "6 Boulevard Saint-Denis, Paris, 75010", + "phone_number": "", + "rdv_solidarites_lieu_id": 1026, + }, + "uuid": "37141381-ac77-41a6-8a7e-748d1c9439d5", + "motif": { + "name": "Entretien d'embauche", + "collectif": False, + "follow_up": False, + "location_type": "public_office", + "motif_category": {"id": 16, "name": "Entretien SIAE", "short_name": "siae_interview"}, + "rdv_solidarites_motif_id": 1443, + }, + "users": [ + { + "id": 3432, + "uid": None, + "role": "demandeur", + "email": "tech@inclusion.beta.gouv.fr", + "title": "madame", + "address": "102 Quai de Jemmapes, 75010 Paris 10ème", + "last_name": "Test", + "birth_date": "1969-05-01", + "birth_name": None, + "created_at": "2024-08-07T17:01:30.719+02:00", + "first_name": "Jeanne", + "phone_number": None, + "france_travail_id": None, + "affiliation_number": None, + "rights_opening_date": None, + "rdv_solidarites_user_id": 5527, + "carnet_de_bord_carnet_id": None, + } + ], + "agents": [ + { + "id": 370, + "email": "tech@inclusion.beta.gouv.fr", + "last_name": "Itou", + "first_name": "Tech", + "rdv_solidarites_agent_id": 1791, + } + ], + "status": "unknown", + "address": "6 Boulevard Saint-Denis, Paris, 75010", + "starts_at": "2024-08-26T09:00:00.000+02:00", + "created_by": "user", + "users_count": 1, + "cancelled_at": "2024-08-20T09:00:00.000+02:00", + "organisation": { + "id": 91, + "name": "Les Emplois de l'Inclusion", + "email": None, + "phone_number": "0102030405", + "motif_categories": [{"id": 16, "name": "Entretien SIAE", "short_name": "siae_interview"}], + "department_number": "60", + "rdv_solidarites_organisation_id": 654, + }, + "participations": [ + { + "id": 1174, + "user": { + "id": 3432, + "uid": None, + "role": "demandeur", + "email": "tech@inclusion.beta.gouv.fr", + "title": "madame", + "address": "102 Quai de Jemmapes, 75010 Paris 10ème", + "last_name": "Test", + "birth_date": "1969-05-01", + "birth_name": None, + "created_at": "2024-08-07T17:01:30.719+02:00", + "first_name": "Jeanne", + "phone_number": None, + "france_travail_id": None, + "affiliation_number": None, + "rights_opening_date": None, + "rdv_solidarites_user_id": 5527, + "carnet_de_bord_carnet_id": None, + }, + "status": "unknown", + "starts_at": "2024-08-26T09:00:00.000+02:00", + "created_at": "2024-08-15T19:30:08.719+02:00", + "created_by": "user", + } + ], + "duration_in_min": 30, + "max_participants_count": None, + "rdv_solidarites_rdv_id": 8725, + }, + "meta": {"event": "created", "model": "Rdv", "timestamp": "2024-08-15 19:30:08 +0200"}, +} diff --git a/tests/rdv_insertion/factories.py b/tests/rdv_insertion/factories.py index 5b7e724b8e8..6e20cdc65b1 100644 --- a/tests/rdv_insertion/factories.py +++ b/tests/rdv_insertion/factories.py @@ -3,8 +3,9 @@ import factory from faker import Faker -from itou.rdv_insertion.models import Appointment, Invitation, InvitationRequest, Location, Participation +from itou.rdv_insertion.models import Appointment, Invitation, InvitationRequest, Location, Participation, WebhookEvent from itou.users.enums import Title +from itou.utils.mocks import rdv_insertion as rdvi_mocks from tests.companies.factories import CompanyFactory from tests.users.factories import JobSeekerFactory @@ -201,3 +202,18 @@ class Params: status = factory.Faker("random_element", elements=Participation.Status.values) rdv_insertion_user_id = factory.Sequence(lambda n: n) rdv_insertion_id = factory.Sequence(lambda n: n) + + +class WebhookEventFactory(factory.django.DjangoModelFactory): + class Meta: + model = WebhookEvent + + class Params: + for_appointment = factory.Trait( + body=rdvi_mocks.RDV_INSERTION_WEBHOOK_APPOINTMENT_BODY, + headers=rdvi_mocks.RDV_INSERTION_WEBHOOK_APPOINTMENT_HEADERS, + ) + + body = rdvi_mocks.RDV_INSERTION_WEBHOOK_INVITATION_BODY + headers = rdvi_mocks.RDV_INSERTION_WEBHOOK_INVITATION_HEADERS + is_processed = factory.Faker("boolean", chance_of_getting_true=30) diff --git a/tests/rdv_insertion/tests.py b/tests/rdv_insertion/tests.py index f75c0fd047f..bb1ab644a51 100644 --- a/tests/rdv_insertion/tests.py +++ b/tests/rdv_insertion/tests.py @@ -25,7 +25,12 @@ ) from tests.job_applications.factories import JobApplicationFactory from tests.prescribers.factories import PrescriberOrganizationWithMembershipFactory -from tests.rdv_insertion.factories import InvitationFactory, InvitationRequestFactory, ParticipationFactory +from tests.rdv_insertion.factories import ( + InvitationFactory, + InvitationRequestFactory, + ParticipationFactory, + WebhookEventFactory, +) from tests.utils.test import TestCase @@ -271,3 +276,17 @@ def test_get_status_class_name(self): self.participation.status = Participation.Status.NOSHOW assert self.participation.get_status_class_name() == "bg-danger-lighter text-danger" + + +class TestWebhookEventModel: + def setup_method(self, **kwargs): + self.webhook_event_invitation = WebhookEventFactory() + self.webhook_event_appointment = WebhookEventFactory(for_appointment=True) + + def test_for_invitation_property(self): + assert self.webhook_event_invitation.for_invitation + assert not self.webhook_event_appointment.for_invitation + + def test_for_appointment_property(self): + assert not self.webhook_event_invitation.for_appointment + assert self.webhook_event_appointment.for_appointment diff --git a/tests/www/rdv_insertion/__init__.py b/tests/www/rdv_insertion/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/www/rdv_insertion/tests.py b/tests/www/rdv_insertion/tests.py new file mode 100644 index 00000000000..cb3ec6ecd55 --- /dev/null +++ b/tests/www/rdv_insertion/tests.py @@ -0,0 +1,239 @@ +import datetime +import hashlib +import hmac +import json +import logging + +from django.conf import settings +from django.test import override_settings +from django.urls import reverse + +from itou.rdv_insertion.models import Appointment, Invitation, Location, Participation, WebhookEvent +from itou.utils.mocks import rdv_insertion as rdv_insertion_mocks +from itou.www.rdv_insertion.views import InvalidSignature, SignatureMissing +from tests.rdv_insertion.factories import InvitationRequestFactory + + +class TestRdvInsertion: + @classmethod + def _make_rdvi_signature(cls, raw_data): + return hmac.new(settings.RDV_INSERTION_WEBHOOK_SECRET.encode(), raw_data, hashlib.sha256).hexdigest() + + def test_webhook_handler_signature_missing(self, client, caplog): + caplog.set_level(logging.INFO, logger="itou.rdv_insertion") + url = reverse("rdv_insertion:webhook") + response = client.post(url, json=rdv_insertion_mocks.RDV_INSERTION_WEBHOOK_INVITATION_BODY) + assert response.status_code == 401 + assert caplog.messages[0] == "Payload encoding is UTF-8" + assert caplog.messages[1] == "Error while handling RDVI webhook" + assert caplog.records[1].exc_info[0] == SignatureMissing + + @override_settings(RDV_INSERTION_WEBHOOK_SECRET="much-much-secret") + def test_webhook_handler_signature_invalid(self, client, caplog): + caplog.set_level(logging.INFO, logger="itou.rdv_insertion") + url = reverse("rdv_insertion:webhook") + response = client.post( + url, + json=rdv_insertion_mocks.RDV_INSERTION_WEBHOOK_INVITATION_BODY, + headers={"x-rdvi-signature": "invalid"}, + ) + assert response.status_code == 403 + assert caplog.messages[0] == "Payload encoding is UTF-8" + assert caplog.messages[1] == "Error while handling RDVI webhook" + assert caplog.records[1].exc_info[0] == InvalidSignature + + @override_settings(RDV_INSERTION_WEBHOOK_SECRET="much-much-secret") + def test_webhook_handler_signature_valid_with_utf8(self, client, caplog): + """ + Test that a signed utf8 payload sent encoded with utf8 is valid. + (RDVI behavior with appointment events) + """ + caplog.set_level(logging.INFO, logger="itou.rdv_insertion") + url = reverse("rdv_insertion:webhook") + raw_data = json.dumps(rdv_insertion_mocks.RDV_INSERTION_WEBHOOK_INVITATION_BODY, ensure_ascii=False).encode() + response = client.post( + url, + data=raw_data, + content_type="application/json", + headers={"x-rdvi-signature": self._make_rdvi_signature(raw_data)}, + ) + assert response.status_code == 200 + assert caplog.messages[0] == "Payload encoding is UTF-8" + + @override_settings(RDV_INSERTION_WEBHOOK_SECRET="much-much-secret") + def test_webhook_handler_signature_valid_with_latin1(self, client, caplog): + """ + Test that a signed utf-8 payload sent encoded with utf-8 is valid. + (RDVI behavior with invitation events) + """ + caplog.set_level(logging.INFO, logger="itou.rdv_insertion") + url = reverse("rdv_insertion:webhook") + raw_data = json.dumps(rdv_insertion_mocks.RDV_INSERTION_WEBHOOK_INVITATION_BODY, ensure_ascii=False).encode() + response = client.post( + url, + data=raw_data.decode().encode("latin-1"), + content_type="application/json", + headers={"x-rdvi-signature": self._make_rdvi_signature(raw_data)}, + ) + assert response.status_code == 200 + assert caplog.messages[0] == "Payload encoding is LATIN-1" + + @override_settings(RDV_INSERTION_WEBHOOK_SECRET="much-much-secret") + def test_webhook_handler_logs_invalid_events(self, client, caplog): + caplog.set_level(logging.WARNING, logger="itou.rdv_insertion") + url = reverse("rdv_insertion:webhook") + raw_data = json.dumps( + { + "data": {}, + "meta": {"event": "updated", "model": "Invalid", "timestamp": "2024-08-15 19:23:12 +0200"}, + }, + ensure_ascii=False, + ).encode() + response = client.post( + url, + data=raw_data.decode().encode("latin-1"), + content_type="application/json", + headers={"x-rdvi-signature": self._make_rdvi_signature(raw_data)}, + ) + assert response.status_code == 200 + assert caplog.messages[0] == "Unhandled event" + + @override_settings(RDV_INSERTION_WEBHOOK_SECRET="much-much-secret") + def test_webhook_handler_does_not_update_invitation(self, client, caplog): + """ + Should ignore events with no invitation requests matching organization + job seeker + """ + caplog.set_level(logging.INFO, logger="itou.rdv_insertion") + url = reverse("rdv_insertion:webhook") + raw_data = json.dumps(rdv_insertion_mocks.RDV_INSERTION_WEBHOOK_INVITATION_BODY, ensure_ascii=False).encode() + response = client.post( + url, + data=raw_data, + content_type="application/json", + headers={"x-rdvi-signature": self._make_rdvi_signature(raw_data)}, + ) + assert response.status_code == 200 + assert caplog.messages[0] == "Payload encoding is UTF-8" + assert caplog.messages[1] == "No invitations matching rdv_insertion_id={}".format( + rdv_insertion_mocks.RDV_INSERTION_WEBHOOK_INVITATION_BODY["data"]["id"] + ) + + # Event must be persisted as-is, unprocessed + webhook_event = WebhookEvent.objects.get() + assert webhook_event.body == rdv_insertion_mocks.RDV_INSERTION_WEBHOOK_INVITATION_BODY + assert not webhook_event.is_processed + + @override_settings(RDV_INSERTION_WEBHOOK_SECRET="much-much-secret") + def test_webhook_handler_updates_invitation(self, client): + body_data = rdv_insertion_mocks.RDV_INSERTION_WEBHOOK_INVITATION_BODY["data"] + + invitation_request = InvitationRequestFactory( + company__rdv_solidarites_id=1234, + rdv_insertion_user_id=body_data["user"]["id"], + email_invitation__status=Invitation.Status.SENT, + email_invitation__rdv_insertion_id=body_data["id"], + ) + + url = reverse("rdv_insertion:webhook") + raw_data = json.dumps(rdv_insertion_mocks.RDV_INSERTION_WEBHOOK_INVITATION_BODY, ensure_ascii=False).encode() + response = client.post( + url, + data=raw_data, + content_type="application/json", + headers={"x-rdvi-signature": self._make_rdvi_signature(raw_data)}, + ) + assert response.status_code == 200 + + # Event must be persisted as-is, unprocessed + webhook_event = WebhookEvent.objects.get() + assert webhook_event.body == rdv_insertion_mocks.RDV_INSERTION_WEBHOOK_INVITATION_BODY + + # Check for updated objects + assert invitation_request.email_invitation.status == Invitation.Status.OPENED + assert invitation_request.email_invitation.delivered_at == datetime.datetime.fromisoformat( + rdv_insertion_mocks.RDV_INSERTION_WEBHOOK_INVITATION_BODY["data"]["delivered_at"] + ) + + @override_settings(RDV_INSERTION_WEBHOOK_SECRET="much-much-secret") + def test_webhook_handler_does_not_create_appointment(self, client, caplog): + """ + Should ignore events with no invitation requests matching organization + job seeker + """ + caplog.set_level(logging.INFO, logger="itou.rdv_insertion") + url = reverse("rdv_insertion:webhook") + raw_data = json.dumps(rdv_insertion_mocks.RDV_INSERTION_WEBHOOK_APPOINTMENT_BODY, ensure_ascii=False).encode() + response = client.post( + url, + data=raw_data, + content_type="application/json", + headers={"x-rdvi-signature": self._make_rdvi_signature(raw_data)}, + ) + assert response.status_code == 200 + assert caplog.messages[0] == "Payload encoding is UTF-8" + assert caplog.messages[1] == "No invitation requests matching rdvs_company_id={}, rdvi_user_ids=[{}]".format( + rdv_insertion_mocks.RDV_INSERTION_WEBHOOK_APPOINTMENT_BODY["data"]["organisation"][ + "rdv_solidarites_organisation_id" + ], + rdv_insertion_mocks.RDV_INSERTION_WEBHOOK_APPOINTMENT_BODY["data"]["users"][0]["id"], + ) + + # Event must be persisted as-is, unprocessed + webhook_event = WebhookEvent.objects.get() + assert webhook_event.body == rdv_insertion_mocks.RDV_INSERTION_WEBHOOK_APPOINTMENT_BODY + assert not webhook_event.is_processed + + # No other objects should be created + assert not Location.objects.exists() + assert not Appointment.objects.exists() + assert not Participation.objects.exists() + + @override_settings(RDV_INSERTION_WEBHOOK_SECRET="much-much-secret") + def test_webhook_handler_creates_appointment(self, client): + body_data = rdv_insertion_mocks.RDV_INSERTION_WEBHOOK_APPOINTMENT_BODY["data"] + + invitation_request = InvitationRequestFactory( + company__rdv_solidarites_id=body_data["organisation"]["rdv_solidarites_organisation_id"], + rdv_insertion_user_id=body_data["users"][0]["id"], + ) + + url = reverse("rdv_insertion:webhook") + raw_data = json.dumps(rdv_insertion_mocks.RDV_INSERTION_WEBHOOK_APPOINTMENT_BODY, ensure_ascii=False).encode() + response = client.post( + url, + data=raw_data, + content_type="application/json", + headers={"x-rdvi-signature": self._make_rdvi_signature(raw_data)}, + ) + assert response.status_code == 200 + + # Event must be persisted as-is, unprocessed + webhook_event = WebhookEvent.objects.get() + assert webhook_event.body == rdv_insertion_mocks.RDV_INSERTION_WEBHOOK_APPOINTMENT_BODY + assert webhook_event.is_processed + + # Check for created objects + appointment = Appointment.objects.prefetch_related("participants").select_related("location").get() + assert appointment.company == invitation_request.company + assert appointment.status == appointment.Status.UNKNOWN + assert appointment.reason_category == appointment.ReasonCategory.SIAE_INTERVIEW + assert appointment.reason == body_data["motif"]["name"] + assert appointment.is_collective == body_data["motif"]["collectif"] + assert appointment.starts_at == datetime.datetime.fromisoformat(body_data["starts_at"]) + assert appointment.duration == datetime.timedelta(minutes=body_data["duration_in_min"]) + assert appointment.canceled_at == datetime.datetime.fromisoformat(body_data["cancelled_at"]) + assert appointment.address == body_data["lieu"]["address"] + assert appointment.total_participants == body_data["users_count"] + assert appointment.max_participants == body_data["max_participants_count"] + assert appointment.rdv_insertion_id == body_data["id"] + + participations = appointment.rdvi_participations.all() + assert len(participations) == 1 + assert participations[0].job_seeker == invitation_request.job_seeker + assert participations[0].status == participations[0].Status.UNKNOWN + assert participations[0].rdv_insertion_user_id == body_data["participations"][0]["user"]["id"] + assert participations[0].rdv_insertion_id == body_data["participations"][0]["id"] + + assert appointment.location.name == body_data["lieu"]["name"] + assert appointment.location.address == body_data["lieu"]["address"] + assert appointment.location.phone_number == body_data["lieu"]["phone_number"] + assert appointment.location.rdv_solidarites_id == body_data["lieu"]["rdv_solidarites_lieu_id"]