[8009] Tito basket signup improvements (#8014)

* rebase

* [8009] update README for Tito integration.

* [8009] wip tito webhook tests

* [8009] move tito events into own app

* [8009] refactor tito webhook code

* [8009] add exception logging to tito webhook basket.subscribe() all

* [8009] move test settings overrides

* [8009] tit tests: reinstate mock for test, switch to test email for test, change how tests are called with invoke

Co-authored-by: Pomax <pomax@nihongoresources.com>
This commit is contained in:
Rich Brennan 2022-02-08 18:53:46 +00:00 коммит произвёл GitHub
Родитель 354f4e278e
Коммит ce64714f0a
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
10 изменённых файлов: 159 добавлений и 23 удалений

Просмотреть файл

@ -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).

Просмотреть файл

Просмотреть файл

@ -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])

Просмотреть файл

@ -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'),

Просмотреть файл

@ -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

Просмотреть файл

@ -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)

Просмотреть файл

@ -244,6 +244,7 @@ INSTALLED_APPS = list(filter(None, [
# the network site
'networkapi',
'networkapi.campaign',
'networkapi.events',
'networkapi.news',
'networkapi.people',
'networkapi.utility',

Просмотреть файл

@ -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(

Просмотреть файл

@ -1,5 +1,3 @@
import { bindEventHandlers } from "./template-js-handler";
import { setupTito } from "./tito.js";
bindEventHandlers();
setupTito();

Просмотреть файл

@ -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"])