refactor: adjust mail ingestion api (#7523)

This commit is contained in:
Jennifer Richards 2024-06-15 16:18:01 -03:00 committed by GitHub
parent 7541c21486
commit 35ab9bf4e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 87 additions and 44 deletions

View file

@ -10,6 +10,7 @@ import sys
from importlib import import_module from importlib import import_module
from pathlib import Path from pathlib import Path
from random import randrange
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
@ -1072,8 +1073,20 @@ class CustomApiTests(TestCase):
self.assertEqual(r.status_code, 400) self.assertEqual(r.status_code, 400)
self.assertFalse(any(m.called for m in mocks)) self.assertFalse(any(m.called for m in mocks))
# test that valid requests call handlers appropriately # bad destination
message_b64 = base64.b64encode(b"This is a message").decode() message_b64 = base64.b64encode(b"This is a message").decode()
r = self.client.post(
url,
{"dest": "not-a-destination", "message": message_b64},
content_type="application/json",
headers={"X-Api-Key": "valid-token"},
)
self.assertEqual(r.status_code, 200)
self.assertEqual(r.headers["Content-Type"], "application/json")
self.assertEqual(json.loads(r.content), {"result": "bad_dest"})
self.assertFalse(any(m.called for m in mocks))
# test that valid requests call handlers appropriately
r = self.client.post( r = self.client.post(
url, url,
{"dest": "iana-review", "message": message_b64}, {"dest": "iana-review", "message": message_b64},
@ -1081,6 +1094,8 @@ class CustomApiTests(TestCase):
headers={"X-Api-Key": "valid-token"}, headers={"X-Api-Key": "valid-token"},
) )
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
self.assertEqual(r.headers["Content-Type"], "application/json")
self.assertEqual(json.loads(r.content), {"result": "ok"})
self.assertTrue(mock_iana_ingest.called) self.assertTrue(mock_iana_ingest.called)
self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message")) self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message"))
self.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest}))) self.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest})))
@ -1093,20 +1108,44 @@ class CustomApiTests(TestCase):
headers={"X-Api-Key": "valid-token"}, headers={"X-Api-Key": "valid-token"},
) )
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
self.assertEqual(r.headers["Content-Type"], "application/json")
self.assertEqual(json.loads(r.content), {"result": "ok"})
self.assertTrue(mock_ipr_ingest.called) self.assertTrue(mock_ipr_ingest.called)
self.assertEqual(mock_ipr_ingest.call_args, mock.call(b"This is a message")) self.assertEqual(mock_ipr_ingest.call_args, mock.call(b"This is a message"))
self.assertFalse(any(m.called for m in (mocks - {mock_ipr_ingest}))) self.assertFalse(any(m.called for m in (mocks - {mock_ipr_ingest})))
mock_ipr_ingest.reset_mock() mock_ipr_ingest.reset_mock()
# bad nomcom-feedback dest
for bad_nomcom_dest in [
"nomcom-feedback", # no suffix
"nomcom-feedback-", # no year
"nomcom-feedback-squid", # not a year,
"nomcom-feedback-2024-2025", # also not a year
]:
r = self.client.post(
url,
{"dest": bad_nomcom_dest, "message": message_b64},
content_type="application/json",
headers={"X-Api-Key": "valid-token"},
)
self.assertEqual(r.status_code, 200)
self.assertEqual(r.headers["Content-Type"], "application/json")
self.assertEqual(json.loads(r.content), {"result": "bad_dest"})
self.assertFalse(any(m.called for m in mocks))
# good nomcom-feedback dest
random_year = randrange(100000)
r = self.client.post( r = self.client.post(
url, url,
{"dest": "nomcom-feedback", "message": message_b64, "year": 2024}, # arbitrary year {"dest": f"nomcom-feedback-{random_year}", "message": message_b64},
content_type="application/json", content_type="application/json",
headers={"X-Api-Key": "valid-token"}, headers={"X-Api-Key": "valid-token"},
) )
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
self.assertEqual(r.headers["Content-Type"], "application/json")
self.assertEqual(json.loads(r.content), {"result": "ok"})
self.assertTrue(mock_nomcom_ingest.called) self.assertTrue(mock_nomcom_ingest.called)
self.assertEqual(mock_nomcom_ingest.call_args, mock.call(b"This is a message", 2024)) self.assertEqual(mock_nomcom_ingest.call_args, mock.call(b"This is a message", random_year))
self.assertFalse(any(m.called for m in (mocks - {mock_nomcom_ingest}))) self.assertFalse(any(m.called for m in (mocks - {mock_nomcom_ingest})))
mock_nomcom_ingest.reset_mock() mock_nomcom_ingest.reset_mock()
@ -1118,7 +1157,9 @@ class CustomApiTests(TestCase):
content_type="application/json", content_type="application/json",
headers={"X-Api-Key": "valid-token"}, headers={"X-Api-Key": "valid-token"},
) )
self.assertEqual(r.status_code, 400) self.assertEqual(r.status_code, 200)
self.assertEqual(r.headers["Content-Type"], "application/json")
self.assertEqual(json.loads(r.content), {"result": "bad_msg"})
self.assertTrue(mock_iana_ingest.called) self.assertTrue(mock_iana_ingest.called)
self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message")) self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message"))
self.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest}))) self.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest})))
@ -1138,7 +1179,9 @@ class CustomApiTests(TestCase):
content_type="application/json", content_type="application/json",
headers={"X-Api-Key": "valid-token"}, headers={"X-Api-Key": "valid-token"},
) )
self.assertEqual(r.status_code, 400) self.assertEqual(r.status_code, 200)
self.assertEqual(r.headers["Content-Type"], "application/json")
self.assertEqual(json.loads(r.content), {"result": "bad_msg"})
self.assertTrue(mock_iana_ingest.called) self.assertTrue(mock_iana_ingest.called)
self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message")) self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message"))
self.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest}))) self.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest})))
@ -1167,7 +1210,9 @@ class CustomApiTests(TestCase):
content_type="application/json", content_type="application/json",
headers={"X-Api-Key": "valid-token"}, headers={"X-Api-Key": "valid-token"},
) )
self.assertEqual(r.status_code, 400) self.assertEqual(r.status_code, 200)
self.assertEqual(r.headers["Content-Type"], "application/json")
self.assertEqual(json.loads(r.content), {"result": "bad_msg"})
self.assertTrue(mock_iana_ingest.called) self.assertTrue(mock_iana_ingest.called)
self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message")) self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message"))
self.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest}))) self.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest})))
@ -1192,7 +1237,9 @@ class CustomApiTests(TestCase):
content_type="application/json", content_type="application/json",
headers={"X-Api-Key": "valid-token"}, headers={"X-Api-Key": "valid-token"},
) )
self.assertEqual(r.status_code, 400) self.assertEqual(r.status_code, 200)
self.assertEqual(r.headers["Content-Type"], "application/json")
self.assertEqual(json.loads(r.content), {"result": "bad_msg"})
self.assertTrue(mock_iana_ingest.called) self.assertTrue(mock_iana_ingest.called)
self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message")) self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message"))
self.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest}))) self.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest})))

View file

@ -8,6 +8,7 @@ import jsonschema
import pytz import pytz
import re import re
from contextlib import suppress
from django.conf import settings from django.conf import settings
from django.contrib.auth import authenticate from django.contrib.auth import authenticate
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
@ -533,32 +534,13 @@ _response_email_json_validator = jsonschema.Draft202012Validator(
"type": "object", "type": "object",
"properties": { "properties": {
"dest": { "dest": {
"enum": [ "type": "string",
"iana-review",
"ipr-response",
"nomcom-feedback",
]
}, },
"message": { "message": {
"type": "string", # base64-encoded mail message "type": "string", # base64-encoded mail message
}, },
}, },
"required": ["dest", "message"], "required": ["dest", "message"],
"if": {
# If dest == "nomcom-feedback"...
"properties": {
"dest": {"const": "nomcom-feedback"},
}
},
"then": {
# ... then also require year, an integer, be present
"properties": {
"year": {
"type": "integer",
},
},
"required": ["year"],
},
} }
) )
@ -630,49 +612,63 @@ class EmailIngestionError(Exception):
@requires_api_token @requires_api_token
@csrf_exempt @csrf_exempt
def ingest_email(request): def ingest_email(request):
"""Ingest incoming email
Returns a 4xx or 5xx status code if the HTTP request was invalid or something went
wrong while processing it. If the request was valid, returns a 200. This may or may
not indicate that the message was accepted.
"""
def _err(code, text): def _http_err(code, text):
return HttpResponse(text, status=code, content_type="text/plain") return HttpResponse(text, status=code, content_type="text/plain")
def _api_response(result):
return JsonResponse(data={"result": result})
if request.method != "POST": if request.method != "POST":
return _err(405, "Method not allowed") return _http_err(405, "Method not allowed")
if request.content_type != "application/json": if request.content_type != "application/json":
return _err(415, "Content-Type must be application/json") return _http_err(415, "Content-Type must be application/json")
# Validate # Validate
try: try:
payload = json.loads(request.body) payload = json.loads(request.body)
_response_email_json_validator.validate(payload) _response_email_json_validator.validate(payload)
except json.decoder.JSONDecodeError as err: except json.decoder.JSONDecodeError as err:
return _err(400, f"JSON parse error at line {err.lineno} col {err.colno}: {err.msg}") return _http_err(400, f"JSON parse error at line {err.lineno} col {err.colno}: {err.msg}")
except jsonschema.exceptions.ValidationError as err: except jsonschema.exceptions.ValidationError as err:
return _err(400, f"JSON schema error at {err.json_path}: {err.message}") return _http_err(400, f"JSON schema error at {err.json_path}: {err.message}")
except Exception: except Exception:
return _err(400, "Invalid request format") return _http_err(400, "Invalid request format")
try: try:
message = base64.b64decode(payload["message"], validate=True) message = base64.b64decode(payload["message"], validate=True)
except binascii.Error: except binascii.Error:
return _err(400, "Invalid message: bad base64 encoding") return _http_err(400, "Invalid message: bad base64 encoding")
dest = payload["dest"] dest = payload["dest"]
valid_dest = False
try: try:
if dest == "iana-review": if dest == "iana-review":
valid_dest = True
iana_ingest_review_email(message) iana_ingest_review_email(message)
elif dest == "ipr-response": elif dest == "ipr-response":
valid_dest = True
ipr_ingest_response_email(message) ipr_ingest_response_email(message)
elif dest == "nomcom-feedback": elif dest.startswith("nomcom-feedback-"):
year = payload["year"] maybe_year = dest[len("nomcom-feedback-"):]
nomcom_ingest_feedback_email(message, year) if maybe_year.isdecimal():
else: valid_dest = True
# Should never get here - json schema validation should enforce the enum nomcom_ingest_feedback_email(message, int(maybe_year))
log.unreachable(date="2024-04-04")
return _err(400, "Invalid dest") # return something reasonable if we got here unexpectedly
except EmailIngestionError as err: except EmailIngestionError as err:
error_email = err.as_emailmessage() error_email = err.as_emailmessage()
if error_email is not None: if error_email is not None:
send_smtp(error_email) with suppress(Exception): # send_smtp logs its own exceptions, ignore them here
return _err(400, err.msg) send_smtp(error_email)
return _api_response("bad_msg")
return HttpResponse(status=200) if not valid_dest:
return _api_response("bad_dest")
return _api_response("ok")