From f8a6b3007ac502e28eec344b7baf813b3234153a Mon Sep 17 00:00:00 2001 From: Paul Schilling Date: Thu, 11 May 2023 14:00:58 +0200 Subject: [PATCH] process PR feedback --- .editorconfig | 2 +- .github/workflows/ci.yml | 15 +- README.rst | 16 +- docs/quickstart.rst | 11 +- log_outgoing_requests/admin.py | 31 ++ log_outgoing_requests/formatters.py | 46 +- log_outgoing_requests/handlers.py | 56 +-- ...0002_outgoingrequestslogconfig_and_more.py | 151 ++++-- log_outgoing_requests/models.py | 116 +++-- .../log_outgoing_requests/css/admin.css | 11 +- log_outgoing_requests/validators.py | 54 +++ setup.cfg | 3 +- testapp/settings.py | 17 +- tests/test_logging.py | 456 +++++++++++++++--- tests/test_validators.py | 95 ++++ tox.ini | 9 +- 16 files changed, 896 insertions(+), 193 deletions(-) create mode 100644 log_outgoing_requests/validators.py create mode 100644 tests/test_validators.py diff --git a/.editorconfig b/.editorconfig index 82da1bc..848bc84 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,7 +2,7 @@ root = true [*] indent_style = space -indent_size = 4 +indent_size = 2 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e1c27c..802d8c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,14 +15,21 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: ["3.7", "3.8", "3.9", "3.10"] + python: ["3.8", "3.9", "3.10"] django: ["3.2", "4.1"] - exclude: - - python: "3.7" - django: "4.1" name: Run the test suite (Python ${{ matrix.python }}, Django ${{ matrix.django }}) + services: + postgres: + image: postgres:14 + env: + POSTGRES_HOST_AUTH_METHOD: trust + ports: + - 5432:5432 + # needed because the postgres container does not provide a healthcheck + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 diff --git a/README.rst b/README.rst index 4dfca82..c49b1d0 100644 --- a/README.rst +++ b/README.rst @@ -97,8 +97,15 @@ To use this with your project you need to follow these steps: } LOG_OUTGOING_REQUESTS_DB_SAVE = True # save logs enabled/disabled based on the boolean value - LOG_OUTGOING_REQUESTS_SAVE_BODY = True # save request/response body - LOG_OUTGOING_REQUESTS_LOG_BODY_TO_STDOUT = True # log request/response body to STDOUT + LOG_OUTGOING_REQUESTS_DB_SAVE_BODY = True # save request/response body + LOG_OUTGOING_REQUESTS_EMIT_BODY = True # log request/response body + LOG_OUTGOING_REQUESTS_CONTENT_TYPES = [ + "text/*", + "application/json", + "application/xml", + ] # save request/response bodies with matching content type + LOG_OUTGOING_REQUESTS_MAX_CONTENT_LENGTH = 2_097_152 # maximal size (in bytes) for the request/response body + LOG_OUTGOING_REQUESTS_REQUIRE_CONTENT_LENGTH = True # if True, the content size of request/response bodies is not checked #. Run the migrations @@ -115,8 +122,9 @@ To use this with your project you need to follow these steps: res = requests.get("https://httpbin.org/json") print(res.json()) -#. Check stdout for the printable output, and navigate to ``/admin/log_outgoing_requests/outgoingrequestslog/`` to see - the saved log records. The settings for saving logs can by overridden under ``/admin/log_outgoing_requests/outgoingrequestslogconfig/``. +#. Check stdout for the printable output, and navigate to ``Admin > Miscellaneous > Outgoing Requests Logs`` + to see the saved log records. In order to override the settings for saving logs, navigate to + ``Admin > Miscellaneous > Outgoing Requests Log Configuration``. Local development diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 70e3301..6f00754 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -58,8 +58,15 @@ Installation } LOG_OUTGOING_REQUESTS_DB_SAVE = True # save logs enabled/disabled based on the boolean value - LOG_OUTGOING_REQUESTS_SAVE_BODY = True # save request/response body - LOG_OUTGOING_REQUESTS_LOG_BODY_TO_STDOUT = True # log request/response body to STDOUT + LOG_OUTGOING_REQUESTS_DB_SAVE_BODY = True # save request/response body + LOG_OUTGOING_REQUESTS_EMIT_BODY = True # log request/response body + LOG_OUTGOING_REQUESTS_CONTENT_TYPES = [ + "text/*", + "application/json", + "application/xml", + ] # save request/response bodies with matching content type + LOG_OUTGOING_REQUESTS_MAX_CONTENT_LENGTH = 2_097_152 # maximal size (in bytes) for the request/response body + LOG_OUTGOING_REQUESTS_REQUIRE_CONTENT_LENGTH = True # if True, the content size of request/response bodies is not checked #. Run ``python manage.py migrate`` to create the necessary database tables. diff --git a/log_outgoing_requests/admin.py b/log_outgoing_requests/admin.py index a05ce64..a62a6b9 100644 --- a/log_outgoing_requests/admin.py +++ b/log_outgoing_requests/admin.py @@ -1,3 +1,5 @@ +from django import forms +from django.conf import settings from django.contrib import admin from django.utils.translation import gettext as _ @@ -62,13 +64,42 @@ class Media: } +class ConfigAdminForm(forms.ModelForm): + class Meta: + model = OutgoingRequestsLogConfig + fields = "__all__" + widgets = {"allowed_content_types": forms.CheckboxSelectMultiple} + help_texts = { + "save_to_db": _( + "Whether request logs should be saved to the database (default: {default})." + ).format(default=settings.LOG_OUTGOING_REQUESTS_DB_SAVE), + "save_body": _( + "Wheter the body of the request and response should be logged (default: " + "{default}). This option is ignored if 'Save Logs to database' is set to " + "False." + ).format(default=settings.LOG_OUTGOING_REQUESTS_DB_SAVE_BODY), + "require_content_length": _( + "Whether request & response headers must specify the content length or not. " + "If checked, the body of requests/responses whose headers do not specify " + "a content length will not be saved. Default: {default}." + ).format(default=settings.LOG_OUTGOING_REQUESTS_REQUIRE_CONTENT_LENGTH), + } + + @admin.register(OutgoingRequestsLogConfig) class OutgoingRequestsLogConfigAdmin(SingletonModelAdmin): + form = ConfigAdminForm fields = ( "save_to_db", "save_body", + "allowed_content_types", + "require_content_length", + "max_content_length", ) list_display = ( "save_to_db", "save_body", + "allowed_content_types", + "require_content_length", + "max_content_length", ) diff --git a/log_outgoing_requests/formatters.py b/log_outgoing_requests/formatters.py index b6905c8..8129e30 100644 --- a/log_outgoing_requests/formatters.py +++ b/log_outgoing_requests/formatters.py @@ -8,32 +8,40 @@ class HttpFormatter(logging.Formatter): def _formatHeaders(self, d): return "\n".join(f"{k}: {v}" for k, v in d.items()) - def _formatBody(self, content: dict, request_or_response: str) -> str: + def _formatBody(self, content: str, request_or_response: str) -> str: if settings.LOG_OUTGOING_REQUESTS_LOG_BODY_TO_STDOUT: return f"\n{request_or_response} body:\n{content}" return "" def formatMessage(self, record): - result = super().formatMessage(record) - if record.name == "requests": - result += textwrap.dedent( - """ - ---------------- request ---------------- - {req.method} {req.url} - {reqhdrs} {request_body} - - ---------------- response ---------------- - {res.status_code} {res.reason} {res.url} - {reshdrs} {response_body} + if record.name != "requests": + return + result = super().formatMessage(record) + result += textwrap.dedent( """ - ).format( - req=record.req, - res=record.res, - reqhdrs=self._formatHeaders(record.req.headers), - reshdrs=self._formatHeaders(record.res.headers), - request_body=self._formatBody(record.req.body, "Request"), - response_body=self._formatBody(record.res.json(), "Response"), + ---------------- request ---------------- + {req.method} {req.url} + {reqhdrs} {request_body} + + ---------------- response ---------------- + {res.status_code} {res.reason} {res.url} + {reshdrs} {response_body} + + """ + ).format( + req=record.req, + res=record.res, + reqhdrs=self._formatHeaders(record.req.headers), + reshdrs=self._formatHeaders(record.res.headers), + request_body=self._formatBody(record.req.body, "Request") + if settings.LOG_OUTGOING_REQUESTS_LOG_BODY_TO_STDOUT is True + else "", + response_body=self._formatBody( + record.res.content.decode("utf-8"), "Response" ) + if settings.LOG_OUTGOING_REQUESTS_LOG_BODY_TO_STDOUT is True + else "", + ) return result diff --git a/log_outgoing_requests/handlers.py b/log_outgoing_requests/handlers.py index 1435083..4d8abda 100644 --- a/log_outgoing_requests/handlers.py +++ b/log_outgoing_requests/handlers.py @@ -2,16 +2,7 @@ import traceback from urllib.parse import urlparse -from django.conf import settings - -ALLOWED_CONTENT_TYPES = [ - "application/json", - "multipart/form-data", - "text/html", - "text/plain", - "", - None, -] +from .validators import validate_content class DatabaseOutgoingRequestsHandler(logging.Handler): @@ -20,29 +11,19 @@ def emit(self, record): config = OutgoingRequestsLogConfig.get_solo() - if config.save_to_db or settings.LOG_OUTGOING_REQUESTS_DB_SAVE: + if config.save_logs_enabled: from .models import OutgoingRequestsLog - trace = None + trace = "" # skip requests not coming from the library requests if not record or not record.getMessage() == "Outgoing request": return - # skip requests with non-allowed content - request_content_type = record.req.headers.get("Content-Type", "") - response_content_type = record.res.headers.get("Content-Type", "") - - if not ( - request_content_type in ALLOWED_CONTENT_TYPES - and response_content_type in ALLOWED_CONTENT_TYPES - ): - return - - safe_req_headers = record.req.headers.copy() + scrubbed_req_headers = record.req.headers.copy() - if "Authorization" in safe_req_headers: - safe_req_headers["Authorization"] = "***hidden***" + if "Authorization" in scrubbed_req_headers: + scrubbed_req_headers["Authorization"] = "***hidden***" if record.exc_info: trace = traceback.format_exc() @@ -54,18 +35,31 @@ def emit(self, record): "params": parsed_url.params, "status_code": record.res.status_code, "method": record.req.method, - "req_content_type": record.req.headers.get("Content-Type", ""), - "res_content_type": record.res.headers.get("Content-Type", ""), + "req_content_type": ( + request_content_type := record.req.headers.get("Content-Type", "") + ), + "res_content_type": ( + response_content_type := record.res.headers.get("Content-Type", "") + ), "timestamp": record.requested_at, "response_ms": int(record.res.elapsed.total_seconds() * 1000), - "req_headers": self.format_headers(safe_req_headers), + "req_headers": self.format_headers(scrubbed_req_headers), "res_headers": self.format_headers(record.res.headers), "trace": trace, } - if config.save_body or settings.LOG_OUTGOING_REQUESTS_SAVE_BODY: - kwargs["req_body"] = (record.req.body,) - kwargs["res_body"] = (record.res.json(),) + if config.save_body_enabled: + request_content_length = record.req.headers.get("Content-Length") + if validate_content( + request_content_length, request_content_type, config + ): + kwargs["req_body"] = record.req.text or "" + + response_content_length = record.res.headers.get("Content-Length") + if validate_content( + response_content_length, response_content_type, config + ): + kwargs["res_body"] = record.res.text OutgoingRequestsLog.objects.create(**kwargs) diff --git a/log_outgoing_requests/migrations/0002_outgoingrequestslogconfig_and_more.py b/log_outgoing_requests/migrations/0002_outgoingrequestslogconfig_and_more.py index 3274041..c649494 100644 --- a/log_outgoing_requests/migrations/0002_outgoingrequestslogconfig_and_more.py +++ b/log_outgoing_requests/migrations/0002_outgoingrequestslogconfig_and_more.py @@ -1,7 +1,10 @@ -# Generated by Django 4.2.1 on 2023-05-09 07:48 +# Generated by Django 4.2.1 on 2023-05-16 07:41 +import django.core.validators from django.db import migrations, models +import log_outgoing_requests.models + class Migration(migrations.Migration): dependencies = [ @@ -23,55 +26,149 @@ class Migration(migrations.Migration): ), ( "save_to_db", - models.IntegerField( - blank=True, - choices=[(None, "Use default"), (0, "No"), (1, "Yes")], - help_text="Whether request logs should be saved to the database (default: True)", - null=True, + models.TextField( + choices=[ + ("use_default", "Use default"), + ("yes", "Yes"), + ("no", "No"), + ], + default="use_default", + help_text="Whether request logs should be saved to the database (default: True).", verbose_name="Save logs to database", ), ), ( "save_body", - models.IntegerField( + models.TextField( + choices=[ + ("use_default", "Use default"), + ("yes", "Yes"), + ("no", "No"), + ], + default="use_default", + help_text="Wheter the body of the request and response should be logged (default: True). This option is ignored if 'Save Logs to database' is set to False.", + verbose_name="Save request + response body", + ), + ), + ( + "allowed_content_types", + log_outgoing_requests.models.ChoiceArrayField( + base_field=models.CharField( + choices=[ + ("audio/*", "Audio"), + ("multipart/form-data", "Form data"), + ("application/json", "JSON"), + ("text/*", "Plain text & HTML"), + ("video/*", "Video"), + ("application/xml", "XML"), + ], + max_length=128, + ), blank=True, - choices=[(None, "Use default"), (0, "No"), (1, "Yes")], - help_text="Wheter the body of the request and response should be logged (default: False). This option is ignored if 'Save Logs to database' is set to False.", + default=log_outgoing_requests.models.OutgoingRequestsLogConfig.get_default_allowed_content_types, + help_text="Only request/response bodies whose content type matches one of the types selected here will be saved. If Save requests + response body is not enabled, this setting has no effect.", null=True, - verbose_name="Save request + response body", + size=None, + verbose_name="Allowed content types", + ), + ), + ( + "require_content_length", + models.TextField( + choices=[ + ("use_default", "Use default"), + ("yes", "Yes"), + ("no", "No"), + ], + default="use_default", + help_text="Whether request & response headers must specify the content length or not. If checked, the body of requests/responses whose headers do not specify a content length will not be saved. Default: True.", + verbose_name="Require content length", + ), + ), + ( + "max_content_length", + models.IntegerField( + blank=True, + default=log_outgoing_requests.models.OutgoingRequestsLogConfig.get_default_max_content_length, + help_text="The maximal size of the request/response content (in bytes). If 'Require content length' is not checked, this setting has no effect.", + validators=[django.core.validators.MinValueValidator(0)], + verbose_name="Maximal content size", ), ), ], options={ - "verbose_name": "Outgoing Requests Logs Configuration", - }, - ), - migrations.AlterModelOptions( - name="outgoingrequestslog", - options={ - "permissions": [("can_view_logs", "Can view outgoing request logs")], - "verbose_name": "Outgoing Requests Log", - "verbose_name_plural": "Outgoing Requests Logs", + "verbose_name": "Outgoing Requests Log Configuration", }, ), migrations.AddField( model_name="outgoingrequestslog", name="req_body", field=models.TextField( - blank=True, - help_text="The request body.", - null=True, - verbose_name="Request body", + blank=True, help_text="The request body.", verbose_name="Request body" ), ), migrations.AddField( model_name="outgoingrequestslog", name="res_body", - field=models.JSONField( + field=models.TextField( + blank=True, help_text="The response body.", verbose_name="Response body" + ), + ), + migrations.AlterField( + model_name="outgoingrequestslog", + name="method", + field=models.CharField( + blank=True, + help_text="The type of request method.", + max_length=10, + verbose_name="Method", + ), + ), + migrations.AlterField( + model_name="outgoingrequestslog", + name="req_content_type", + field=models.CharField( + blank=True, + help_text="The content type of the request.", + max_length=50, + verbose_name="Request content type", + ), + ), + migrations.AlterField( + model_name="outgoingrequestslog", + name="req_headers", + field=models.TextField( + blank=True, + help_text="The request headers.", + verbose_name="Request headers", + ), + ), + migrations.AlterField( + model_name="outgoingrequestslog", + name="res_content_type", + field=models.CharField( + blank=True, + help_text="The content type of the response.", + max_length=50, + verbose_name="Response content type", + ), + ), + migrations.AlterField( + model_name="outgoingrequestslog", + name="res_headers", + field=models.TextField( + blank=True, + help_text="The response headers.", + verbose_name="Response headers", + ), + ), + migrations.AlterField( + model_name="outgoingrequestslog", + name="trace", + field=models.TextField( blank=True, - help_text="The response body.", - null=True, - verbose_name="Response body", + help_text="Text providing information in case of request failure.", + verbose_name="Trace", ), ), ] diff --git a/log_outgoing_requests/models.py b/log_outgoing_requests/models.py index 9de596e..0b95d92 100644 --- a/log_outgoing_requests/models.py +++ b/log_outgoing_requests/models.py @@ -1,6 +1,9 @@ from urllib.parse import urlparse +from django import forms from django.conf import settings +from django.contrib.postgres.fields import ArrayField +from django.core.validators import MinValueValidator from django.db import models from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ @@ -39,46 +42,39 @@ class OutgoingRequestsLog(models.Model): method = models.CharField( verbose_name=_("Method"), max_length=10, - default="", blank=True, help_text=_("The type of request method."), ) req_content_type = models.CharField( verbose_name=_("Request content type"), max_length=50, - default="", blank=True, help_text=_("The content type of the request."), ) res_content_type = models.CharField( verbose_name=_("Response content type"), max_length=50, - default="", blank=True, help_text=_("The content type of the response."), ) req_headers = models.TextField( verbose_name=_("Request headers"), blank=True, - null=True, help_text=_("The request headers."), ) - req_body = models.TextField( - verbose_name=_("Request body"), - blank=True, - null=True, - help_text=_("The request body."), - ) res_headers = models.TextField( verbose_name=_("Response headers"), blank=True, - null=True, help_text=_("The response headers."), ) - res_body = models.JSONField( + req_body = models.TextField( + verbose_name=_("Request body"), + blank=True, + help_text=_("The request body."), + ) + res_body = models.TextField( verbose_name=_("Response body"), blank=True, - null=True, help_text=_("The response body."), ) response_ms = models.PositiveIntegerField( @@ -94,16 +90,12 @@ class OutgoingRequestsLog(models.Model): trace = models.TextField( verbose_name=_("Trace"), blank=True, - null=True, help_text=_("Text providing information in case of request failure."), ) class Meta: verbose_name = _("Outgoing Requests Log") verbose_name_plural = _("Outgoing Requests Logs") - permissions = [ - ("can_view_logs", "Can view outgoing request logs"), - ] def __str__(self): return ("{hostname} at {date}").format( @@ -119,33 +111,87 @@ def query_params(self): return self.url_parsed.query +class ChoiceArrayField(ArrayField): + def formfield(self, **kwargs): + defaults = { + "form_class": forms.MultipleChoiceField, + "choices": self.base_field.choices, + } + defaults.update(kwargs) + return super(ArrayField, self).formfield(**defaults) + + class OutgoingRequestsLogConfig(SingletonModel): - class SaveLogsChoices(models.IntegerChoices): - NO = 0, _("No") - YES = 1, _("Yes") + class SaveLogsChoice(models.TextChoices): + use_default = "use_default", _("Use default") + yes = "yes", _("Yes") + no = "no", _("No") + + class ContentType(models.TextChoices): + audio = "audio/*", _("Audio") + form_data = "multipart/form-data", _("Form data") + json = "application/json", _("JSON") + text = "text/*", ("Plain text & HTML") + video = "video/*", _("Video") + xml = "application/xml", _("XML") - __empty__ = _("Use default") + # unbound convenience functions b/c Django can't serialize lambdas or classmethods + def get_default_allowed_content_types(): + return settings.LOG_OUTGOING_REQUESTS_CONTENT_TYPES - save_to_db = models.IntegerField( + def get_default_max_content_length(): + return settings.LOG_OUTGOING_REQUESTS_MAX_CONTENT_LENGTH + + save_to_db = models.TextField( _("Save logs to database"), - choices=SaveLogsChoices.choices, - blank=True, - null=True, - help_text=_( - "Whether request logs should be saved to the database (default: {default})" - ).format(default=settings.LOG_OUTGOING_REQUESTS_DB_SAVE), + choices=SaveLogsChoice.choices, + default=SaveLogsChoice.use_default, ) - save_body = models.IntegerField( + save_body = models.TextField( _("Save request + response body"), - choices=SaveLogsChoices.choices, + choices=SaveLogsChoice.choices, + default=SaveLogsChoice.use_default, + ) + allowed_content_types = ChoiceArrayField( + models.CharField(max_length=128, choices=ContentType.choices), + verbose_name=_("Allowed content types"), + default=get_default_allowed_content_types, blank=True, null=True, + ) + require_content_length = models.TextField( + _("Require content length"), + choices=SaveLogsChoice.choices, + default=SaveLogsChoice.use_default, + ) + max_content_length = models.IntegerField( + _("Maximal content size"), + validators=[MinValueValidator(0)], + blank=True, + default=get_default_max_content_length, help_text=_( - "Wheter the body of the request and response should be logged (default: " - "{default}). This option is ignored if 'Save Logs to database' is set to " - "False." - ).format(default=settings.LOG_OUTGOING_REQUESTS_SAVE_BODY), + "The maximal size of the request/response content (in bytes). " + "If '{require}' is not checked, this setting has no effect." + ).format(require=_("Require content length")), ) + @property + def save_logs_enabled(self): + if self.save_to_db == "use_default": + return settings.LOG_OUTGOING_REQUESTS_DB_SAVE + return self.save_to_db == "yes" + + @property + def save_body_enabled(self): + if self.save_body == "use_default": + return settings.LOG_OUTGOING_REQUESTS_DB_SAVE_BODY + return self.save_body == "yes" + + @property + def content_length_required(self): + if self.require_content_length == "use_default": + return settings.LOG_OUTGOING_REQUESTS_REQUIRE_CONTENT_LENGTH + return self.require_content_length == "yes" + class Meta: - verbose_name = _("Outgoing Requests Logs Configuration") + verbose_name = _("Outgoing Requests Log Configuration") diff --git a/log_outgoing_requests/static/log_outgoing_requests/css/admin.css b/log_outgoing_requests/static/log_outgoing_requests/css/admin.css index 8485b3b..5654b4d 100644 --- a/log_outgoing_requests/static/log_outgoing_requests/css/admin.css +++ b/log_outgoing_requests/static/log_outgoing_requests/css/admin.css @@ -1,6 +1,9 @@ .field-res_body { - display: inline-block; - width: 40rem; - font-family: monospace; - line-height: 1.5rem; + display: inline-block; + width: var(--dlor-body-width, 40rem); + font-family: monospace; + line-height: 1.5rem; +} +input[name="max_content_length"] { + width: 7.25em; } diff --git a/log_outgoing_requests/validators.py b/log_outgoing_requests/validators.py new file mode 100644 index 0000000..96ba57e --- /dev/null +++ b/log_outgoing_requests/validators.py @@ -0,0 +1,54 @@ +from typing import TYPE_CHECKING, Any, Iterable, Optional + +if TYPE_CHECKING: # pragma: nocover + from .models import OutgoingRequestsLogConfig + + +def _validate_content_length( + content_length: Any, config: "OutgoingRequestsLogConfig" +) -> bool: + max_content_length: Optional[int] = config.max_content_length + content_length_required: bool = config.content_length_required + + # case 1: check of content_length not required + if not content_length_required: + return True + + # case 2: no content-length in headers + if content_length is None: + return False + + # normalize input + try: + content_length = int(content_length) + except (TypeError, ValueError): + return False + + # case 3 + return content_length <= max_content_length + + +def _validate_content_type( + content_type: Optional[str], config: "OutgoingRequestsLogConfig" +) -> bool: + # check 1 + 2: missing argument (or wrong type) for `content_type` + if content_type is None or not isinstance(content_type, str): + return False + + allowed_mime_types: Iterable[str] = config.allowed_content_types or [] + normalized = [item for string in allowed_mime_types for item in string.split(",")] + regulars = [item for item in normalized if not item.endswith("*")] + wildcards = [item for item in normalized if item.endswith("*")] + + # check 3: is mime type included in regular types? + if content_type in regulars: + return True + + # check 3: is the string mime_type a substring of any string in wildcard_mimes? + return any(content_type.startswith(pattern[:-1]) for pattern in wildcards) + + +def validate_content(content_length: str, content_type: str, config: Any) -> bool: + return _validate_content_length(content_length, config) and _validate_content_type( + content_type, config + ) diff --git a/setup.cfg b/setup.cfg index 5eb1286..e47f44d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -24,7 +24,6 @@ classifiers = Operating System :: Unix Operating System :: MacOS Operating System :: Microsoft :: Windows - Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 @@ -54,6 +53,8 @@ include = log_outgoing_requests.* [options.extras_require] +db = + psycopg2 tests = pytest pytest-django diff --git a/testapp/settings.py b/testapp/settings.py index 9a91f5e..50043b7 100644 --- a/testapp/settings.py +++ b/testapp/settings.py @@ -8,8 +8,11 @@ DATABASES = { "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": os.path.join(BASE_DIR, "log_outgoing_requests.db"), + "ENGINE": "django.db.backends.postgresql", + "NAME": os.getenv("DB_NAME", "log_outgoing_requests"), + "USER": os.getenv("DB_USER", "postgres"), + "PASSWORD": os.getenv("DB_PASSWORD", "postgres"), + "HOST": os.getenv("DB_HOST", "localhost"), } } @@ -92,7 +95,15 @@ # LOG OUTGOING REQUESTS # LOG_OUTGOING_REQUESTS_DB_SAVE = True -LOG_OUTGOING_REQUESTS_SAVE_BODY = False +LOG_OUTGOING_REQUESTS_DB_SAVE_BODY = True +LOG_OUTGOING_REQUESTS_CONTENT_TYPES = [ + "text/*", + "application/json", + "application/xml", +] +LOG_OUTGOING_REQUESTS_EMIT_BODY = True +LOG_OUTGOING_REQUESTS_MAX_CONTENT_LENGTH = 2_097_152 # 2MB +LOG_OUTGOING_REQUESTS_REQUIRE_CONTENT_LENGTH = True ROOT_URLCONF = "testapp.urls" diff --git a/tests/test_logging.py b/tests/test_logging.py index fc6bc6c..270cfe3 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -1,3 +1,5 @@ +import json + from django.test import TestCase, override_settings import requests @@ -17,7 +19,13 @@ def _setUpMocks(self, m): content=b"some content", ) - @override_settings(LOG_OUTGOING_REQUESTS_DB_SAVE=True) + def _setUpMocksAlt(self, m): + m.get( + "http://example.com/some-path?version=2.0", + status_code=200, + content="some content", + ), + def test_outgoing_requests_are_logged(self, m): self._setUpMocks(m) @@ -29,8 +37,11 @@ def test_outgoing_requests_are_logged(self, m): self.assertEqual(logs.records[0].getMessage(), "Outgoing request") self.assertEqual(logs.records[0].levelname, "DEBUG") - @override_settings(LOG_OUTGOING_REQUESTS_DB_SAVE=True) - @override_settings(LOG_OUTGOING_REQUESTS_SAVE_BODY=True) + @override_settings( + LOG_OUTGOING_REQUESTS_DB_SAVE=True, + LOG_OUTGOING_REQUESTS_DB_SAVE_BODY=True, + LOG_OUTGOING_REQUESTS_CONTENT_TYPES=["text/*", "application/json"], + ) def test_expected_data_is_saved_when_saving_enabled(self, m): methods = [ ("GET", requests.get, m.get), @@ -46,14 +57,15 @@ def test_expected_data_is_saved_when_saving_enabled(self, m): mocked( "http://example.com/some-path?version=2.0", status_code=200, - json={"test": "data"}, + json={"test": "I sleep all night and work all day."}, request_headers={ "Authorization": "test", - "Content-Type": "text/html", + "Content-Type": "application/json", }, headers={ "Date": "Tue, 21 Mar 2023 15:24:08 GMT", "Content-Type": "application/json", + "Content-Length": "47", }, ) expected_req_headers = ( @@ -62,14 +74,17 @@ def test_expected_data_is_saved_when_saving_enabled(self, m): "Accept: */*\n" "Connection: keep-alive\n" "Authorization: ***hidden***\n" - "Content-Type: text/html" + "Content-Type: application/json\n" + "Content-Length: 40" ) - if method not in ["HEAD", "GET"]: - expected_req_headers += "\nContent-Length: 0" response = func( "http://example.com/some-path?version=2.0", - headers={"Authorization": "test", "Content-Type": "text/html"}, + headers={ + "Authorization": "test", + "Content-Type": "application/json", + }, + json={"test": "I'm a lumberjack and I'm OK."}, ) request_log = OutgoingRequestsLog.objects.last() @@ -82,22 +97,28 @@ def test_expected_data_is_saved_when_saving_enabled(self, m): self.assertEqual(request_log.query_params, "version=2.0") self.assertEqual(response.status_code, 200) self.assertEqual(request_log.method, method) - self.assertEqual(request_log.req_content_type, "text/html") + self.assertEqual(request_log.req_content_type, "application/json") self.assertEqual(request_log.res_content_type, "application/json") self.assertEqual(request_log.response_ms, 0) self.assertEqual(request_log.req_headers, expected_req_headers) self.assertEqual( request_log.res_headers, - "Date: Tue, 21 Mar 2023 15:24:08 GMT\nContent-Type: application/json", + "Date: Tue, 21 Mar 2023 15:24:08 GMT\nContent-Type: application/json\nContent-Length: 47", ) self.assertEqual( request_log.timestamp.strftime("%Y-%m-%d %H:%M:%S"), "2021-10-18 13:00:00", ) - self.assertEqual(request_log.res_body[0], {"test": "data"}) - self.assertIsNone(request_log.trace) + self.assertEqual( + json.loads(request_log.req_body), + {"test": "I'm a lumberjack and I'm OK."}, + ) + self.assertEqual( + json.loads(request_log.res_body), + {"test": "I sleep all night and work all day."}, + ) + self.assertEqual(request_log.trace, "") - @override_settings(LOG_OUTGOING_REQUESTS_DB_SAVE=True) def test_authorization_header_is_hidden(self, m): self._setUpMocks(m) @@ -111,16 +132,37 @@ def test_authorization_header_is_hidden(self, m): @override_settings(LOG_OUTGOING_REQUESTS_DB_SAVE=False) def test_data_is_not_saved_when_saving_disabled(self, m): - self._setUpMocks(m) + methods = [ + ("GET", requests.get, m.get), + ("POST", requests.post, m.post), + ("PUT", requests.put, m.put), + ("PATCH", requests.patch, m.patch), + ("DELETE", requests.delete, m.delete), + ("HEAD", requests.head, m.head), + ] - with self.assertLogs("requests", level="DEBUG") as logs: - requests.get("http://example.com/some-path?version=2.0") + for method, func, mocked in methods: + with self.subTest(): + mocked( + "http://example.com/some-path?version=2.0", + status_code=200, + json={"test": "data"}, + request_headers={ + "Authorization": "test", + "Content-Type": "text/html", + }, + headers={ + "Date": "Tue, 21 Mar 2023 15:24:08 GMT", + "Content-Type": "application/json", + "Content-Length": "16", + }, + ) + func( + "http://example.com/some-path?version=2.0", + headers={"Authorization": "test", "Content-Type": "text/html"}, + ) - self.assertEqual(logs.output, ["DEBUG:requests:Outgoing request"]) - self.assertEqual(logs.records[0].name, "requests") - self.assertEqual(logs.records[0].getMessage(), "Outgoing request") - self.assertEqual(logs.records[0].levelname, "DEBUG") - self.assertFalse(OutgoingRequestsLog.objects.exists()) + self.assertFalse(OutgoingRequestsLog.objects.exists()) @override_settings(LOG_OUTGOING_REQUESTS_DB_SAVE=False) def test_outgoing_requests_are_logged_when_saving_disabled(self, m): @@ -133,25 +175,19 @@ def test_outgoing_requests_are_logged_when_saving_disabled(self, m): self.assertEqual(logs.records[0].name, "requests") self.assertEqual(logs.records[0].getMessage(), "Outgoing request") self.assertEqual(logs.records[0].levelname, "DEBUG") + self.assertEqual(logs.records[0].res.text, "some content") - @override_settings(LOG_OUTGOING_REQUESTS_DB_SAVE=False) - def test_request_data_is_not_saved_when_saving_disabled(self, m): - self._setUpMocks(m) - - requests.get("http://example.com/some-path?version=2.0") - - self.assertFalse(OutgoingRequestsLog.objects.exists()) - - @override_settings(LOG_OUTGOING_REQUESTS_DB_SAVE=True) - @override_settings(LOG_OUTGOING_REQUESTS_SAVE_BODY=True) + @override_settings( + LOG_OUTGOING_REQUESTS_CONTENT_TYPES=["text/*"], + LOG_OUTGOING_REQUESTS_REQUIRE_CONTENT_LENGTH=False, + ) def test_logging_disallowed_content_type_request(self, m): + """Assert that request body is not saved: content type not allowed""" + methods = [ - ("GET", requests.get, m.get), ("POST", requests.post, m.post), ("PUT", requests.put, m.put), ("PATCH", requests.patch, m.patch), - ("DELETE", requests.delete, m.delete), - ("HEAD", requests.head, m.head), ] for method, func, mocked in methods: @@ -162,7 +198,7 @@ def test_logging_disallowed_content_type_request(self, m): json={"test": "data"}, request_headers={ "Authorization": "test", - "Content-Type": "video/mp4", + "Content-Type": "application/json", }, headers={ "Date": "Tue, 21 Mar 2023 15:24:08 GMT", @@ -172,16 +208,24 @@ def test_logging_disallowed_content_type_request(self, m): func( "http://example.com/some-path?version=2.0", - headers={"Authorization": "test", "Content-Type": "video/mp4"}, + headers={ + "Authorization": "test", + "Content-Type": "application/json", + }, + json={"test": "data"}, ) request_log = OutgoingRequestsLog.objects.last() - self.assertIsNone(request_log) + self.assertEqual(request_log.req_body, "") - @override_settings(LOG_OUTGOING_REQUESTS_DB_SAVE=True) - @override_settings(LOG_OUTGOING_REQUESTS_SAVE_BODY=True) + @override_settings( + LOG_OUTGOING_REQUESTS_CONTENT_TYPES=["text/*"], + LOG_OUTGOING_REQUESTS_REQUIRE_CONTENT_LENGTH=False, + ) def test_logging_disallowed_content_type_response(self, m): + """Assert that body of response not saved: content type not allowed""" + methods = [ ("GET", requests.get, m.get), ("POST", requests.post, m.post), @@ -214,10 +258,9 @@ def test_logging_disallowed_content_type_response(self, m): request_log = OutgoingRequestsLog.objects.last() - self.assertIsNone(request_log) + self.assertEqual(request_log.res_body, "") - @override_settings(LOG_OUTGOING_REQUESTS_DB_SAVE=True) - @override_settings(LOG_OUTGOING_REQUESTS_SAVE_BODY=False) + @override_settings(LOG_OUTGOING_REQUESTS_DB_SAVE_BODY=False) def test_body_not_saved_when_setting_disabled(self, m): methods = [ ("GET", requests.get, m.get), @@ -251,15 +294,10 @@ def test_body_not_saved_when_setting_disabled(self, m): request_log = OutgoingRequestsLog.objects.last() - self.assertIsNone(request_log.res_body) - - @override_settings(LOG_OUTGOING_REQUESTS_DB_SAVE=False) - @override_settings(LOG_OUTGOING_REQUESTS_SAVE_BODY=False) - def test_admin_override_db_save(self, m): - config = OutgoingRequestsLogConfig.get_solo() - config.save_to_db = 1 # True - config.save() + self.assertEqual(request_log.res_body, "") + def test_missing_content_length(self, m): + """Assert that body is not saved when content-length is required but missing from header""" methods = [ ("GET", requests.get, m.get), ("POST", requests.post, m.post), @@ -284,7 +322,46 @@ def test_admin_override_db_save(self, m): "Content-Type": "application/json", }, ) + func( + "http://example.com/some-path?version=2.0", + headers={"Authorization": "test", "Content-Type": "text/html"}, + ) + request_log = OutgoingRequestsLog.objects.last() + + self.assertEqual( + request_log.res_headers, + "Date: Tue, 21 Mar 2023 15:24:08 GMT\nContent-Type: application/json", + ) + self.assertEqual(request_log.res_body, "") + + @override_settings(LOG_OUTGOING_REQUESTS_REQUIRE_CONTENT_LENGTH=False) + def test_missing_content_length_not_required(self, m): + """Assert that body is saved when content-length is not required and missing from header""" + methods = [ + ("GET", requests.get, m.get), + ("POST", requests.post, m.post), + ("PUT", requests.put, m.put), + ("PATCH", requests.patch, m.patch), + ("DELETE", requests.delete, m.delete), + ("HEAD", requests.head, m.head), + ] + + for method, func, mocked in methods: + with self.subTest(): + mocked( + "http://example.com/some-path?version=2.0", + status_code=200, + json={"test": "data"}, + request_headers={ + "Authorization": "test", + "Content-Type": "text/html", + }, + headers={ + "Date": "Tue, 21 Mar 2023 15:24:08 GMT", + "Content-Type": "application/json", + }, + ) func( "http://example.com/some-path?version=2.0", headers={"Authorization": "test", "Content-Type": "text/html"}, @@ -292,16 +369,59 @@ def test_admin_override_db_save(self, m): request_log = OutgoingRequestsLog.objects.last() - self.assertIsNotNone(request_log) - self.assertIsNone(request_log.res_body) + self.assertEqual( + request_log.res_headers, + "Date: Tue, 21 Mar 2023 15:24:08 GMT\nContent-Type: application/json", + ) + self.assertEqual(json.loads(request_log.res_body), {"test": "data"}) - @override_settings(LOG_OUTGOING_REQUESTS_DB_SAVE=True) - @override_settings(LOG_OUTGOING_REQUESTS_SAVE_BODY=False) - def test_admin_override_save_body(self, m): - config = OutgoingRequestsLogConfig.get_solo() - config.save_body = 1 # True - config.save() + @override_settings(LOG_OUTGOING_REQUESTS_MAX_CONTENT_LENGTH=10) + def test_exceeding_content_length(self, m): + """Assert that body is not saved when content-length exceeds pre-defined max""" + methods = [ + ("GET", requests.get, m.get), + ("POST", requests.post, m.post), + ("PUT", requests.put, m.put), + ("PATCH", requests.patch, m.patch), + ("DELETE", requests.delete, m.delete), + ("HEAD", requests.head, m.head), + ] + for method, func, mocked in methods: + with self.subTest(): + mocked( + "http://example.com/some-path?version=2.0", + status_code=200, + json={"test": "data"}, + request_headers={ + "Authorization": "test", + "Content-Type": "text/html", + }, + headers={ + "Date": "Tue, 21 Mar 2023 15:24:08 GMT", + "Content-Type": "application/json", + "Content-Length": "16", + }, + ) + func( + "http://example.com/some-path?version=2.0", + headers={"Authorization": "test", "Content-Type": "text/html"}, + ) + + request_log = OutgoingRequestsLog.objects.last() + + self.assertEqual( + request_log.res_headers, + "Date: Tue, 21 Mar 2023 15:24:08 GMT\nContent-Type: application/json\nContent-Length: 16", + ) + self.assertEqual(request_log.res_body, "") + + @override_settings( + LOG_OUTGOING_REQUESTS_MAX_CONTENT_LENGTH=10, + LOG_OUTGOING_REQUESTS_REQUIRE_CONTENT_LENGTH=False, + ) + def test_exceeding_content_length_check_not_required(self, m): + """Assert that body is saved when content-length exceeds pre-defined max but length check not required""" methods = [ ("GET", requests.get, m.get), ("POST", requests.post, m.post), @@ -321,6 +441,47 @@ def test_admin_override_save_body(self, m): "Authorization": "test", "Content-Type": "text/html", }, + headers={ + "Date": "Tue, 21 Mar 2023 15:24:08 GMT", + "Content-Type": "application/json", + "Content-Length": "16", + }, + ) + func( + "http://example.com/some-path?version=2.0", + headers={"Authorization": "test", "Content-Type": "text/html"}, + ) + + request_log = OutgoingRequestsLog.objects.last() + + self.assertEqual( + request_log.res_headers, + "Date: Tue, 21 Mar 2023 15:24:08 GMT\nContent-Type: application/json\nContent-Length: 16", + ) + self.assertEqual(json.loads(request_log.res_body), {"test": "data"}) + + @override_settings( + LOG_OUTGOING_REQUESTS_DB_SAVE=True, LOG_OUTGOING_REQUESTS_DB_SAVE_BODY=True + ) + def test_missing_body_response(self, m): + methods = [ + ("GET", requests.get, m.get), + ("POST", requests.post, m.post), + ("PUT", requests.put, m.put), + ("PATCH", requests.patch, m.patch), + ("DELETE", requests.delete, m.delete), + ("HEAD", requests.head, m.head), + ] + + for method, func, mocked in methods: + with self.subTest(): + mocked( + "http://example.com/some-path?version=2.0", + status_code=200, + request_headers={ + "Authorization": "test", + "Content-Type": "text/html", + }, headers={ "Date": "Tue, 21 Mar 2023 15:24:08 GMT", "Content-Type": "application/json", @@ -334,4 +495,179 @@ def test_admin_override_save_body(self, m): request_log = OutgoingRequestsLog.objects.last() - self.assertIsNotNone(request_log.res_body) + self.assertEqual(request_log.res_body, "") + + +@requests_mock.Mocker() +class OutgoingRequestsLogConfigTest(TestCase): + # Mocks & fixtures + def _setUpMocks(self, m): + m.get( + "http://example.com/some-path?version=2.0", + status_code=200, + content=b"some content", + ) + + def _setUpMocksAlt(self, m): + m.get( + "http://example.com/some-path?version=2.0", + status_code=200, + content=None, + ) + + @override_settings(LOG_OUTGOING_REQUESTS_DB_SAVE=True) + def test_admin_override_db_save(self, m): + """Assert that `save_to_db` can be overriden in admin""" + config = OutgoingRequestsLogConfig.get_solo() + config.save_to_db = "no" + config.save() + + mocked = m.get + mocked( + "http://example.com/some-path?version=2.0", + status_code=200, + json={"test": "data"}, + request_headers={ + "Authorization": "test", + "Content-Type": "text/html", + }, + headers={ + "Date": "Tue, 21 Mar 2023 15:24:08 GMT", + "Content-Type": "application/json", + "Content-Length": "16", + }, + ) + requests.get( + "http://example.com/some-path?version=2.0", + headers={"Authorization": "test", "Content-Type": "text/html"}, + ) + + request_log = OutgoingRequestsLog.objects.last() + + self.assertIsNone(request_log) + + @override_settings( + LOG_OUTGOING_REQUESTS_DB_SAVE=True, + LOG_OUTGOING_REQUESTS_DB_SAVE_BODY=True, + ) + def test_admin_override_save_body(self, m): + """Assert that `save_body` can be disabled in admin""" + config = OutgoingRequestsLogConfig.get_solo() + config.save_body = "no" + config.save() + + mocked = m.get + mocked( + "http://example.com/some-path?version=2.0", + status_code=200, + json={"test": "data"}, + request_headers={ + "Authorization": "test", + "Content-Type": "text/html", + }, + headers={ + "Date": "Tue, 21 Mar 2023 15:24:08 GMT", + "Content-Type": "application/json", + "Content-Length": "16", + }, + ) + requests.get( + "http://example.com/some-path?version=2.0", + headers={"Authorization": "test", "Content-Type": "text/html"}, + ) + + request_log = OutgoingRequestsLog.objects.last() + + self.assertEqual(request_log.res_body, "") + + @override_settings(LOG_OUTGOING_REQUESTS_REQUIRE_CONTENT_LENGTH=True) + def test_admin_override_require_content_length(self, m): + """Assert that `require_content_length` can be overriden in admin""" + config = OutgoingRequestsLogConfig.get_solo() + config.require_content_length = "no" + config.save() + + mocked = m.get + mocked( + "http://example.com/some-path?version=2.0", + status_code=200, + json={"test": "data"}, + request_headers={ + "Authorization": "test", + "Content-Type": "text/html", + }, + headers={ + "Date": "Tue, 21 Mar 2023 15:24:08 GMT", + "Content-Type": "application/json", + }, + ) + requests.get( + "http://example.com/some-path?version=2.0", + headers={"Authorization": "test", "Content-Type": "text/html"}, + ) + + request_log = OutgoingRequestsLog.objects.last() + + self.assertEqual(json.loads(request_log.res_body), {"test": "data"}) + + @override_settings(LOG_OUTGOING_REQUESTS_MAX_CONTENT_LENGTH=1) + def test_admin_override_max_content_length(self, m): + """Assert that `max_content_length` can be overriden in admin""" + config = OutgoingRequestsLogConfig.get_solo() + config.max_content_length = "20" + config.save() + + mocked = m.get + mocked( + "http://example.com/some-path?version=2.0", + status_code=200, + json={"test": "data"}, + request_headers={ + "Authorization": "test", + "Content-Type": "text/html", + }, + headers={ + "Date": "Tue, 21 Mar 2023 15:24:08 GMT", + "Content-Type": "application/json", + "Content-Length": "16", + }, + ) + requests.get( + "http://example.com/some-path?version=2.0", + headers={"Authorization": "test", "Content-Type": "text/html"}, + ) + + request_log = OutgoingRequestsLog.objects.last() + + self.assertEqual(json.loads(request_log.res_body), {"test": "data"}) + + @override_settings(LOG_OUTGOING_REQUESTS_CONTENT_TYPES=["text/*"]) + def test_admin_override_conten_types(self, m): + """Assert that `allowed_content_types` can be overriden in admin""" + config = OutgoingRequestsLogConfig.get_solo() + config.allowed_content_types = ["text/*", "video/mp4"] + config.save() + + mocked = m.get + mocked( + "http://example.com/some-path?version=2.0", + status_code=200, + json={"test": "data"}, + request_headers={ + "Authorization": "test", + "Content-Type": "text/html", + }, + headers={ + "Date": "Tue, 21 Mar 2023 15:24:08 GMT", + "Content-Type": "video/mp4", + "Content-Length": "16", + }, + ) + requests.get( + "http://example.com/some-path?version=2.0", + headers={"Authorization": "test", "Content-Type": "text/html"}, + ) + + request_log = OutgoingRequestsLog.objects.last() + + self.assertEqual(json.loads(request_log.res_body), {"test": "data"}) diff --git a/tests/test_validators.py b/tests/test_validators.py new file mode 100644 index 0000000..54aec49 --- /dev/null +++ b/tests/test_validators.py @@ -0,0 +1,95 @@ +import pytest + +from log_outgoing_requests.validators import ( + _validate_content_length, + _validate_content_type, +) + + +# +# Fixtures +# +@pytest.fixture() +def config(): + from log_outgoing_requests.models import OutgoingRequestsLogConfig + + conf = OutgoingRequestsLogConfig.get_solo() + return conf + + +@pytest.fixture() +def config_max_content_length(config): + config.max_content_length = 150 + config.save() + return config + + +@pytest.fixture() +def config_content_types(config): + config.allowed_content_types = ["text/*", "application/json"] + config.save() + return config + + +# +# Test _validate_content_length +# +@pytest.mark.django_db +class TestValidateContentLength: + @pytest.mark.parametrize("content_length", [100, "100", 100.5, None, {}]) + def test_no_length_check_required(self, content_length, config): + config.require_content_length = "no" + config.save() + assert _validate_content_length(content_length, config) is True + + @pytest.mark.parametrize("content_length", [None]) + def test_no_content_length_provided(self, content_length, config): + assert _validate_content_length(content_length, config) is False + + @pytest.mark.parametrize("content_length", ["test", {}]) + def test_validate_content_length_wrong_argument_type( + self, content_length, config_max_content_length + ): + assert ( + _validate_content_length(content_length, config_max_content_length) is False + ) + + @pytest.mark.parametrize("content_length", [100, 150, "100", 100.5]) + def test_validate_content_length_success( + self, content_length, config_max_content_length + ): + assert ( + _validate_content_length(content_length, config_max_content_length) is True + ) + + @pytest.mark.parametrize("content_length", [160, "160", 160.5]) + def test_validate_content_length_fail( + self, content_length, config_max_content_length + ): + assert ( + _validate_content_length(content_length, config_max_content_length) is False + ) + + +# +# Test _validate_content_type +# +@pytest.mark.django_db +class TestValidateContentType: + @pytest.mark.parametrize("content_type", [None, 42, {}]) + def test_validate_content_type_wrong_argument_type( + self, content_type, config_content_types + ): + assert _validate_content_type(content_type, config_content_types) is False + + @pytest.mark.parametrize( + "content_type", ["text/plain", "text/html", "text/*", "application/json"] + ) + def test_validate_content_type_success(self, content_type, config_content_types): + assert _validate_content_type(content_type, config_content_types) is True + + @pytest.mark.parametrize( + "content_type", ["video/mp4", "application/bogus", "*", ""] + ) + def test_validate_content_type_fail(self, content_type, config_content_types): + assert _validate_content_type(content_type, config_content_types) is False diff --git a/tox.ini b/tox.ini index 8dcf981..178c9e3 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,5 @@ [tox] envlist = - py37-django32 py{38,39,310}-django{32,41} isort black @@ -10,7 +9,6 @@ skip_missing_interpreters = true [gh-actions] python = - 3.7: py37 3.8: py38 3.9: py39 3.10: py310 @@ -24,7 +22,13 @@ DJANGO = setenv = DJANGO_SETTINGS_MODULE=testapp.settings PYTHONPATH={toxinidir} +passenv = + PGPORT + DB_USER + DB_HOST + DB_PASSWORD extras = + db tests coverage deps = @@ -55,6 +59,7 @@ basepython=python changedir=docs skipsdist=true extras = + db tests docs commands=