From fbbfca9a59398fa2790b5c1718c8011c9e758708 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?L=C3=A1szl=C3=B3=20K=C3=A1rolyi?= <laszlo@karolyi.hu>
Date: Tue, 30 Jul 2024 23:14:54 +0200
Subject: [PATCH 1/8] Quick hack for including csp_nonces from requests into
 script tags

---
 debug_toolbar/templates/debug_toolbar/base.html                 | 2 +-
 .../templates/debug_toolbar/includes/panel_content.html         | 2 +-
 debug_toolbar/templates/debug_toolbar/redirect.html             | 2 +-
 3 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/debug_toolbar/templates/debug_toolbar/base.html b/debug_toolbar/templates/debug_toolbar/base.html
index 4867a834e..f5cef599a 100644
--- a/debug_toolbar/templates/debug_toolbar/base.html
+++ b/debug_toolbar/templates/debug_toolbar/base.html
@@ -4,7 +4,7 @@
 <link rel="stylesheet" href="{% static 'debug_toolbar/css/toolbar.css' %}">
 {% endblock %}
 {% block js %}
-<script type="module" src="{% static 'debug_toolbar/js/toolbar.js' %}" async></script>
+<script{% if toolbar.request.csp_nonce %} nonce="{{ toolbar.request.csp_nonce }}"{% endif %} type="module" src="{% static 'debug_toolbar/js/toolbar.js' %}" async></script>
 {% endblock %}
 <div id="djDebug" class="djdt-hidden" dir="ltr"
      {% if not toolbar.should_render_panels %}
diff --git a/debug_toolbar/templates/debug_toolbar/includes/panel_content.html b/debug_toolbar/templates/debug_toolbar/includes/panel_content.html
index 585682c61..7d729e20b 100644
--- a/debug_toolbar/templates/debug_toolbar/includes/panel_content.html
+++ b/debug_toolbar/templates/debug_toolbar/includes/panel_content.html
@@ -8,7 +8,7 @@ <h3>{{ panel.title }}</h3>
     </div>
     <div class="djDebugPanelContent">
       {% if toolbar.should_render_panels %}
-        {% for script in panel.scripts %}<script type="module" src="{{ script }}" async></script>{% endfor %}
+        {% for script in panel.scripts %}<script{% if toolbar.request.csp_nonce %} nonce="{{ toolbar.request.csp_nonce }}"{% endif %} type="module" src="{{ script }}" async></script>{% endfor %}
         <div class="djdt-scroll">{{ panel.content }}</div>
       {% else %}
         <div class="djdt-loader"></div>
diff --git a/debug_toolbar/templates/debug_toolbar/redirect.html b/debug_toolbar/templates/debug_toolbar/redirect.html
index 96b97de2d..cb6b4a6ea 100644
--- a/debug_toolbar/templates/debug_toolbar/redirect.html
+++ b/debug_toolbar/templates/debug_toolbar/redirect.html
@@ -3,7 +3,7 @@
 <html lang="en">
   <head>
     <title>Django Debug Toolbar Redirects Panel: {{ status_line }}</title>
-    <script type="module" src="{% static 'debug_toolbar/js/redirect.js' %}" async></script>
+    <script{% if toolbar.request.csp_nonce %} nonce="{{ toolbar.request.csp_nonce }}"{% endif %} type="module" src="{% static 'debug_toolbar/js/redirect.js' %}" async></script>
   </head>
   <body>
     <h1>{{ status_line }}</h1>

From ba4ae5064a0cbed51f22c8fce6be3b45b7867854 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?L=C3=A1szl=C3=B3=20K=C3=A1rolyi?= <laszlo@karolyi.hu>
Date: Thu, 1 Aug 2024 19:54:02 +0200
Subject: [PATCH 2/8] Add rendering tests & nonces on link/style tags

---
 .gitignore                                    |   1 +
 .../templates/debug_toolbar/base.html         |   4 +-
 debug_toolbar/toolbar.py                      |   3 +-
 tests/base.py                                 |   4 +
 tests/panels/test_template.py                 |   3 +-
 tests/test_csp_rendering.py                   | 103 ++++++++++++++++++
 tests/test_integration.py                     |   1 -
 tox.ini                                       |   1 +
 8 files changed, 115 insertions(+), 5 deletions(-)
 create mode 100644 tests/test_csp_rendering.py

diff --git a/.gitignore b/.gitignore
index 988922d50..c89013a11 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,4 @@ geckodriver.log
 coverage.xml
 .direnv/
 .envrc
+venv
diff --git a/debug_toolbar/templates/debug_toolbar/base.html b/debug_toolbar/templates/debug_toolbar/base.html
index f5cef599a..b0308be55 100644
--- a/debug_toolbar/templates/debug_toolbar/base.html
+++ b/debug_toolbar/templates/debug_toolbar/base.html
@@ -1,7 +1,7 @@
 {% load i18n static %}
 {% block css %}
-<link rel="stylesheet" href="{% static 'debug_toolbar/css/print.css' %}" media="print">
-<link rel="stylesheet" href="{% static 'debug_toolbar/css/toolbar.css' %}">
+<link{% if toolbar.request.csp_nonce %} nonce="{{ toolbar.request.csp_nonce }}"{% endif %} rel="stylesheet" href="{% static 'debug_toolbar/css/print.css' %}" media="print">
+<link{% if toolbar.request.csp_nonce %} nonce="{{ toolbar.request.csp_nonce }}"{% endif %} rel="stylesheet" href="{% static 'debug_toolbar/css/toolbar.css' %}">
 {% endblock %}
 {% block js %}
 <script{% if toolbar.request.csp_nonce %} nonce="{{ toolbar.request.csp_nonce }}"{% endif %} type="module" src="{% static 'debug_toolbar/js/toolbar.js' %}" async></script>
diff --git a/debug_toolbar/toolbar.py b/debug_toolbar/toolbar.py
index e1b5474de..a1f347d58 100644
--- a/debug_toolbar/toolbar.py
+++ b/debug_toolbar/toolbar.py
@@ -19,6 +19,7 @@
 from django.utils.translation import get_language, override as lang_override
 
 from debug_toolbar import APP_NAME, settings as dt_settings
+from debug_toolbar.panels import Panel
 
 
 class DebugToolbar:
@@ -38,7 +39,7 @@ def __init__(self, request, get_response):
         # Use OrderedDict for the _panels attribute so that items can be efficiently
         # removed using FIFO order in the DebugToolbar.store() method.  The .popitem()
         # method of Python's built-in dict only supports LIFO removal.
-        self._panels = OrderedDict()
+        self._panels = OrderedDict[str, Panel]()
         while panels:
             panel = panels.pop()
             self._panels[panel.panel_id] = panel
diff --git a/tests/base.py b/tests/base.py
index 5cc432add..9d12c5219 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -1,8 +1,11 @@
+from typing import Optional
+
 import html5lib
 from asgiref.local import Local
 from django.http import HttpResponse
 from django.test import Client, RequestFactory, TestCase, TransactionTestCase
 
+from debug_toolbar.panels import Panel
 from debug_toolbar.toolbar import DebugToolbar
 
 
@@ -32,6 +35,7 @@ def handle_toolbar_created(sender, toolbar=None, **kwargs):
 class BaseMixin:
     client_class = ToolbarTestClient
 
+    panel: Optional[Panel] = None
     panel_id = None
 
     def setUp(self):
diff --git a/tests/panels/test_template.py b/tests/panels/test_template.py
index 636e88a23..bce8f026b 100644
--- a/tests/panels/test_template.py
+++ b/tests/panels/test_template.py
@@ -98,7 +98,8 @@ def test_custom_context_processor(self):
         )
 
     def test_disabled(self):
-        config = {"DISABLE_PANELS": {"debug_toolbar.panels.templates.TemplatesPanel"}}
+        config = {"DISABLE_PANELS": {
+            "debug_toolbar.panels.templates.TemplatesPanel"}}
         self.assertTrue(self.panel.enabled)
         with self.settings(DEBUG_TOOLBAR_CONFIG=config):
             self.assertFalse(self.panel.enabled)
diff --git a/tests/test_csp_rendering.py b/tests/test_csp_rendering.py
new file mode 100644
index 000000000..d5d5d1016
--- /dev/null
+++ b/tests/test_csp_rendering.py
@@ -0,0 +1,103 @@
+from typing import Dict
+from xml.etree.ElementTree import Element
+
+from django.conf import settings
+from django.http.response import HttpResponse
+from django.test.utils import ContextList, override_settings
+from html5lib.constants import E
+from html5lib.html5parser import HTMLParser
+
+from .base import BaseTestCase
+
+
+def _get_ns(element: Element) -> dict[str, str]:
+    """
+    Return the default `xmlns`. See
+    https://docs.python.org/3/library/xml.etree.elementtree.html#parsing-xml-with-namespaces
+    """
+    if not element.tag.startswith('{'):
+        return dict()
+    return {'': element.tag[1:].split('}', maxsplit=1)[0]}
+
+
+class CspRenderingTestCase(BaseTestCase):
+    'Testing if `csp-nonce` renders.'
+    panel_id = "StaticFilesPanel"
+
+    # def setUp(self):
+    #     self.factory = RequestFactory()
+    #     self.async_factory = AsyncRequestFactory()
+
+    def _fail_if_missing(
+            self, root: Element, path: str, namespaces: Dict[str, str],
+            nonce: str):
+        """
+        Search elements, fail if a `nonce` attribute is missing on them.
+        """
+        elements = root.findall(path=path, namespaces=namespaces)
+        for item in elements:
+            if item.attrib.get('nonce') != nonce:
+                raise self.failureException(f'{item} has no nonce attribute.')
+
+    def _fail_if_found(
+            self, root: Element, path: str, namespaces: Dict[str, str]):
+        """
+        Search elements, fail if a `nonce` attribute is found on them.
+        """
+        elements = root.findall(path=path, namespaces=namespaces)
+        for item in elements:
+            if 'nonce' in item.attrib:
+                raise self.failureException(f'{item} has no nonce attribute.')
+
+    def _fail_on_invalid_html(self, content: bytes, parser: HTMLParser):
+        'Fail if the passed HTML is invalid.'
+        if parser.errors:
+            default_msg = ['Content is invalid HTML:']
+            lines = content.split(b'\n')
+            for position, errorcode, datavars in parser.errors:
+                default_msg.append('  %s' % E[errorcode] % datavars)
+                default_msg.append('    %r' % lines[position[0] - 1])
+            msg = self._formatMessage(None, '\n'.join(default_msg))
+            raise self.failureException(msg)
+
+    @override_settings(
+        DEBUG=True, MIDDLEWARE=settings.MIDDLEWARE + [
+            'csp.middleware.CSPMiddleware'
+        ])
+    def test_exists(self):
+        'A `nonce` should exists when using the `CSPMiddleware`.'
+        response = self.client.get(path='/regular/basic/')
+        if not isinstance(response, HttpResponse):
+            raise self.failureException(f'{response!r} is not a HttpResponse')
+        self.assertEqual(response.status_code, 200)
+        parser = HTMLParser()
+        el_htmlroot: Element = parser.parse(stream=response.content)
+        self._fail_on_invalid_html(content=response.content, parser=parser)
+        self.assertContains(response, 'djDebug')
+        namespaces = _get_ns(element=el_htmlroot)
+        context: ContextList = \
+            response.context  # pyright: ignore[reportAttributeAccessIssue]
+        nonce = str(context['toolbar'].request.csp_nonce)
+        self._fail_if_missing(
+            root=el_htmlroot, path='.//link', namespaces=namespaces,
+            nonce=nonce)
+        self._fail_if_missing(
+            root=el_htmlroot, path='.//script', namespaces=namespaces,
+            nonce=nonce)
+
+    @override_settings(DEBUG=True)
+    def test_missing(self):
+        'A `nonce` should not exist when not using the `CSPMiddleware`.'
+        response = self.client.get(path='/regular/basic/')
+        if not isinstance(response, HttpResponse):
+            raise self.failureException(f'{response!r} is not a HttpResponse')
+        self.assertEqual(response.status_code, 200)
+        parser = HTMLParser()
+        el_htmlroot: Element = parser.parse(stream=response.content)
+        self._fail_on_invalid_html(content=response.content, parser=parser)
+        self.assertContains(response, 'djDebug')
+        namespaces = _get_ns(element=el_htmlroot)
+        self._fail_if_found(
+            root=el_htmlroot, path='.//link', namespaces=namespaces)
+        self._fail_if_found(
+            root=el_htmlroot, path='.//script', namespaces=namespaces)
diff --git a/tests/test_integration.py b/tests/test_integration.py
index df276d90c..1d94a5056 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -53,7 +53,6 @@ def title(self):
     def content(self):
         raise Exception
 
-
 @override_settings(DEBUG=True)
 class DebugToolbarTestCase(BaseTestCase):
     def test_show_toolbar(self):
diff --git a/tox.ini b/tox.ini
index a0e72827a..160b33db7 100644
--- a/tox.ini
+++ b/tox.ini
@@ -21,6 +21,7 @@ deps =
     pygments
     selenium>=4.8.0
     sqlparse
+    django-csp
 passenv=
     CI
     COVERAGE_ARGS

From 64041b66e214f7c68514279fc3569689b639d643 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?L=C3=A1szl=C3=B3=20K=C3=A1rolyi?= <laszlo@karolyi.hu>
Date: Thu, 1 Aug 2024 19:59:08 +0200
Subject: [PATCH 3/8] Fixing for py38

---
 debug_toolbar/toolbar.py    | 8 +++++---
 tests/test_csp_rendering.py | 8 ++------
 2 files changed, 7 insertions(+), 9 deletions(-)

diff --git a/debug_toolbar/toolbar.py b/debug_toolbar/toolbar.py
index a1f347d58..d444abc87 100644
--- a/debug_toolbar/toolbar.py
+++ b/debug_toolbar/toolbar.py
@@ -4,8 +4,8 @@
 
 import re
 import uuid
-from collections import OrderedDict
 from functools import lru_cache
+from typing import OrderedDict
 
 from django.apps import apps
 from django.conf import settings
@@ -16,9 +16,11 @@
 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
+from django.utils.translation import get_language
+from django.utils.translation import override as lang_override
 
-from debug_toolbar import APP_NAME, settings as dt_settings
+from debug_toolbar import APP_NAME
+from debug_toolbar import settings as dt_settings
 from debug_toolbar.panels import Panel
 
 
diff --git a/tests/test_csp_rendering.py b/tests/test_csp_rendering.py
index d5d5d1016..a46c8bf49 100644
--- a/tests/test_csp_rendering.py
+++ b/tests/test_csp_rendering.py
@@ -10,13 +10,13 @@
 from .base import BaseTestCase
 
 
-def _get_ns(element: Element) -> dict[str, str]:
+def _get_ns(element: Element) -> Dict[str, str]:
     """
     Return the default `xmlns`. See
     https://docs.python.org/3/library/xml.etree.elementtree.html#parsing-xml-with-namespaces
     """
     if not element.tag.startswith('{'):
-        return dict()
+        return {}
     return {'': element.tag[1:].split('}', maxsplit=1)[0]}
 
 
@@ -24,10 +24,6 @@ class CspRenderingTestCase(BaseTestCase):
     'Testing if `csp-nonce` renders.'
     panel_id = "StaticFilesPanel"
 
-    # def setUp(self):
-    #     self.factory = RequestFactory()
-    #     self.async_factory = AsyncRequestFactory()
-
     def _fail_if_missing(
             self, root: Element, path: str, namespaces: Dict[str, str],
             nonce: str):

From 260d2fbfff027865e6e430181c3baec7ea904f17 Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
 <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Thu, 1 Aug 2024 18:25:06 +0000
Subject: [PATCH 4/8] [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci
---
 debug_toolbar/toolbar.py      |  6 +--
 tests/panels/test_template.py |  3 +-
 tests/test_csp_rendering.py   | 74 +++++++++++++++++------------------
 tests/test_integration.py     |  1 +
 4 files changed, 39 insertions(+), 45 deletions(-)

diff --git a/debug_toolbar/toolbar.py b/debug_toolbar/toolbar.py
index d444abc87..b2541dfa0 100644
--- a/debug_toolbar/toolbar.py
+++ b/debug_toolbar/toolbar.py
@@ -16,11 +16,9 @@
 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
-from django.utils.translation import override as lang_override
+from django.utils.translation import get_language, override as lang_override
 
-from debug_toolbar import APP_NAME
-from debug_toolbar import settings as dt_settings
+from debug_toolbar import APP_NAME, settings as dt_settings
 from debug_toolbar.panels import Panel
 
 
diff --git a/tests/panels/test_template.py b/tests/panels/test_template.py
index bce8f026b..636e88a23 100644
--- a/tests/panels/test_template.py
+++ b/tests/panels/test_template.py
@@ -98,8 +98,7 @@ def test_custom_context_processor(self):
         )
 
     def test_disabled(self):
-        config = {"DISABLE_PANELS": {
-            "debug_toolbar.panels.templates.TemplatesPanel"}}
+        config = {"DISABLE_PANELS": {"debug_toolbar.panels.templates.TemplatesPanel"}}
         self.assertTrue(self.panel.enabled)
         with self.settings(DEBUG_TOOLBAR_CONFIG=config):
             self.assertFalse(self.panel.enabled)
diff --git a/tests/test_csp_rendering.py b/tests/test_csp_rendering.py
index a46c8bf49..d20c2bb07 100644
--- a/tests/test_csp_rendering.py
+++ b/tests/test_csp_rendering.py
@@ -15,85 +15,81 @@ def _get_ns(element: Element) -> Dict[str, str]:
     Return the default `xmlns`. See
     https://docs.python.org/3/library/xml.etree.elementtree.html#parsing-xml-with-namespaces
     """
-    if not element.tag.startswith('{'):
+    if not element.tag.startswith("{"):
         return {}
-    return {'': element.tag[1:].split('}', maxsplit=1)[0]}
+    return {"": element.tag[1:].split("}", maxsplit=1)[0]}
 
 
 class CspRenderingTestCase(BaseTestCase):
-    'Testing if `csp-nonce` renders.'
+    "Testing if `csp-nonce` renders."
+
     panel_id = "StaticFilesPanel"
 
     def _fail_if_missing(
-            self, root: Element, path: str, namespaces: Dict[str, str],
-            nonce: str):
+        self, root: Element, path: str, namespaces: Dict[str, str], nonce: str
+    ):
         """
         Search elements, fail if a `nonce` attribute is missing on them.
         """
         elements = root.findall(path=path, namespaces=namespaces)
         for item in elements:
-            if item.attrib.get('nonce') != nonce:
-                raise self.failureException(f'{item} has no nonce attribute.')
+            if item.attrib.get("nonce") != nonce:
+                raise self.failureException(f"{item} has no nonce attribute.")
 
-    def _fail_if_found(
-            self, root: Element, path: str, namespaces: Dict[str, str]):
+    def _fail_if_found(self, root: Element, path: str, namespaces: Dict[str, str]):
         """
         Search elements, fail if a `nonce` attribute is found on them.
         """
         elements = root.findall(path=path, namespaces=namespaces)
         for item in elements:
-            if 'nonce' in item.attrib:
-                raise self.failureException(f'{item} has no nonce attribute.')
+            if "nonce" in item.attrib:
+                raise self.failureException(f"{item} has no nonce attribute.")
 
     def _fail_on_invalid_html(self, content: bytes, parser: HTMLParser):
-        'Fail if the passed HTML is invalid.'
+        "Fail if the passed HTML is invalid."
         if parser.errors:
-            default_msg = ['Content is invalid HTML:']
-            lines = content.split(b'\n')
+            default_msg = ["Content is invalid HTML:"]
+            lines = content.split(b"\n")
             for position, errorcode, datavars in parser.errors:
-                default_msg.append('  %s' % E[errorcode] % datavars)
-                default_msg.append('    %r' % lines[position[0] - 1])
-            msg = self._formatMessage(None, '\n'.join(default_msg))
+                default_msg.append("  %s" % E[errorcode] % datavars)
+                default_msg.append("    %r" % lines[position[0] - 1])
+            msg = self._formatMessage(None, "\n".join(default_msg))
             raise self.failureException(msg)
 
     @override_settings(
-        DEBUG=True, MIDDLEWARE=settings.MIDDLEWARE + [
-            'csp.middleware.CSPMiddleware'
-        ])
+        DEBUG=True, MIDDLEWARE=settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"]
+    )
     def test_exists(self):
-        'A `nonce` should exists when using the `CSPMiddleware`.'
-        response = self.client.get(path='/regular/basic/')
+        "A `nonce` should exists when using the `CSPMiddleware`."
+        response = self.client.get(path="/regular/basic/")
         if not isinstance(response, HttpResponse):
-            raise self.failureException(f'{response!r} is not a HttpResponse')
+            raise self.failureException(f"{response!r} is not a HttpResponse")
         self.assertEqual(response.status_code, 200)
         parser = HTMLParser()
         el_htmlroot: Element = parser.parse(stream=response.content)
         self._fail_on_invalid_html(content=response.content, parser=parser)
-        self.assertContains(response, 'djDebug')
+        self.assertContains(response, "djDebug")
         namespaces = _get_ns(element=el_htmlroot)
-        context: ContextList = \
-            response.context  # pyright: ignore[reportAttributeAccessIssue]
-        nonce = str(context['toolbar'].request.csp_nonce)
+        context: ContextList = response.context  # pyright: ignore[reportAttributeAccessIssue]
+        nonce = str(context["toolbar"].request.csp_nonce)
         self._fail_if_missing(
-            root=el_htmlroot, path='.//link', namespaces=namespaces,
-            nonce=nonce)
+            root=el_htmlroot, path=".//link", namespaces=namespaces, nonce=nonce
+        )
         self._fail_if_missing(
-            root=el_htmlroot, path='.//script', namespaces=namespaces,
-            nonce=nonce)
+            root=el_htmlroot, path=".//script", namespaces=namespaces, nonce=nonce
+        )
 
     @override_settings(DEBUG=True)
     def test_missing(self):
-        'A `nonce` should not exist when not using the `CSPMiddleware`.'
-        response = self.client.get(path='/regular/basic/')
+        "A `nonce` should not exist when not using the `CSPMiddleware`."
+        response = self.client.get(path="/regular/basic/")
         if not isinstance(response, HttpResponse):
-            raise self.failureException(f'{response!r} is not a HttpResponse')
+            raise self.failureException(f"{response!r} is not a HttpResponse")
         self.assertEqual(response.status_code, 200)
         parser = HTMLParser()
         el_htmlroot: Element = parser.parse(stream=response.content)
         self._fail_on_invalid_html(content=response.content, parser=parser)
-        self.assertContains(response, 'djDebug')
+        self.assertContains(response, "djDebug")
         namespaces = _get_ns(element=el_htmlroot)
-        self._fail_if_found(
-            root=el_htmlroot, path='.//link', namespaces=namespaces)
-        self._fail_if_found(
-            root=el_htmlroot, path='.//script', namespaces=namespaces)
+        self._fail_if_found(root=el_htmlroot, path=".//link", namespaces=namespaces)
+        self._fail_if_found(root=el_htmlroot, path=".//script", namespaces=namespaces)
diff --git a/tests/test_integration.py b/tests/test_integration.py
index 1d94a5056..df276d90c 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -53,6 +53,7 @@ def title(self):
     def content(self):
         raise Exception
 
+
 @override_settings(DEBUG=True)
 class DebugToolbarTestCase(BaseTestCase):
     def test_show_toolbar(self):

From 073748cf12728eccb0aa7083b5ac4c5e6ffb19a2 Mon Sep 17 00:00:00 2001
From: tschilling <schillingt@better-simple.com>
Date: Fri, 2 Aug 2024 08:14:58 -0500
Subject: [PATCH 5/8] Improve testing of django-csp integration

Some of these changes are stylistic, such as renaming _get_ns to get_namespaces.

Important changes:
- Adds tests for specific panels that use scripts
- Fixes redirects panel to actually use the nonce
- Fetches the toolbar instance from the store rather than context
---
 debug_toolbar/panels/redirects.py |  6 +-
 debug_toolbar/toolbar.py          |  2 +
 requirements_dev.txt              |  1 +
 tests/test_csp_rendering.py       | 99 ++++++++++++++++++++++---------
 4 files changed, 79 insertions(+), 29 deletions(-)

diff --git a/debug_toolbar/panels/redirects.py b/debug_toolbar/panels/redirects.py
index 8894d1a18..349564edb 100644
--- a/debug_toolbar/panels/redirects.py
+++ b/debug_toolbar/panels/redirects.py
@@ -21,7 +21,11 @@ def process_request(self, request):
             if redirect_to:
                 status_line = f"{response.status_code} {response.reason_phrase}"
                 cookies = response.cookies
-                context = {"redirect_to": redirect_to, "status_line": status_line}
+                context = {
+                    "redirect_to": redirect_to,
+                    "status_line": status_line,
+                    "toolbar": self.toolbar,
+                }
                 # Using SimpleTemplateResponse avoids running global context processors.
                 response = SimpleTemplateResponse(
                     "debug_toolbar/redirect.html", context
diff --git a/debug_toolbar/toolbar.py b/debug_toolbar/toolbar.py
index b2541dfa0..35d789a53 100644
--- a/debug_toolbar/toolbar.py
+++ b/debug_toolbar/toolbar.py
@@ -5,6 +5,8 @@
 import re
 import uuid
 from functools import lru_cache
+
+# Can be removed when python3.8 is dropped
 from typing import OrderedDict
 
 from django.apps import apps
diff --git a/requirements_dev.txt b/requirements_dev.txt
index 03e436622..e66eba5c6 100644
--- a/requirements_dev.txt
+++ b/requirements_dev.txt
@@ -11,6 +11,7 @@ html5lib
 selenium
 tox
 black
+django-csp # Used in tests/test_csp_rendering
 
 # Integration support
 
diff --git a/tests/test_csp_rendering.py b/tests/test_csp_rendering.py
index d20c2bb07..60e5314d8 100644
--- a/tests/test_csp_rendering.py
+++ b/tests/test_csp_rendering.py
@@ -2,15 +2,16 @@
 from xml.etree.ElementTree import Element
 
 from django.conf import settings
-from django.http.response import HttpResponse
 from django.test.utils import ContextList, override_settings
 from html5lib.constants import E
 from html5lib.html5parser import HTMLParser
 
+from debug_toolbar.toolbar import DebugToolbar
+
 from .base import BaseTestCase
 
 
-def _get_ns(element: Element) -> Dict[str, str]:
+def get_namespaces(element: Element) -> Dict[str, str]:
     """
     Return the default `xmlns`. See
     https://docs.python.org/3/library/xml.etree.elementtree.html#parsing-xml-with-namespaces
@@ -20,10 +21,12 @@ def _get_ns(element: Element) -> Dict[str, str]:
     return {"": element.tag[1:].split("}", maxsplit=1)[0]}
 
 
+@override_settings(DEBUG=True)
 class CspRenderingTestCase(BaseTestCase):
-    "Testing if `csp-nonce` renders."
+    """Testing if `csp-nonce` renders."""
 
-    panel_id = "StaticFilesPanel"
+    def setUp(self):
+        self.parser = HTMLParser()
 
     def _fail_if_missing(
         self, root: Element, path: str, namespaces: Dict[str, str], nonce: str
@@ -46,50 +49,90 @@ def _fail_if_found(self, root: Element, path: str, namespaces: Dict[str, str]):
                 raise self.failureException(f"{item} has no nonce attribute.")
 
     def _fail_on_invalid_html(self, content: bytes, parser: HTMLParser):
-        "Fail if the passed HTML is invalid."
+        """Fail if the passed HTML is invalid."""
         if parser.errors:
             default_msg = ["Content is invalid HTML:"]
             lines = content.split(b"\n")
-            for position, errorcode, datavars in parser.errors:
-                default_msg.append("  %s" % E[errorcode] % datavars)
+            for position, error_code, data_vars in parser.errors:
+                default_msg.append("  %s" % E[error_code] % data_vars)
                 default_msg.append("    %r" % lines[position[0] - 1])
             msg = self._formatMessage(None, "\n".join(default_msg))
             raise self.failureException(msg)
 
     @override_settings(
-        DEBUG=True, MIDDLEWARE=settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"]
+        MIDDLEWARE=settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"]
     )
     def test_exists(self):
-        "A `nonce` should exists when using the `CSPMiddleware`."
+        """A `nonce` should exist when using the `CSPMiddleware`."""
         response = self.client.get(path="/regular/basic/")
-        if not isinstance(response, HttpResponse):
-            raise self.failureException(f"{response!r} is not a HttpResponse")
         self.assertEqual(response.status_code, 200)
-        parser = HTMLParser()
-        el_htmlroot: Element = parser.parse(stream=response.content)
-        self._fail_on_invalid_html(content=response.content, parser=parser)
+
+        html_root: Element = self.parser.parse(stream=response.content)
+        self._fail_on_invalid_html(content=response.content, parser=self.parser)
         self.assertContains(response, "djDebug")
-        namespaces = _get_ns(element=el_htmlroot)
-        context: ContextList = response.context  # pyright: ignore[reportAttributeAccessIssue]
+
+        namespaces = get_namespaces(element=html_root)
+        toolbar = list(DebugToolbar._store.values())[0]
+        nonce = str(toolbar.request.csp_nonce)
+        self._fail_if_missing(
+            root=html_root, path=".//link", namespaces=namespaces, nonce=nonce
+        )
+        self._fail_if_missing(
+            root=html_root, path=".//script", namespaces=namespaces, nonce=nonce
+        )
+
+    @override_settings(
+        DEBUG_TOOLBAR_CONFIG={"DISABLE_PANELS": set()},
+        MIDDLEWARE=settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"],
+    )
+    def test_redirects_exists(self):
+        response = self.client.get("/redirect/")
+        self.assertEqual(response.status_code, 200)
+
+        html_root: Element = self.parser.parse(stream=response.content)
+        self._fail_on_invalid_html(content=response.content, parser=self.parser)
+        self.assertContains(response, "djDebug")
+
+        namespaces = get_namespaces(element=html_root)
+        context: ContextList = response.context
         nonce = str(context["toolbar"].request.csp_nonce)
         self._fail_if_missing(
-            root=el_htmlroot, path=".//link", namespaces=namespaces, nonce=nonce
+            root=html_root, path=".//link", namespaces=namespaces, nonce=nonce
         )
         self._fail_if_missing(
-            root=el_htmlroot, path=".//script", namespaces=namespaces, nonce=nonce
+            root=html_root, path=".//script", namespaces=namespaces, nonce=nonce
         )
 
-    @override_settings(DEBUG=True)
+    @override_settings(
+        MIDDLEWARE=settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"]
+    )
+    def test_panel_content_nonce_exists(self):
+        response = self.client.get("/regular/basic/")
+        self.assertEqual(response.status_code, 200)
+
+        toolbar = list(DebugToolbar._store.values())[0]
+        panels_to_check = ["HistoryPanel", "TimerPanel"]
+        for panel in panels_to_check:
+            content = toolbar.get_panel_by_id(panel).content
+            html_root: Element = self.parser.parse(stream=content)
+            namespaces = get_namespaces(element=html_root)
+            nonce = str(toolbar.request.csp_nonce)
+            self._fail_if_missing(
+                root=html_root, path=".//link", namespaces=namespaces, nonce=nonce
+            )
+            self._fail_if_missing(
+                root=html_root, path=".//script", namespaces=namespaces, nonce=nonce
+            )
+
     def test_missing(self):
-        "A `nonce` should not exist when not using the `CSPMiddleware`."
+        """A `nonce` should not exist when not using the `CSPMiddleware`."""
         response = self.client.get(path="/regular/basic/")
-        if not isinstance(response, HttpResponse):
-            raise self.failureException(f"{response!r} is not a HttpResponse")
         self.assertEqual(response.status_code, 200)
-        parser = HTMLParser()
-        el_htmlroot: Element = parser.parse(stream=response.content)
-        self._fail_on_invalid_html(content=response.content, parser=parser)
+
+        html_root: Element = self.parser.parse(stream=response.content)
+        self._fail_on_invalid_html(content=response.content, parser=self.parser)
         self.assertContains(response, "djDebug")
-        namespaces = _get_ns(element=el_htmlroot)
-        self._fail_if_found(root=el_htmlroot, path=".//link", namespaces=namespaces)
-        self._fail_if_found(root=el_htmlroot, path=".//script", namespaces=namespaces)
+
+        namespaces = get_namespaces(element=html_root)
+        self._fail_if_found(root=html_root, path=".//link", namespaces=namespaces)
+        self._fail_if_found(root=html_root, path=".//script", namespaces=namespaces)

From ad4b463a7ffb850cb45ddde0f4241dcc9aa8977a Mon Sep 17 00:00:00 2001
From: tschilling <schillingt@better-simple.com>
Date: Fri, 2 Aug 2024 08:21:34 -0500
Subject: [PATCH 6/8] Use IntegrationTestCase as it clears the store.

---
 tests/test_csp_rendering.py | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/tests/test_csp_rendering.py b/tests/test_csp_rendering.py
index 60e5314d8..b54103730 100644
--- a/tests/test_csp_rendering.py
+++ b/tests/test_csp_rendering.py
@@ -8,7 +8,7 @@
 
 from debug_toolbar.toolbar import DebugToolbar
 
-from .base import BaseTestCase
+from .base import IntegrationTestCase
 
 
 def get_namespaces(element: Element) -> Dict[str, str]:
@@ -22,10 +22,11 @@ def get_namespaces(element: Element) -> Dict[str, str]:
 
 
 @override_settings(DEBUG=True)
-class CspRenderingTestCase(BaseTestCase):
+class CspRenderingTestCase(IntegrationTestCase):
     """Testing if `csp-nonce` renders."""
 
     def setUp(self):
+        super().setUp()
         self.parser = HTMLParser()
 
     def _fail_if_missing(

From 7da5f264c24fa75190dcd1ac6b4d700886312fe8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?L=C3=A1szl=C3=B3=20K=C3=A1rolyi?= <laszlo@karolyi.hu>
Date: Fri, 2 Aug 2024 15:27:48 +0200
Subject: [PATCH 7/8] Fix typing errors

---
 tests/test_csp_rendering.py | 16 +++++++++-------
 1 file changed, 9 insertions(+), 7 deletions(-)

diff --git a/tests/test_csp_rendering.py b/tests/test_csp_rendering.py
index b54103730..12385aeae 100644
--- a/tests/test_csp_rendering.py
+++ b/tests/test_csp_rendering.py
@@ -1,7 +1,8 @@
-from typing import Dict
+from typing import Dict, cast
 from xml.etree.ElementTree import Element
 
 from django.conf import settings
+from django.http.response import HttpResponse
 from django.test.utils import ContextList, override_settings
 from html5lib.constants import E
 from html5lib.html5parser import HTMLParser
@@ -47,7 +48,7 @@ def _fail_if_found(self, root: Element, path: str, namespaces: Dict[str, str]):
         elements = root.findall(path=path, namespaces=namespaces)
         for item in elements:
             if "nonce" in item.attrib:
-                raise self.failureException(f"{item} has no nonce attribute.")
+                raise self.failureException(f"{item} has a nonce attribute.")
 
     def _fail_on_invalid_html(self, content: bytes, parser: HTMLParser):
         """Fail if the passed HTML is invalid."""
@@ -65,7 +66,7 @@ def _fail_on_invalid_html(self, content: bytes, parser: HTMLParser):
     )
     def test_exists(self):
         """A `nonce` should exist when using the `CSPMiddleware`."""
-        response = self.client.get(path="/regular/basic/")
+        response = cast(HttpResponse, self.client.get(path="/regular/basic/"))
         self.assertEqual(response.status_code, 200)
 
         html_root: Element = self.parser.parse(stream=response.content)
@@ -87,7 +88,7 @@ def test_exists(self):
         MIDDLEWARE=settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"],
     )
     def test_redirects_exists(self):
-        response = self.client.get("/redirect/")
+        response = cast(HttpResponse, self.client.get(path="/regular/basic/"))
         self.assertEqual(response.status_code, 200)
 
         html_root: Element = self.parser.parse(stream=response.content)
@@ -95,7 +96,8 @@ def test_redirects_exists(self):
         self.assertContains(response, "djDebug")
 
         namespaces = get_namespaces(element=html_root)
-        context: ContextList = response.context
+        context: ContextList = \
+            response.context  # pyright: ignore[reportAttributeAccessIssue]
         nonce = str(context["toolbar"].request.csp_nonce)
         self._fail_if_missing(
             root=html_root, path=".//link", namespaces=namespaces, nonce=nonce
@@ -108,7 +110,7 @@ def test_redirects_exists(self):
         MIDDLEWARE=settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"]
     )
     def test_panel_content_nonce_exists(self):
-        response = self.client.get("/regular/basic/")
+        response = cast(HttpResponse, self.client.get(path="/regular/basic/"))
         self.assertEqual(response.status_code, 200)
 
         toolbar = list(DebugToolbar._store.values())[0]
@@ -127,7 +129,7 @@ def test_panel_content_nonce_exists(self):
 
     def test_missing(self):
         """A `nonce` should not exist when not using the `CSPMiddleware`."""
-        response = self.client.get(path="/regular/basic/")
+        response = cast(HttpResponse, self.client.get(path="/regular/basic/"))
         self.assertEqual(response.status_code, 200)
 
         html_root: Element = self.parser.parse(stream=response.content)

From 1a6ff1143aa531821f25aad1a393228e4f46810d Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
 <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Fri, 2 Aug 2024 14:37:02 +0000
Subject: [PATCH 8/8] [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci
---
 tests/test_csp_rendering.py | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/tests/test_csp_rendering.py b/tests/test_csp_rendering.py
index 12385aeae..5e355b15a 100644
--- a/tests/test_csp_rendering.py
+++ b/tests/test_csp_rendering.py
@@ -96,8 +96,7 @@ def test_redirects_exists(self):
         self.assertContains(response, "djDebug")
 
         namespaces = get_namespaces(element=html_root)
-        context: ContextList = \
-            response.context  # pyright: ignore[reportAttributeAccessIssue]
+        context: ContextList = response.context  # pyright: ignore[reportAttributeAccessIssue]
         nonce = str(context["toolbar"].request.csp_nonce)
         self._fail_if_missing(
             root=html_root, path=".//link", namespaces=namespaces, nonce=nonce