diff --git a/README.md b/README.md index c5cc1ef72..36c4725ce 100755 --- a/README.md +++ b/README.md @@ -93,6 +93,11 @@ The fake data generator can generate a site structure for the Mozilla Festival t In order to access the Mozilla Festival site locally on a different domain than the main Foundation site, you'll need to edit your hosts file (`/etc/hosts` on *nix systems, `C:\Windows\System32\Drivers\etc\hosts` on Windows) to allow you to access the site at `mozfest.localhost:8000`. To enable this, add the following line to your hosts file: `127.0.0.1 mozfest.localhost` +Ticket purchases are implemented using a third-party integration with [Tito](https://ti.to/). +There is a `TitoWidget` Streamfield block that's used to place a button on a page to open the Tito widget. +A webhook (Django view) receives requests from Tito when a ticket is completed in order to sign users up for the Mozilla newsletter. The event-specific environment variables `TITO_SECURITY_TOKEN` and `TITO_NEWSLETTER_QUESTION_ID` are required for this to work, and can be found in the Customize > Webhooks section of the Tito admin dashboard for the event. As these are currently global in the Mozilla site only one Tito event can be supported at a time. + + ## Gotchas As this is REST API and CMS built on top of Django, there are some "gotcha!"s to keep in mind due to the high level of magic in the Django code base (where things will happen automatically without the code explicitly telling you). diff --git a/network-api/networkapi/events/__init__.py b/network-api/networkapi/events/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/network-api/networkapi/events/tests.py b/network-api/networkapi/events/tests.py new file mode 100644 index 000000000..8bf87c622 --- /dev/null +++ b/network-api/networkapi/events/tests.py @@ -0,0 +1,105 @@ +import json +from unittest import mock + +from django.urls import reverse +from django.test import RequestFactory, TestCase, override_settings + +from .views import tito_ticket_completed +from .utils import sign_tito_request + + +TITO_SECURITY_TOKEN = "abcdef123456" +TITO_NEWSLETTER_QUESTION_ID = 123456 + + +@override_settings( + TITO_SECURITY_TOKEN=TITO_SECURITY_TOKEN, + TITO_NEWSLETTER_QUESTION_ID=TITO_NEWSLETTER_QUESTION_ID, +) +class TitoTicketCompletedTest(TestCase): + def setUp(self): + self.url = reverse("tito-ticket-completed") + + def test_incorrect_http_method(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 405) + + def test_incorrect_webhook_name(self): + response = self.client.post(self.url, data={}, HTTP_X_WEBHOOK_NAME="invalid") + self.assertEqual(response.status_code, 400) + self.assertEqual(response.content.decode(), "Not a ticket completed request") + + def test_missing_tito_signature(self): + response = self.client.post( + self.url, data={}, HTTP_X_WEBHOOK_NAME="ticket.completed" + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.content.decode(), "Payload verification failed") + + def test_invalid_tito_signature(self): + response = self.client.post( + self.url, + data={}, + HTTP_X_WEBHOOK_NAME="ticket.completed", + HTTP_TITO_SIGNATURE="invalid", + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.content.decode(), "Payload verification failed") + + @mock.patch("networkapi.events.views.basket") + def test_calls_basket_api(self, mock_basket): + secret = bytes(TITO_SECURITY_TOKEN, "utf-8") + data = { + "answers": [ + { + "question": {"id": TITO_NEWSLETTER_QUESTION_ID}, + "response": ["yes"], + }, + ], + "email": "rich@test.com", + } + + factory = RequestFactory() + request = factory.post( + self.url, + data=json.dumps(data), + content_type="application/json", + HTTP_X_WEBHOOK_NAME="ticket.completed", + ) + request.META["HTTP_TITO_SIGNATURE"] = sign_tito_request(secret, request.body) + + response = tito_ticket_completed(request) + + self.assertEqual(response.status_code, 202) + mock_basket.subscribe.assert_called_once_with( + "rich@test.com", "mozilla-festival" + ) + + def test_logs_basket_exception(self): + # Using `failure@example.com` as the email causes an exception, see: + # https://github.com/mozilla/basket-example#tips + + secret = bytes(TITO_SECURITY_TOKEN, "utf-8") + data = { + "answers": [ + { + "question": {"id": TITO_NEWSLETTER_QUESTION_ID}, + "response": ["yes"], + }, + ], + "email": "failure@example.com", + } + + factory = RequestFactory() + request = factory.post( + self.url, + data=json.dumps(data), + content_type="application/json", + HTTP_X_WEBHOOK_NAME="ticket.completed", + ) + request.META["HTTP_TITO_SIGNATURE"] = sign_tito_request(secret, request.body) + + with self.assertLogs(logger="networkapi.events.views", level="ERROR") as cm: + response = tito_ticket_completed(request) + self.assertEqual(response.status_code, 202) + self.assertIn("Basket subscription from Tito webhook failed", cm.output[0]) diff --git a/network-api/networkapi/mozfest/urls.py b/network-api/networkapi/events/urls.py similarity index 70% rename from network-api/networkapi/mozfest/urls.py rename to network-api/networkapi/events/urls.py index 9b32234a2..40419db20 100644 --- a/network-api/networkapi/mozfest/urls.py +++ b/network-api/networkapi/events/urls.py @@ -1,6 +1,6 @@ from django.urls import re_path -from networkapi.mozfest.views import tito_ticket_completed +from .views import tito_ticket_completed urlpatterns = [ re_path('^ticket-completed/?$', tito_ticket_completed, name='tito-ticket-completed'), diff --git a/network-api/networkapi/events/utils.py b/network-api/networkapi/events/utils.py new file mode 100644 index 000000000..4e8cf32c3 --- /dev/null +++ b/network-api/networkapi/events/utils.py @@ -0,0 +1,29 @@ +import base64 +import hashlib +import hmac + +from django.conf import settings + + +def is_valid_tito_request(signature, request_body): + secret = bytes(settings.TITO_SECURITY_TOKEN, "utf-8") + signed = sign_tito_request(secret, request_body) + + return signature == signed + + +def sign_tito_request(secret, content): + # https://ti.to/docs/api/admin#webhooks-verifying-the-payload + return base64.b64encode( + hmac.new(secret, content, digestmod=hashlib.sha256).digest() + ).decode("utf-8") + + +def has_signed_up_to_newsletter(tito_answers): + for answer in tito_answers: + if answer["question"]["id"] == int( + settings.TITO_NEWSLETTER_QUESTION_ID + ) and len(answer["response"]): + return True + + return False diff --git a/network-api/networkapi/mozfest/views.py b/network-api/networkapi/events/views.py similarity index 58% rename from network-api/networkapi/mozfest/views.py rename to network-api/networkapi/events/views.py index fdc6a7630..60cc73a8c 100644 --- a/network-api/networkapi/mozfest/views.py +++ b/network-api/networkapi/events/views.py @@ -1,14 +1,16 @@ -import base64 -import hashlib -import hmac import json +import logging import basket -from django.conf import settings + from django.http import HttpResponseBadRequest, HttpResponse from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST +from .utils import has_signed_up_to_newsletter, is_valid_tito_request + +logger = logging.getLogger(__name__) + # To configure endpoints see: # https://ti.to/Mozilla/mozilla-festival-2022/admin/settings/webhook_endpoints @@ -22,24 +24,20 @@ def tito_ticket_completed(request): return HttpResponseBadRequest("Not a ticket completed request") # does the payload hash signature match - # https://ti.to/docs/api/admin#webhooks-verifying-the-payload tito_signature = request.META.get("HTTP_TITO_SIGNATURE", "") - secret = bytes(settings.TITO_SECURITY_TOKEN, "utf-8") - signature = base64.b64encode( - hmac.new(secret, request.body, digestmod=hashlib.sha256).digest() - ).decode("utf-8") - - if tito_signature != signature: + if not is_valid_tito_request(tito_signature, request.body): return HttpResponseBadRequest("Payload verification failed") # have they signed up to the newsletter? data = json.loads(request.body.decode()) - for answer in data.get("answers", []): - if ( - answer["question"]["id"] == int(settings.TITO_NEWSLETTER_QUESTION_ID) - and len(answer["response"]) - and data.get("email") - ): - basket.subscribe(data.get("email"), "mozilla-festival") + email = data.get("email") + + if email and has_signed_up_to_newsletter(data.get("answers", [])): + try: + basket.subscribe(email, "mozilla-festival") + except Exception as error: + logger.exception( + f"Basket subscription from Tito webhook failed: {str(error)}" + ) return HttpResponse(status=202) diff --git a/network-api/networkapi/settings.py b/network-api/networkapi/settings.py index fdc00c8ae..751cd607c 100644 --- a/network-api/networkapi/settings.py +++ b/network-api/networkapi/settings.py @@ -244,6 +244,7 @@ INSTALLED_APPS = list(filter(None, [ # the network site 'networkapi', 'networkapi.campaign', + 'networkapi.events', 'networkapi.news', 'networkapi.people', 'networkapi.utility', diff --git a/network-api/networkapi/urls.py b/network-api/networkapi/urls.py index 29574df73..1c8abf8aa 100644 --- a/network-api/networkapi/urls.py +++ b/network-api/networkapi/urls.py @@ -86,7 +86,7 @@ urlpatterns = list(filter(None, [ re_path(r'^api/people/', include('networkapi.people.urls')), re_path(r'^environment.json', EnvVariablesView.as_view()), re_path(r'^help/', review_app_help_view, name='Review app help'), - re_path(r'^tito/', include('networkapi.mozfest.urls')), + re_path(r'^tito/', include('networkapi.events.urls')), # Wagtail CMS routes re_path( diff --git a/source/js/foundation/pages/mozfest/index.js b/source/js/foundation/pages/mozfest/index.js index 22ccacc0b..867fea116 100644 --- a/source/js/foundation/pages/mozfest/index.js +++ b/source/js/foundation/pages/mozfest/index.js @@ -1,5 +1,3 @@ import { bindEventHandlers } from "./template-js-handler"; -import { setupTito } from "./tito.js"; bindEventHandlers(); -setupTito(); diff --git a/tasks.py b/tasks.py index 9b8760d9b..3183b4789 100644 --- a/tasks.py +++ b/tasks.py @@ -243,7 +243,7 @@ def test_python(ctx): **PLATFORM_ARG, ) print("* Running tests") - manage(ctx, "test") + manage(ctx, "test networkapi") @task(aliases=["docker-test-node"])