feat(python): catch all exceptions thrown through fastapi route paths

This commit does quite a bit:
- Catches unhandled exceptions raised in the route handler and
  produces a 500 Internal Server Error Arch-themed response.
- Each unhandled exception causes a notification to be sent to new
  `notifications.postmaster` email with a "Traceback ID."
- Traceback ID is logged to the server along with the traceback which
  caused the 500: `docker-compose logs fastapi | grep '<traceback_id>'`
- If `options.traceback` is set to `1`, traceback is displayed in
  the new 500.html template.

Signed-off-by: Kevin Morris <kevr@0cost.org>
This commit is contained in:
Kevin Morris 2022-01-09 22:32:49 -08:00
parent c775e8a692
commit d675c0dc26
No known key found for this signature in database
GPG key ID: F7E46DED420788F3
10 changed files with 230 additions and 14 deletions

View file

@ -1,19 +1,28 @@
import http
import os
import re
from unittest import mock
import fastapi
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
import aurweb.asgi
import aurweb.config
import aurweb.redis
from aurweb.testing.email import Email
from aurweb.testing.requests import Request
@pytest.fixture
def setup(db_test, email_test):
return
@pytest.mark.asyncio
async def test_asgi_startup_session_secret_exception(monkeypatch):
""" Test that we get an IOError on app_startup when we cannot
@ -66,3 +75,45 @@ async def test_asgi_app_unsupported_backends():
expr = r"^.*\(sqlite\) is unsupported.*$"
with pytest.raises(ValueError, match=expr):
await aurweb.asgi.app_startup()
def test_internal_server_error(setup: None,
caplog: pytest.LogCaptureFixture):
config_getboolean = aurweb.config.getboolean
def mock_getboolean(section: str, key: str) -> bool:
if section == "options" and key == "traceback":
return True
return config_getboolean(section, key)
@aurweb.asgi.app.get("/internal_server_error")
async def internal_server_error(request: fastapi.Request):
raise ValueError("test exception")
with mock.patch("aurweb.config.getboolean", side_effect=mock_getboolean):
with TestClient(app=aurweb.asgi.app) as request:
resp = request.get("/internal_server_error")
assert resp.status_code == int(http.HTTPStatus.INTERNAL_SERVER_ERROR)
# Let's assert that a notification was sent out to the postmaster.
assert Email.count() == 1
aur_location = aurweb.config.get("options", "aur_location")
email = Email(1)
assert f"Location: {aur_location}" in email.body
assert "Traceback ID:" in email.body
assert "Version:" in email.body
assert "Datetime:" in email.body
assert f"[1] {aur_location}" in email.body
# Assert that the exception got logged with with its traceback id.
expr = r"FATAL\[.{7}\]"
assert re.search(expr, caplog.text)
# Let's do it again; no email should be sent the next time,
# since the hash is stored in redis.
with mock.patch("aurweb.config.getboolean", side_effect=mock_getboolean):
with TestClient(app=aurweb.asgi.app) as request:
resp = request.get("/internal_server_error")
assert resp.status_code == int(http.HTTPStatus.INTERNAL_SERVER_ERROR)
assert Email.count() == 1

View file

@ -1,3 +1,5 @@
import re
from datetime import datetime
from http import HTTPStatus
from unittest import mock
@ -322,9 +324,13 @@ def test_generate_unique_sid_exhausted(client: TestClient, user: User,
response = request.post("/login", data=post_data, cookies={})
assert response.status_code == int(HTTPStatus.INTERNAL_SERVER_ERROR)
expected = "Unable to generate a unique session ID"
assert expected in response.text
assert "500 - Internal Server Error" in response.text
# Make sure an IntegrityError from the DB got logged out.
# Make sure an IntegrityError from the DB got logged out
# with a FATAL traceback ID.
expr = r"FATAL\[.{7}\]"
assert re.search(expr, caplog.text)
assert "IntegrityError" in caplog.text
expr = r"Duplicate entry .+ for key .+SessionID.+"
assert re.search(expr, response.text)