diff --git a/aurweb/asgi.py b/aurweb/asgi.py index 2dd546aa..ad0b7ca0 100644 --- a/aurweb/asgi.py +++ b/aurweb/asgi.py @@ -9,6 +9,8 @@ import typing from urllib.parse import quote_plus +import requests + from fastapi import FastAPI, HTTPException, Request, Response from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles @@ -33,7 +35,6 @@ from aurweb.packages.util import get_pkg_or_base from aurweb.prometheus import instrumentator from aurweb.redis import redis_connection from aurweb.routers import APP_ROUTES -from aurweb.scripts import notify from aurweb.templates import make_context, render_template logger = logging.get_logger(__name__) @@ -109,6 +110,10 @@ async def internal_server_error(request: Request, exc: Exception) -> Response: :param request: FastAPI Request :return: Rendered 500.html template with status_code 500 """ + repo = aurweb.config.get("notifications", "gitlab-instance") + project = aurweb.config.get("notifications", "error-project") + token = aurweb.config.get("notifications", "error-token") + context = make_context(request, "Internal Server Error") # Print out the exception via `traceback` and store the value @@ -120,39 +125,80 @@ async def internal_server_error(request: Request, exc: Exception) -> Response: # Produce a SHA1 hash of the traceback string. tb_hash = hashlib.sha1(tb.encode()).hexdigest() - - # Use the first 7 characters of the sha1 for the traceback id. - # We will use this to log and include in the notification. tb_id = tb_hash[:7] redis = redis_connection() - pipe = redis.pipeline() key = f"tb:{tb_hash}" - pipe.get(key) - retval, = pipe.execute() + retval = redis.get(key) if not retval: # Expire in one hour; this is just done to make sure we # don't infinitely store these values, but reduce the number # of automated reports (notification below). At this time of # writing, unexpected exceptions are not common, thus this # will not produce a large memory footprint in redis. + pipe = redis.pipeline() pipe.set(key, tb) - pipe.expire(key, 3600) + pipe.expire(key, 86400) # One day. pipe.execute() # Send out notification about it. - notif = notify.ServerErrorNotification( - tb_id, context.get("version"), context.get("utcnow")) - notif.send() + if "set-me" not in (project, token): + proj = quote_plus(project) + endp = f"{repo}/api/v4/projects/{proj}/issues" - retval = tb + base = f"{request.url.scheme}://{request.url.netloc}" + title = f"Traceback [{tb_id}]: {base}{request.url.path}" + desc = [ + "DISCLAIMER", + "----------", + "**This issue is confidential** and should be sanitized " + "before sharing with users or developers. Please ensure " + "you've completed the following tasks:", + "- [ ] I have removed any sensitive data and " + "the description history.", + "", + "Exception Details", + "-----------------", + f"- Route: `{request.url.path}`", + f"- User: `{request.user.Username}`", + f"- Email: `{request.user.Email}`", + ] + + # Add method-specific information to the description. + if request.method.lower() == "get": + # get + if request.url.query: + desc = desc + [f"- Query: `{request.url.query}`"] + desc += ["", f"```{tb}```"] + else: + # post + form_data = str(dict(request.state.form_data)) + desc = desc + [ + f"- Data: `{form_data}`" + ] + ["", f"```{tb}```"] + + headers = {"Authorization": f"Bearer {token}"} + data = { + "title": title, + "description": "\n".join(desc), + "labels": ["triage"], + "confidential": True, + } + logger.info(endp) + resp = requests.post(endp, json=data, headers=headers) + if resp.status_code != http.HTTPStatus.CREATED: + logger.error( + f"Unable to report exception to {repo}: {resp.text}") + else: + logger.warning("Unable to report an exception found due to " + "unset notifications.error-{{project,token}}") + + # Log details about the exception traceback. + logger.error(f"FATAL[{tb_id}]: An unexpected exception has occurred.") + logger.error(tb) else: retval = retval.decode() - # Log details about the exception traceback. - logger.error(f"FATAL[{tb_id}]: An unexpected exception has occurred.") - logger.error(retval) - return render_template(request, "errors/500.html", context, status_code=http.HTTPStatus.INTERNAL_SERVER_ERROR) diff --git a/aurweb/auth/__init__.py b/aurweb/auth/__init__.py index c4f433c0..cb6f3e4d 100644 --- a/aurweb/auth/__init__.py +++ b/aurweb/auth/__init__.py @@ -33,6 +33,8 @@ class AnonymousUser: makes a request against FastAPI. """ # Stub attributes used to mimic a real user. ID = 0 + Username = "N/A" + Email = "N/A" class AccountType: """ A stubbed AccountType static class. In here, we use an ID diff --git a/aurweb/config.py b/aurweb/config.py index 728f4b11..4f8ae55f 100644 --- a/aurweb/config.py +++ b/aurweb/config.py @@ -6,7 +6,7 @@ from typing import Any # Publicly visible version of aurweb. This is used to display # aurweb versioning in the footer and must be maintained. # Todo: Make this dynamic/automated. -AURWEB_VERSION = "v6.0.9" +AURWEB_VERSION = "v6.0.10" _parser = None diff --git a/aurweb/exceptions.py b/aurweb/exceptions.py index 1c45b7f3..30a3df08 100644 --- a/aurweb/exceptions.py +++ b/aurweb/exceptions.py @@ -1,4 +1,8 @@ -from typing import Any +import functools + +from typing import Any, Callable + +import fastapi class AurwebException(Exception): @@ -90,3 +94,19 @@ class ValidationError(AurwebException): class InvariantError(AurwebException): pass + + +def handle_form_exceptions(route: Callable) -> fastapi.Response: + """ + A decorator required when fastapi POST routes are defined. + + This decorator populates fastapi's `request.state` with a `form_data` + attribute, which is then used to report form data when exceptions + are caught and reported. + """ + + @functools.wraps(route) + async def wrapper(request: fastapi.Request, *args, **kwargs): + request.state.form_data = await request.form() + return await route(request, *args, **kwargs) + return wrapper diff --git a/aurweb/routers/accounts.py b/aurweb/routers/accounts.py index 36ac48d2..b603d22a 100644 --- a/aurweb/routers/accounts.py +++ b/aurweb/routers/accounts.py @@ -13,7 +13,7 @@ import aurweb.config from aurweb import cookies, db, l10n, logging, models, util from aurweb.auth import account_type_required, requires_auth, requires_guest from aurweb.captcha import get_captcha_salts -from aurweb.exceptions import ValidationError +from aurweb.exceptions import ValidationError, handle_form_exceptions from aurweb.l10n import get_translator_for_request from aurweb.models import account_type as at from aurweb.models.ssh_pub_key import get_fingerprint @@ -35,6 +35,7 @@ async def passreset(request: Request): @router.post("/passreset", response_class=HTMLResponse) +@handle_form_exceptions @requires_guest async def passreset_post(request: Request, user: str = Form(...), @@ -253,6 +254,7 @@ async def account_register(request: Request, @router.post("/register", response_class=HTMLResponse) +@handle_form_exceptions @requires_guest async def account_register_post(request: Request, U: str = Form(default=str()), # Username @@ -369,6 +371,7 @@ async def account_edit(request: Request, username: str): @router.post("/account/{username}/edit", response_class=HTMLResponse) +@handle_form_exceptions @requires_auth async def account_edit_post(request: Request, username: str, @@ -492,6 +495,7 @@ async def accounts(request: Request): @router.post("/accounts") +@handle_form_exceptions @requires_auth @account_type_required({at.TRUSTED_USER, at.DEVELOPER, @@ -601,6 +605,7 @@ async def terms_of_service(request: Request): @router.post("/tos") +@handle_form_exceptions @requires_auth async def terms_of_service_post(request: Request, accept: bool = Form(default=False)): diff --git a/aurweb/routers/auth.py b/aurweb/routers/auth.py index 5d88ed48..fc5209ce 100644 --- a/aurweb/routers/auth.py +++ b/aurweb/routers/auth.py @@ -8,6 +8,7 @@ import aurweb.config from aurweb import cookies, db, time from aurweb.auth import requires_auth, requires_guest +from aurweb.exceptions import handle_form_exceptions from aurweb.l10n import get_translator_for_request from aurweb.models import User from aurweb.templates import make_variable_context, render_template @@ -29,6 +30,7 @@ async def login_get(request: Request, next: str = "/"): @router.post("/login", response_class=HTMLResponse) +@handle_form_exceptions @requires_guest async def login_post(request: Request, next: str = Form(...), @@ -82,6 +84,7 @@ async def login_post(request: Request, @router.post("/logout") +@handle_form_exceptions @requires_auth async def logout(request: Request, next: str = Form(default="/")): if request.user.is_authenticated(): diff --git a/aurweb/routers/html.py b/aurweb/routers/html.py index 8d32089a..b9d291d2 100644 --- a/aurweb/routers/html.py +++ b/aurweb/routers/html.py @@ -15,6 +15,7 @@ import aurweb.models.package_request from aurweb import cookies, db, models, time, util from aurweb.cache import db_count_cache +from aurweb.exceptions import handle_form_exceptions from aurweb.models.account_type import TRUSTED_USER_AND_DEV_ID, TRUSTED_USER_ID from aurweb.models.package_request import PENDING_ID from aurweb.packages.util import query_notified, query_voted, updated_packages @@ -31,6 +32,7 @@ async def favicon(request: Request): @router.post("/language", response_class=RedirectResponse) +@handle_form_exceptions async def language(request: Request, set_lang: str = Form(...), next: str = Form(...), diff --git a/aurweb/routers/packages.py b/aurweb/routers/packages.py index 95e934ea..8f6cb7d6 100644 --- a/aurweb/routers/packages.py +++ b/aurweb/routers/packages.py @@ -8,7 +8,7 @@ import aurweb.filters # noqa: F401 from aurweb import config, db, defaults, logging, models, util from aurweb.auth import creds, requires_auth -from aurweb.exceptions import InvariantError +from aurweb.exceptions import InvariantError, handle_form_exceptions from aurweb.models.relation_type import CONFLICTS_ID, PROVIDES_ID, REPLACES_ID from aurweb.packages import util as pkgutil from aurweb.packages.search import PackageSearch @@ -416,6 +416,7 @@ PACKAGE_ACTIONS = { @router.post("/packages") +@handle_form_exceptions @requires_auth async def packages_post(request: Request, IDs: List[int] = Form(default=[]), diff --git a/aurweb/routers/pkgbase.py b/aurweb/routers/pkgbase.py index 045454ec..23651350 100644 --- a/aurweb/routers/pkgbase.py +++ b/aurweb/routers/pkgbase.py @@ -6,7 +6,7 @@ from sqlalchemy import and_ from aurweb import config, db, l10n, logging, templates, time, util from aurweb.auth import creds, requires_auth -from aurweb.exceptions import InvariantError, ValidationError +from aurweb.exceptions import InvariantError, ValidationError, handle_form_exceptions from aurweb.models import PackageBase from aurweb.models.package_comment import PackageComment from aurweb.models.package_keyword import PackageKeyword @@ -88,6 +88,7 @@ async def pkgbase_flag_comment(request: Request, name: str): @router.post("/pkgbase/{name}/keywords") +@handle_form_exceptions async def pkgbase_keywords(request: Request, name: str, keywords: str = Form(default=str())): pkgbase = get_pkg_or_base(name, PackageBase) @@ -130,6 +131,7 @@ async def pkgbase_flag_get(request: Request, name: str): @router.post("/pkgbase/{name}/flag") +@handle_form_exceptions @requires_auth async def pkgbase_flag_post(request: Request, name: str, comments: str = Form(default=str())): @@ -158,6 +160,7 @@ async def pkgbase_flag_post(request: Request, name: str, @router.post("/pkgbase/{name}/comments") +@handle_form_exceptions @requires_auth async def pkgbase_comments_post( request: Request, name: str, @@ -254,6 +257,7 @@ async def pkgbase_comment_edit(request: Request, name: str, id: int, @router.post("/pkgbase/{name}/comments/{id}") +@handle_form_exceptions @requires_auth async def pkgbase_comment_post( request: Request, name: str, id: int, @@ -294,6 +298,7 @@ async def pkgbase_comment_post( @router.post("/pkgbase/{name}/comments/{id}/pin") +@handle_form_exceptions @requires_auth async def pkgbase_comment_pin(request: Request, name: str, id: int, next: str = Form(default=None)): @@ -328,6 +333,7 @@ async def pkgbase_comment_pin(request: Request, name: str, id: int, @router.post("/pkgbase/{name}/comments/{id}/unpin") +@handle_form_exceptions @requires_auth async def pkgbase_comment_unpin(request: Request, name: str, id: int, next: str = Form(default=None)): @@ -361,6 +367,7 @@ async def pkgbase_comment_unpin(request: Request, name: str, id: int, @router.post("/pkgbase/{name}/comments/{id}/delete") +@handle_form_exceptions @requires_auth async def pkgbase_comment_delete(request: Request, name: str, id: int, next: str = Form(default=None)): @@ -400,6 +407,7 @@ async def pkgbase_comment_delete(request: Request, name: str, id: int, @router.post("/pkgbase/{name}/comments/{id}/undelete") +@handle_form_exceptions @requires_auth async def pkgbase_comment_undelete(request: Request, name: str, id: int, next: str = Form(default=None)): @@ -438,6 +446,7 @@ async def pkgbase_comment_undelete(request: Request, name: str, id: int, @router.post("/pkgbase/{name}/vote") +@handle_form_exceptions @requires_auth async def pkgbase_vote(request: Request, name: str): pkgbase = get_pkg_or_base(name, PackageBase) @@ -462,6 +471,7 @@ async def pkgbase_vote(request: Request, name: str): @router.post("/pkgbase/{name}/unvote") +@handle_form_exceptions @requires_auth async def pkgbase_unvote(request: Request, name: str): pkgbase = get_pkg_or_base(name, PackageBase) @@ -482,6 +492,7 @@ async def pkgbase_unvote(request: Request, name: str): @router.post("/pkgbase/{name}/notify") +@handle_form_exceptions @requires_auth async def pkgbase_notify(request: Request, name: str): pkgbase = get_pkg_or_base(name, PackageBase) @@ -491,6 +502,7 @@ async def pkgbase_notify(request: Request, name: str): @router.post("/pkgbase/{name}/unnotify") +@handle_form_exceptions @requires_auth async def pkgbase_unnotify(request: Request, name: str): pkgbase = get_pkg_or_base(name, PackageBase) @@ -500,6 +512,7 @@ async def pkgbase_unnotify(request: Request, name: str): @router.post("/pkgbase/{name}/unflag") +@handle_form_exceptions @requires_auth async def pkgbase_unflag(request: Request, name: str): pkgbase = get_pkg_or_base(name, PackageBase) @@ -527,6 +540,7 @@ async def pkgbase_disown_get(request: Request, name: str, @router.post("/pkgbase/{name}/disown") +@handle_form_exceptions @requires_auth async def pkgbase_disown_post(request: Request, name: str, comments: str = Form(default=str()), @@ -565,6 +579,7 @@ async def pkgbase_disown_post(request: Request, name: str, @router.post("/pkgbase/{name}/adopt") +@handle_form_exceptions @requires_auth async def pkgbase_adopt_post(request: Request, name: str): pkgbase = get_pkg_or_base(name, PackageBase) @@ -607,6 +622,7 @@ async def pkgbase_comaintainers(request: Request, name: str) -> Response: @router.post("/pkgbase/{name}/comaintainers") +@handle_form_exceptions @requires_auth async def pkgbase_comaintainers_post(request: Request, name: str, users: str = Form(default=str())) \ @@ -660,6 +676,7 @@ async def pkgbase_request(request: Request, name: str, @router.post("/pkgbase/{name}/request") +@handle_form_exceptions @requires_auth async def pkgbase_request_post(request: Request, name: str, type: str = Form(...), @@ -755,6 +772,7 @@ async def pkgbase_delete_get(request: Request, name: str, @router.post("/pkgbase/{name}/delete") +@handle_form_exceptions @requires_auth async def pkgbase_delete_post(request: Request, name: str, confirm: bool = Form(default=False), @@ -819,6 +837,7 @@ async def pkgbase_merge_get(request: Request, name: str, @router.post("/pkgbase/{name}/merge") +@handle_form_exceptions @requires_auth async def pkgbase_merge_post(request: Request, name: str, into: str = Form(default=str()), diff --git a/aurweb/routers/requests.py b/aurweb/routers/requests.py index ac69f079..086aa3bc 100644 --- a/aurweb/routers/requests.py +++ b/aurweb/routers/requests.py @@ -6,6 +6,7 @@ from sqlalchemy import case from aurweb import db, defaults, time, util from aurweb.auth import creds, requires_auth +from aurweb.exceptions import handle_form_exceptions from aurweb.models import PackageRequest, User from aurweb.models.package_request import PENDING_ID, REJECTED_ID from aurweb.requests.util import get_pkgreq_by_id @@ -63,6 +64,7 @@ async def request_close(request: Request, id: int): @router.post("/requests/{id}/close") +@handle_form_exceptions @requires_auth async def request_close_post(request: Request, id: int, comments: str = Form(default=str())): diff --git a/aurweb/routers/rpc.py b/aurweb/routers/rpc.py index 17eb377c..49e98f8c 100644 --- a/aurweb/routers/rpc.py +++ b/aurweb/routers/rpc.py @@ -11,6 +11,7 @@ from fastapi import APIRouter, Form, Query, Request, Response from fastapi.responses import JSONResponse from aurweb import defaults +from aurweb.exceptions import handle_form_exceptions from aurweb.ratelimit import check_ratelimit from aurweb.rpc import RPC, documentation @@ -150,6 +151,7 @@ async def rpc(request: Request, @router.get("/rpc.php") # Temporary! Remove on 03/04 @router.post("/rpc/") @router.post("/rpc") +@handle_form_exceptions async def rpc_post(request: Request, v: Optional[int] = Form(default=None), type: Optional[str] = Form(default=None), diff --git a/aurweb/routers/trusted_user.py b/aurweb/routers/trusted_user.py index 71f0e932..2d6ea92c 100644 --- a/aurweb/routers/trusted_user.py +++ b/aurweb/routers/trusted_user.py @@ -9,6 +9,7 @@ from sqlalchemy import and_, func, or_ from aurweb import db, l10n, logging, models, time from aurweb.auth import creds, requires_auth +from aurweb.exceptions import handle_form_exceptions from aurweb.models import User from aurweb.models.account_type import TRUSTED_USER_AND_DEV_ID, TRUSTED_USER_ID from aurweb.templates import make_context, make_variable_context, render_template @@ -173,6 +174,7 @@ async def trusted_user_proposal(request: Request, proposal: int): @router.post("/tu/{proposal}") +@handle_form_exceptions @requires_auth async def trusted_user_proposal_post(request: Request, proposal: int, decision: str = Form(...)): @@ -245,6 +247,7 @@ async def trusted_user_addvote(request: Request, user: str = str(), @router.post("/addvote") +@handle_form_exceptions @requires_auth async def trusted_user_addvote_post(request: Request, user: str = Form(default=str()), diff --git a/aurweb/scripts/notify.py b/aurweb/scripts/notify.py index bc06085c..c823b09e 100755 --- a/aurweb/scripts/notify.py +++ b/aurweb/scripts/notify.py @@ -7,8 +7,6 @@ import subprocess import sys import textwrap -from typing import List, Tuple - from sqlalchemy import and_, or_ import aurweb.config @@ -16,7 +14,7 @@ import aurweb.db import aurweb.filters import aurweb.l10n -from aurweb import db, l10n, logging +from aurweb import db, logging from aurweb.models import PackageBase, User from aurweb.models.package_comaintainer import PackageComaintainer from aurweb.models.package_comment import PackageComment @@ -130,48 +128,6 @@ class Notification: logger.error(str(exc)) -class ServerErrorNotification(Notification): - """ A notification used to represent an internal server error. """ - - def __init__(self, traceback_id: int, version: str, utc: int): - """ - Construct a ServerErrorNotification. - - :param traceback_id: Traceback ID - :param version: aurweb version - :param utc: UTC timestamp - """ - self._tb_id = traceback_id - self._version = version - self._utc = utc - - postmaster = aurweb.config.get("notifications", "postmaster") - self._to = postmaster - - super().__init__() - - def get_recipients(self) -> List[Tuple[str, str]]: - from aurweb.auth import AnonymousUser - user = (db.query(User).filter(User.Email == self._to).first() - or AnonymousUser()) - return [(self._to, user.LangPreference)] - - def get_subject(self, lang: str) -> str: - return l10n.translator.translate("AUR Server Error", lang) - - def get_body(self, lang: str) -> str: - """ A forcibly English email body. """ - dt = aurweb.filters.timestamp_to_datetime(self._utc) - dts = dt.strftime("%Y-%m-%d %H:%M") - return (f"Traceback ID: {self._tb_id}\n" - f"Location: {aur_location}\n" - f"Version: {self._version}\n" - f"Datetime: {dts} UTC\n") - - def get_refs(self): - return (aur_location,) - - class ResetKeyNotification(Notification): def __init__(self, uid): diff --git a/cliff.toml b/cliff.toml index fba14d28..12cd7e0b 100644 --- a/cliff.toml +++ b/cliff.toml @@ -32,7 +32,7 @@ conventional_commits = true # regex for parsing and grouping commits commit_parsers = [ { message = "^feat", group = "Features"}, - { message = "^fix", group = "Bug Fixes"}, + { message = "^fix", group = "Bugfixes"}, { message = "^doc", group = "Documentation"}, { message = "^perf", group = "Performance"}, { message = "^change", group = "Changes" }, diff --git a/conf/config.defaults b/conf/config.defaults index cb96bad2..371c99b2 100644 --- a/conf/config.defaults +++ b/conf/config.defaults @@ -67,9 +67,24 @@ smtp-user = smtp-password = sender = notify@aur.archlinux.org reply-to = noreply@aur.archlinux.org -; Administration email which will receive notifications about + +; Gitlab instance base URL. We use this instance to report +; server errors in the form of confidential issues (see error-project). +gitlab-instance = https://gitlab.archlinux.org + +; Project URI which will received confidential issues about ; various server details like uncaught exceptions. -postmaster = admin@example.org +; Errors reported will be filed using the 'triage' label, and so +; the 'triage' label must exist in any project URI given. +; +; - must be a valid project URI on notifications.error-repository +; - must contain a 'triage' label +; +error-project = set-me + +; Gitlab access token with API privileges to post +; notifications.error-project issues. +error-token = set-me [fingerprints] Ed25519 = SHA256:HQ03dn6EasJHNDlt51KpQpFkT3yBX83x7BoIkA1iv2k diff --git a/pyproject.toml b/pyproject.toml index 8c7ede9b..2bd77eba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ # [tool.poetry] name = "aurweb" -version = "v6.0.9" +version = "v6.0.10" license = "GPL-2.0-only" description = "Source code for the Arch User Repository's website" homepage = "https://aur.archlinux.org" diff --git a/templates/dashboard.html b/templates/dashboard.html index ec998187..48f42dc6 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -63,3 +63,5 @@ {% endif %} + + diff --git a/test/test_asgi.py b/test/test_asgi.py index af13266f..667ae871 100644 --- a/test/test_asgi.py +++ b/test/test_asgi.py @@ -2,6 +2,7 @@ import http import os import re +from typing import Callable from unittest import mock import fastapi @@ -14,13 +15,39 @@ import aurweb.asgi import aurweb.config import aurweb.redis -from aurweb.testing.email import Email +from aurweb.exceptions import handle_form_exceptions from aurweb.testing.requests import Request @pytest.fixture def setup(db_test, email_test): - return + aurweb.redis.redis_connection().flushall() + yield + aurweb.redis.redis_connection().flushall() + + +@pytest.fixture +def mock_glab_request(monkeypatch): + def wrapped(return_value=None, side_effect=None): + def what_to_return(*args, **kwargs): + if side_effect: + return side_effect # pragma: no cover + return return_value + monkeypatch.setattr("requests.post", what_to_return) + return wrapped + + +def mock_glab_config(project: str = "test/project", token: str = "test-token"): + config_get = aurweb.config.get + + def wrapper(section: str, key: str) -> str: + if section == "notifications": + if key == "error-project": + return project + elif key == "error-token": + return token + return config_get(section, key) + return wrapper @pytest.mark.asyncio @@ -77,8 +104,8 @@ async def test_asgi_app_unsupported_backends(): await aurweb.asgi.app_startup() -def test_internal_server_error(setup: None, - caplog: pytest.LogCaptureFixture): +@pytest.fixture +def use_traceback(): config_getboolean = aurweb.config.getboolean def mock_getboolean(section: str, key: str) -> bool: @@ -86,34 +113,100 @@ def test_internal_server_error(setup: None, return True return config_getboolean(section, key) + with mock.patch("aurweb.config.getboolean", side_effect=mock_getboolean): + yield + + +class FakeResponse: + def __init__(self, status_code: int = 201, text: str = "{}"): + self.status_code = status_code + self.text = text + + +def test_internal_server_error_bad_glab(setup: None, use_traceback: None, + mock_glab_request: Callable, + caplog: pytest.LogCaptureFixture): @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 mock.patch("aurweb.config.get", side_effect=mock_glab_config()): with TestClient(app=aurweb.asgi.app) as request: + mock_glab_request(FakeResponse(status_code=404)) 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 + expr = r"ERROR.*Unable to report exception to" + assert re.search(expr, caplog.text) - 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 + expr = r"FATAL\[.{7}\]" + assert re.search(expr, caplog.text) + + +def test_internal_server_error_no_token(setup: None, use_traceback: None, + mock_glab_request: Callable, + caplog: pytest.LogCaptureFixture): + @aurweb.asgi.app.get("/internal_server_error") + async def internal_server_error(request: fastapi.Request): + raise ValueError("test exception") + + mock_get = mock_glab_config(token="set-me") + with mock.patch("aurweb.config.get", side_effect=mock_get): + with TestClient(app=aurweb.asgi.app) as request: + mock_glab_request(FakeResponse()) + resp = request.get("/internal_server_error") + assert resp.status_code == int(http.HTTPStatus.INTERNAL_SERVER_ERROR) + + expr = r"WARNING.*Unable to report an exception found" + assert re.search(expr, caplog.text) + + expr = r"FATAL\[.{7}\]" + assert re.search(expr, caplog.text) + + +def test_internal_server_error(setup: None, use_traceback: None, + mock_glab_request: Callable, + caplog: pytest.LogCaptureFixture): + @aurweb.asgi.app.get("/internal_server_error") + async def internal_server_error(request: fastapi.Request): + raise ValueError("test exception") + + with mock.patch("aurweb.config.get", side_effect=mock_glab_config()): + with TestClient(app=aurweb.asgi.app) as request: + mock_glab_request(FakeResponse()) + # Test with a ?query=string to cover the request.url.query path. + resp = request.get("/internal_server_error?query=string") + assert resp.status_code == int(http.HTTPStatus.INTERNAL_SERVER_ERROR) # 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): + # Let's do it again to exercise the cached path. + caplog.clear() + with mock.patch("aurweb.config.get", side_effect=mock_glab_config()): with TestClient(app=aurweb.asgi.app) as request: + mock_glab_request(FakeResponse()) resp = request.get("/internal_server_error") assert resp.status_code == int(http.HTTPStatus.INTERNAL_SERVER_ERROR) - assert Email.count() == 1 + assert "FATAL" not in caplog.text + + +def test_internal_server_error_post(setup: None, use_traceback: None, + mock_glab_request: Callable, + caplog: pytest.LogCaptureFixture): + @aurweb.asgi.app.post("/internal_server_error") + @handle_form_exceptions + async def internal_server_error(request: fastapi.Request): + raise ValueError("test exception") + + data = {"some": "data"} + with mock.patch("aurweb.config.get", side_effect=mock_glab_config()): + with TestClient(app=aurweb.asgi.app) as request: + mock_glab_request(FakeResponse()) + # Test with a ?query=string to cover the request.url.query path. + resp = request.post("/internal_server_error", data=data) + assert resp.status_code == int(http.HTTPStatus.INTERNAL_SERVER_ERROR) + + expr = r"FATAL\[.{7}\]" + assert re.search(expr, caplog.text) diff --git a/test/test_config.py b/test/test_config.py index b78f477c..f451d8b3 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -160,7 +160,7 @@ def test_config_main_set_invalid_value(): assert stderr.getvalue().strip() == expected -@ mock.patch("aurweb.config.save", side_effect=noop) +@mock.patch("aurweb.config.save", side_effect=noop) def test_config_main_set_unknown_section(save: None): stderr = io.StringIO()