diff --git a/.gitignore b/.gitignore index ee3559cc4..988922d50 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ htmlcov .tox geckodriver.log coverage.xml +.direnv/ +.envrc diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index af8ab8b6f..54a49e4d6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: hooks: - id: doc8 - repo: https://github.com/adamchainz/django-upgrade - rev: 1.17.0 + rev: 1.19.0 hooks: - id: django-upgrade args: [--target-version, "4.2"] @@ -32,7 +32,7 @@ repos: args: - --trailing-comma=es5 - repo: https://github.com/pre-commit/mirrors-eslint - rev: v9.3.0 + rev: v9.6.0 hooks: - id: eslint additional_dependencies: @@ -44,7 +44,7 @@ repos: args: - --fix - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.4.5' + rev: 'v0.5.0' hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/README.rst b/README.rst index 31c8a6f59..2ce1db4b7 100644 --- a/README.rst +++ b/README.rst @@ -44,7 +44,7 @@ Here's a screenshot of the toolbar in action: In addition to the built-in panels, a number of third-party panels are contributed by the community. -The current stable version of the Debug Toolbar is 4.4.2. It works on +The current stable version of the Debug Toolbar is 4.4.3. It works on Django ≥ 4.2.0. The Debug Toolbar does not currently support `Django's asynchronous views diff --git a/debug_toolbar/__init__.py b/debug_toolbar/__init__.py index 003f26cda..5ddb15d15 100644 --- a/debug_toolbar/__init__.py +++ b/debug_toolbar/__init__.py @@ -4,7 +4,7 @@ # Do not use pkg_resources to find the version but set it here directly! # see issue #1446 -VERSION = "4.4.2" +VERSION = "4.4.3" # Code that discovers files or modules in INSTALLED_APPS imports this module. urls = "debug_toolbar.urls", APP_NAME diff --git a/debug_toolbar/apps.py b/debug_toolbar/apps.py index a2e977d84..a49875bac 100644 --- a/debug_toolbar/apps.py +++ b/debug_toolbar/apps.py @@ -5,10 +5,12 @@ from django.conf import settings from django.core.checks import Error, Warning, register from django.middleware.gzip import GZipMiddleware +from django.urls import NoReverseMatch, reverse from django.utils.module_loading import import_string from django.utils.translation import gettext_lazy as _ -from debug_toolbar import settings as dt_settings +from debug_toolbar import APP_NAME, settings as dt_settings +from debug_toolbar.settings import CONFIG_DEFAULTS class DebugToolbarConfig(AppConfig): @@ -213,7 +215,33 @@ def debug_toolbar_installed_when_running_tests_check(app_configs, **kwargs): """ Check that the toolbar is not being used when tests are running """ - if not settings.DEBUG and dt_settings.get_config()["IS_RUNNING_TESTS"]: + # Check if show toolbar callback has changed + show_toolbar_changed = ( + dt_settings.get_config()["SHOW_TOOLBAR_CALLBACK"] + != CONFIG_DEFAULTS["SHOW_TOOLBAR_CALLBACK"] + ) + try: + # Check if the toolbar's urls are installed + reverse(f"{APP_NAME}:render_panel") + toolbar_urls_installed = True + except NoReverseMatch: + toolbar_urls_installed = False + + # If the user is using the default SHOW_TOOLBAR_CALLBACK, + # then the middleware will respect the change to settings.DEBUG. + # However, if the user has changed the callback to: + # DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": lambda request: DEBUG} + # where DEBUG is not settings.DEBUG, then it won't pick up that Django' + # test runner has changed the value for settings.DEBUG, and the middleware + # will inject the toolbar, while the URLs aren't configured leading to a + # NoReverseMatch error. + likely_error_setup = show_toolbar_changed and not toolbar_urls_installed + + if ( + not settings.DEBUG + and dt_settings.get_config()["IS_RUNNING_TESTS"] + and likely_error_setup + ): return [ Error( "The Django Debug Toolbar can't be used with tests", diff --git a/debug_toolbar/middleware.py b/debug_toolbar/middleware.py index 0513e2379..65b5282c5 100644 --- a/debug_toolbar/middleware.py +++ b/debug_toolbar/middleware.py @@ -20,8 +20,14 @@ def show_toolbar(request): """ Default function to determine whether to show the toolbar on a given page. """ - internal_ips = list(settings.INTERNAL_IPS) + if not settings.DEBUG: + return False + # Test: settings + if request.META.get("REMOTE_ADDR") in settings.INTERNAL_IPS: + return True + + # Test: Docker try: # This is a hack for docker installations. It attempts to look # up the IP address of the docker host. @@ -31,11 +37,14 @@ def show_toolbar(request): ".".join(socket.gethostbyname("host.docker.internal").rsplit(".")[:-1]) + ".1" ) - internal_ips.append(docker_ip) + if request.META.get("REMOTE_ADDR") == docker_ip: + return True except socket.gaierror: # It's fine if the lookup errored since they may not be using docker pass - return settings.DEBUG and request.META.get("REMOTE_ADDR") in internal_ips + + # No test passed + return False @lru_cache(maxsize=None) diff --git a/debug_toolbar/panels/alerts.py b/debug_toolbar/panels/alerts.py new file mode 100644 index 000000000..27a7119ee --- /dev/null +++ b/debug_toolbar/panels/alerts.py @@ -0,0 +1,148 @@ +from html.parser import HTMLParser + +from django.utils.translation import gettext_lazy as _ + +from debug_toolbar.panels import Panel + + +class FormParser(HTMLParser): + """ + HTML form parser, used to check for invalid configurations of forms that + take file inputs. + """ + + def __init__(self): + super().__init__() + self.in_form = False + self.current_form = {} + self.forms = [] + self.form_ids = [] + self.referenced_file_inputs = [] + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + if tag == "form": + self.in_form = True + form_id = attrs.get("id") + if form_id: + self.form_ids.append(form_id) + self.current_form = { + "file_form": False, + "form_attrs": attrs, + "submit_element_attrs": [], + } + elif ( + self.in_form + and tag == "input" + and attrs.get("type") == "file" + and (not attrs.get("form") or attrs.get("form") == "") + ): + self.current_form["file_form"] = True + elif ( + self.in_form + and ( + (tag == "input" and attrs.get("type") in {"submit", "image"}) + or tag == "button" + ) + and (not attrs.get("form") or attrs.get("form") == "") + ): + self.current_form["submit_element_attrs"].append(attrs) + elif tag == "input" and attrs.get("form"): + self.referenced_file_inputs.append(attrs) + + def handle_endtag(self, tag): + if tag == "form" and self.in_form: + self.forms.append(self.current_form) + self.in_form = False + + +class AlertsPanel(Panel): + """ + A panel to alert users to issues. + """ + + messages = { + "form_id_missing_enctype": _( + 'Form with id "{form_id}" contains file input, but does not have the attribute enctype="multipart/form-data".' + ), + "form_missing_enctype": _( + 'Form contains file input, but does not have the attribute enctype="multipart/form-data".' + ), + "input_refs_form_missing_enctype": _( + 'Input element references form with id "{form_id}", but the form does not have the attribute enctype="multipart/form-data".' + ), + } + + title = _("Alerts") + + template = "debug_toolbar/panels/alerts.html" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.alerts = [] + + @property + def nav_subtitle(self): + alerts = self.get_stats()["alerts"] + if alerts: + alert_text = "alert" if len(alerts) == 1 else "alerts" + return f"{len(alerts)} {alert_text}" + else: + return "" + + def add_alert(self, alert): + self.alerts.append(alert) + + def check_invalid_file_form_configuration(self, html_content): + """ + Inspects HTML content for a form that includes a file input but does + not have the encoding type set to multipart/form-data, and warns the + user if so. + """ + parser = FormParser() + parser.feed(html_content) + + # Check for file inputs directly inside a form that do not reference + # any form through the form attribute + for form in parser.forms: + if ( + form["file_form"] + and form["form_attrs"].get("enctype") != "multipart/form-data" + and not any( + elem.get("formenctype") == "multipart/form-data" + for elem in form["submit_element_attrs"] + ) + ): + if form_id := form["form_attrs"].get("id"): + alert = self.messages["form_id_missing_enctype"].format( + form_id=form_id + ) + else: + alert = self.messages["form_missing_enctype"] + self.add_alert({"alert": alert}) + + # Check for file inputs that reference a form + form_attrs_by_id = { + form["form_attrs"].get("id"): form["form_attrs"] for form in parser.forms + } + + for attrs in parser.referenced_file_inputs: + form_id = attrs.get("form") + if form_id and attrs.get("type") == "file": + form_attrs = form_attrs_by_id.get(form_id) + if form_attrs and form_attrs.get("enctype") != "multipart/form-data": + alert = self.messages["input_refs_form_missing_enctype"].format( + form_id=form_id + ) + self.add_alert({"alert": alert}) + + return self.alerts + + def generate_stats(self, request, response): + html_content = response.content.decode(response.charset) + self.check_invalid_file_form_configuration(html_content) + + # Further alert checks can go here + + # Write all alerts to record_stats + self.record_stats({"alerts": self.alerts}) diff --git a/debug_toolbar/settings.py b/debug_toolbar/settings.py index ca7036c34..48483cf40 100644 --- a/debug_toolbar/settings.py +++ b/debug_toolbar/settings.py @@ -67,6 +67,7 @@ def get_config(): "debug_toolbar.panels.sql.SQLPanel", "debug_toolbar.panels.staticfiles.StaticFilesPanel", "debug_toolbar.panels.templates.TemplatesPanel", + "debug_toolbar.panels.alerts.AlertsPanel", "debug_toolbar.panels.cache.CachePanel", "debug_toolbar.panels.signals.SignalsPanel", "debug_toolbar.panels.redirects.RedirectsPanel", diff --git a/debug_toolbar/static/debug_toolbar/css/toolbar.css b/debug_toolbar/static/debug_toolbar/css/toolbar.css index e028a67b7..e495eeb0c 100644 --- a/debug_toolbar/static/debug_toolbar/css/toolbar.css +++ b/debug_toolbar/static/debug_toolbar/css/toolbar.css @@ -1,6 +1,5 @@ /* Variable definitions */ -:root, -#djDebug[data-theme="light"] { +:root { /* Font families are the same as in Django admin/css/base.css */ --djdt-font-family-primary: "Segoe UI", system-ui, Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", @@ -10,7 +9,10 @@ "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Courier New", monospace, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; +} +:root, +#djDebug[data-theme="light"] { --djdt-font-color: black; --djdt-background-color: white; --djdt-panel-content-background-color: #eee; @@ -732,4 +734,6 @@ #djDebug[data-theme="dark"] #djToggleThemeButton svg.theme-dark, #djDebug[data-theme="auto"] #djToggleThemeButton svg.theme-auto { display: block; + height: 1rem; + width: 1rem; } diff --git a/debug_toolbar/templates/debug_toolbar/includes/theme_selector.html b/debug_toolbar/templates/debug_toolbar/includes/theme_selector.html index 372727900..926ff250b 100644 --- a/debug_toolbar/templates/debug_toolbar/includes/theme_selector.html +++ b/debug_toolbar/templates/debug_toolbar/includes/theme_selector.html @@ -2,8 +2,6 @@ aria-hidden="true" class="djdt-hidden theme-auto" fill="currentColor" - width="1rem" - height="1rem" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg" @@ -15,8 +13,6 @@ + {% endfor %} +{% else %} +

{% trans "No alerts found" %}

+{% endif %} diff --git a/debug_toolbar/toolbar.py b/debug_toolbar/toolbar.py index fc07543b5..04502ab09 100644 --- a/debug_toolbar/toolbar.py +++ b/debug_toolbar/toolbar.py @@ -2,16 +2,18 @@ The main DebugToolbar class that loads and renders the Toolbar. """ +import re import uuid from collections import OrderedDict from functools import lru_cache from django.apps import apps +from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.dispatch import Signal from django.template import TemplateSyntaxError from django.template.loader import render_to_string -from django.urls import path, resolve +from django.urls import include, path, re_path, resolve from django.urls.exceptions import Resolver404 from django.utils.module_loading import import_string from django.utils.translation import get_language, override as lang_override @@ -186,3 +188,27 @@ def observe_request(request): Determine whether to update the toolbar from a client side request. """ return True + + +def debug_toolbar_urls(prefix="__debug__"): + """ + Return a URL pattern for serving toolbar in debug mode. + + from django.conf import settings + from debug_toolbar.toolbar import debug_toolbar_urls + + urlpatterns = [ + # ... the rest of your URLconf goes here ... + ] + debug_toolbar_urls() + """ + if not prefix: + raise ImproperlyConfigured("Empty urls prefix not permitted") + elif not settings.DEBUG: + # No-op if not in debug mode. + return [] + return [ + re_path( + r"^%s/" % re.escape(prefix.lstrip("/")), + include("debug_toolbar.urls"), + ), + ] diff --git a/docs/changes.rst b/docs/changes.rst index a0215d09c..4d26be57f 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -4,6 +4,20 @@ Change log Pending ------- +4.4.3 (2024-07-04) +------------------ + +* Added alert panel with warning when form is using file fields + without proper encoding type. +* Fixed overriding font-family for both light and dark themes. +* Restored compatibility with ``iptools.IpRangeList``. +* Limit ``E001`` check to likely error cases when the + ``SHOW_TOOLBAR_CALLBACK`` has changed, but the toolbar's URL + paths aren't installed. +* Introduce helper function ``debug_toolbar_urls`` to + simplify installation. +* Moved "1rem" height/width for SVGs to CSS properties. + 4.4.2 (2024-05-27) ------------------ diff --git a/docs/conf.py b/docs/conf.py index 2e3a527a1..5f69100f7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -25,7 +25,7 @@ copyright = copyright.format(datetime.date.today().year) # The full version, including alpha/beta/rc tags -release = "4.4.2" +release = "4.4.3" # -- General configuration --------------------------------------------------- diff --git a/docs/configuration.rst b/docs/configuration.rst index 04694aceb..e4ccc1dae 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -29,6 +29,7 @@ default value is:: 'debug_toolbar.panels.sql.SQLPanel', 'debug_toolbar.panels.staticfiles.StaticFilesPanel', 'debug_toolbar.panels.templates.TemplatesPanel', + 'debug_toolbar.panels.alerts.AlertsPanel', 'debug_toolbar.panels.cache.CachePanel', 'debug_toolbar.panels.signals.SignalsPanel', 'debug_toolbar.panels.redirects.RedirectsPanel', @@ -152,6 +153,16 @@ Toolbar options implication is that it is possible to execute arbitrary SQL through the SQL panel when the ``SECRET_KEY`` value is leaked somehow. + .. warning:: + + Do not use + ``DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": lambda request: DEBUG}`` + in your project's settings.py file. The toolbar expects to use + ``django.conf.settings.DEBUG``. Using your project's setting's ``DEBUG`` + is likely to cause unexpected results when running your tests. This is because + Django automatically sets ``settings.DEBUG = False``, but your project's + setting's ``DEBUG`` will still be set to ``True``. + .. _OBSERVE_REQUEST_CALLBACK: * ``OBSERVE_REQUEST_CALLBACK`` diff --git a/docs/installation.rst b/docs/installation.rst index 657450fac..9200504b7 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -95,14 +95,14 @@ Add django-debug-toolbar's URLs to your project's URLconf: .. code-block:: python from django.urls import include, path + from debug_toolbar.toolbar import debug_toolbar_urls urlpatterns = [ - # ... - path("__debug__/", include("debug_toolbar.urls")), - ] + # ... the rest of your URLconf goes here ... + ] + debug_toolbar_urls() -This example uses the ``__debug__`` prefix, but you can use any prefix that -doesn't clash with your application's URLs. +By default this uses the ``__debug__`` prefix for the paths, but you can +use any prefix that doesn't clash with your application's URLs. 5. Add the Middleware @@ -180,11 +180,13 @@ You should also modify your URLconf file: .. code-block:: python + from django.conf import settings + from debug_toolbar.toolbar import debug_toolbar_urls + if not settings.TESTING: urlpatterns = [ *urlpatterns, - path("__debug__/", include("debug_toolbar.urls")), - ] + ] + debug_toolbar_urls() Alternatively, you can check out the :ref:`IS_RUNNING_TESTS ` option. diff --git a/docs/panels.rst b/docs/panels.rst index 33359ea46..c9ea6bbf0 100644 --- a/docs/panels.rst +++ b/docs/panels.rst @@ -9,6 +9,17 @@ Default built-in panels The following panels are enabled by default. +Alerts +~~~~~~~ + +.. class:: debug_toolbar.panels.alerts.AlertsPanel + +This panel shows alerts for a set of pre-defined cases: + +- Alerts when the response has a form without the + ``enctype="multipart/form-data"`` attribute and the form contains + a file input. + History ~~~~~~~ diff --git a/example/templates/bad_form.html b/example/templates/bad_form.html new file mode 100644 index 000000000..f50662c6e --- /dev/null +++ b/example/templates/bad_form.html @@ -0,0 +1,14 @@ +{% load cache %} + + + + + Bad form + + +

Bad form test

+
+ +
+ + diff --git a/example/templates/index.html b/example/templates/index.html index ee00d5f05..527f5d2a3 100644 --- a/example/templates/index.html +++ b/example/templates/index.html @@ -14,6 +14,7 @@

Index of Tests

  • Prototype 1.7.3.0
  • Hotwire Turbo
  • htmx
  • +
  • Bad form
  • Django Admin

    {% endcache %} diff --git a/example/urls.py b/example/urls.py index 7569a57f9..6dded2da7 100644 --- a/example/urls.py +++ b/example/urls.py @@ -1,12 +1,17 @@ -from django.conf import settings from django.contrib import admin -from django.urls import include, path +from django.urls import path from django.views.generic import TemplateView +from debug_toolbar.toolbar import debug_toolbar_urls from example.views import increment urlpatterns = [ path("", TemplateView.as_view(template_name="index.html"), name="home"), + path( + "bad-form/", + TemplateView.as_view(template_name="bad_form.html"), + name="bad_form", + ), path("jquery/", TemplateView.as_view(template_name="jquery/index.html")), path("mootools/", TemplateView.as_view(template_name="mootools/index.html")), path("prototype/", TemplateView.as_view(template_name="prototype/index.html")), @@ -34,9 +39,4 @@ ), path("admin/", admin.site.urls), path("ajax/increment", increment, name="ajax_increment"), -] - -if settings.ENABLE_DEBUG_TOOLBAR: - urlpatterns += [ - path("__debug__/", include("debug_toolbar.urls")), - ] +] + debug_toolbar_urls() diff --git a/tests/panels/test_alerts.py b/tests/panels/test_alerts.py new file mode 100644 index 000000000..e61c8da12 --- /dev/null +++ b/tests/panels/test_alerts.py @@ -0,0 +1,101 @@ +from django.http import HttpResponse +from django.template import Context, Template + +from ..base import BaseTestCase + + +class AlertsPanelTestCase(BaseTestCase): + panel_id = "AlertsPanel" + + def test_alert_warning_display(self): + """ + Test that the panel (does not) display[s] an alert when there are + (no) problems. + """ + self.panel.record_stats({"alerts": []}) + self.assertNotIn("alerts", self.panel.nav_subtitle) + + self.panel.record_stats({"alerts": ["Alert 1", "Alert 2"]}) + self.assertIn("2 alerts", self.panel.nav_subtitle) + + def test_file_form_without_enctype_multipart_form_data(self): + """ + Test that the panel displays a form invalid message when there is + a file input but encoding not set to multipart/form-data. + """ + test_form = '
    ' + result = self.panel.check_invalid_file_form_configuration(test_form) + expected_error = ( + 'Form with id "test-form" contains file input, ' + 'but does not have the attribute enctype="multipart/form-data".' + ) + self.assertEqual(result[0]["alert"], expected_error) + self.assertEqual(len(result), 1) + + def test_file_form_no_id_without_enctype_multipart_form_data(self): + """ + Test that the panel displays a form invalid message when there is + a file input but encoding not set to multipart/form-data. + + This should use the message when the form has no id. + """ + test_form = '
    ' + result = self.panel.check_invalid_file_form_configuration(test_form) + expected_error = ( + "Form contains file input, but does not have " + 'the attribute enctype="multipart/form-data".' + ) + self.assertEqual(result[0]["alert"], expected_error) + self.assertEqual(len(result), 1) + + def test_file_form_with_enctype_multipart_form_data(self): + test_form = """
    + +
    """ + result = self.panel.check_invalid_file_form_configuration(test_form) + + self.assertEqual(len(result), 0) + + def test_file_form_with_enctype_multipart_form_data_in_button(self): + test_form = """
    + + +
    """ + result = self.panel.check_invalid_file_form_configuration(test_form) + + self.assertEqual(len(result), 0) + + def test_referenced_file_input_without_enctype_multipart_form_data(self): + test_file_input = """
    + """ + result = self.panel.check_invalid_file_form_configuration(test_file_input) + + expected_error = ( + 'Input element references form with id "test-form", ' + 'but the form does not have the attribute enctype="multipart/form-data".' + ) + self.assertEqual(result[0]["alert"], expected_error) + self.assertEqual(len(result), 1) + + def test_referenced_file_input_with_enctype_multipart_form_data(self): + test_file_input = """
    +
    + """ + result = self.panel.check_invalid_file_form_configuration(test_file_input) + + self.assertEqual(len(result), 0) + + def test_integration_file_form_without_enctype_multipart_form_data(self): + t = Template('
    ') + c = Context({}) + rendered_template = t.render(c) + response = HttpResponse(content=rendered_template) + + self.panel.generate_stats(self.request, response) + + self.assertIn("1 alert", self.panel.nav_subtitle) + self.assertIn( + "Form with id "test-form" contains file input, " + "but does not have the attribute enctype="multipart/form-data".", + self.panel.content, + ) diff --git a/tests/panels/test_history.py b/tests/panels/test_history.py index 2e0aa2179..4c5244934 100644 --- a/tests/panels/test_history.py +++ b/tests/panels/test_history.py @@ -75,6 +75,7 @@ class HistoryViewsTestCase(IntegrationTestCase): "SQLPanel", "StaticFilesPanel", "TemplatesPanel", + "AlertsPanel", "CachePanel", "SignalsPanel", "ProfilingPanel", diff --git a/tests/test_checks.py b/tests/test_checks.py index 827886db1..27db92a9d 100644 --- a/tests/test_checks.py +++ b/tests/test_checks.py @@ -1,9 +1,9 @@ from unittest.mock import patch -from django.core.checks import Error, Warning, run_checks +from django.core.checks import Warning, run_checks from django.test import SimpleTestCase, override_settings +from django.urls import NoReverseMatch -from debug_toolbar import settings as dt_settings from debug_toolbar.apps import debug_toolbar_installed_when_running_tests_check @@ -239,39 +239,70 @@ def test_check_w007_invalid(self, mocked_guess_type): ], ) - def test_debug_toolbar_installed_when_running_tests(self): - with self.settings(DEBUG=True): - # Update the config options because self.settings() - # would require redefining DEBUG_TOOLBAR_CONFIG entirely. - dt_settings.get_config()["IS_RUNNING_TESTS"] = True - errors = debug_toolbar_installed_when_running_tests_check(None) - self.assertEqual(len(errors), 0) - - dt_settings.get_config()["IS_RUNNING_TESTS"] = False - errors = debug_toolbar_installed_when_running_tests_check(None) - self.assertEqual(len(errors), 0) - with self.settings(DEBUG=False): - dt_settings.get_config()["IS_RUNNING_TESTS"] = False - errors = debug_toolbar_installed_when_running_tests_check(None) - self.assertEqual(len(errors), 0) + @patch("debug_toolbar.apps.reverse") + def test_debug_toolbar_installed_when_running_tests(self, reverse): + params = [ + { + "debug": True, + "running_tests": True, + "show_callback_changed": True, + "urls_installed": False, + "errors": False, + }, + { + "debug": False, + "running_tests": False, + "show_callback_changed": True, + "urls_installed": False, + "errors": False, + }, + { + "debug": False, + "running_tests": True, + "show_callback_changed": False, + "urls_installed": False, + "errors": False, + }, + { + "debug": False, + "running_tests": True, + "show_callback_changed": True, + "urls_installed": True, + "errors": False, + }, + { + "debug": False, + "running_tests": True, + "show_callback_changed": True, + "urls_installed": False, + "errors": True, + }, + ] + for config in params: + with self.subTest(**config): + config_setting = { + "RENDER_PANELS": False, + "IS_RUNNING_TESTS": config["running_tests"], + "SHOW_TOOLBAR_CALLBACK": ( + (lambda *args: True) + if config["show_callback_changed"] + else "debug_toolbar.middleware.show_toolbar" + ), + } + if config["urls_installed"]: + reverse.side_effect = lambda *args: None + else: + reverse.side_effect = NoReverseMatch() - dt_settings.get_config()["IS_RUNNING_TESTS"] = True - errors = debug_toolbar_installed_when_running_tests_check(None) - self.assertEqual( - errors, - [ - Error( - "The Django Debug Toolbar can't be used with tests", - hint="Django changes the DEBUG setting to False when running " - "tests. By default the Django Debug Toolbar is installed because " - "DEBUG is set to True. For most cases, you need to avoid installing " - "the toolbar when running tests. If you feel this check is in error, " - "you can set `DEBUG_TOOLBAR_CONFIG['IS_RUNNING_TESTS'] = False` to " - "bypass this check.", - id="debug_toolbar.E001", - ) - ], - ) + with self.settings( + DEBUG=config["debug"], DEBUG_TOOLBAR_CONFIG=config_setting + ): + errors = debug_toolbar_installed_when_running_tests_check(None) + if config["errors"]: + self.assertEqual(len(errors), 1) + self.assertEqual(errors[0].id, "debug_toolbar.E001") + else: + self.assertEqual(len(errors), 0) @override_settings( DEBUG_TOOLBAR_CONFIG={ diff --git a/tests/test_integration.py b/tests/test_integration.py index 465804651..71525affe 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -75,6 +75,28 @@ def test_show_toolbar_docker(self, mocked_gethostbyname): self.assertTrue(show_toolbar(self.request)) mocked_gethostbyname.assert_called_once_with("host.docker.internal") + def test_not_iterating_over_INTERNAL_IPS(self): + """Verify that the middleware does not iterate over INTERNAL_IPS in some way. + + Some people use iptools.IpRangeList for their INTERNAL_IPS. This is a class + that can quickly answer the question if the setting contain a certain IP address, + but iterating over this object will drain all performance / blow up. + """ + + class FailOnIteration: + def __iter__(self): + raise RuntimeError( + "The testcase failed: the code should not have iterated over INTERNAL_IPS" + ) + + def __contains__(self, x): + return True + + with self.settings(INTERNAL_IPS=FailOnIteration()): + response = self.client.get("/regular/basic/") + self.assertEqual(response.status_code, 200) + self.assertContains(response, "djDebug") # toolbar + def test_should_render_panels_RENDER_PANELS(self): """ The toolbar should force rendering panels on each request diff --git a/tests/test_toolbar.py b/tests/test_toolbar.py new file mode 100644 index 000000000..7155d3fcb --- /dev/null +++ b/tests/test_toolbar.py @@ -0,0 +1,17 @@ +from django.core.exceptions import ImproperlyConfigured + +from debug_toolbar.toolbar import debug_toolbar_urls +from tests.base import BaseTestCase + + +class DebugToolbarUrlsTestCase(BaseTestCase): + def test_empty_prefix_errors(self): + with self.assertRaises(ImproperlyConfigured): + debug_toolbar_urls(prefix="") + + def test_empty_when_debug_is_false(self): + self.assertEqual(debug_toolbar_urls(), []) + + def test_has_path(self): + with self.settings(DEBUG=True): + self.assertEqual(len(debug_toolbar_urls()), 1)