Repository: jazzband/django-cookie-consent (language: python)

Issue:
Fix middleware
Solves #13 and #33


Discussion / hints:

Fix patch:
diff --git a/AUTHORS b/AUTHORS
index 5cf4140..501d706 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -9,3 +9,4 @@ The following is a list of much appreciated contributors:
 * Mejans
 * Sergei Maertens (sergei-maertens)
 * Abdullah Alahdal (alahdal)
+* some1ataplace
diff --git a/cookie_consent/middleware.py b/cookie_consent/middleware.py
index bf6d1c7..945d142 100644
--- a/cookie_consent/middleware.py
+++ b/cookie_consent/middleware.py
@@ -1,40 +1,61 @@
 # -*- coding: utf-8 -*-
-from django.utils.encoding import smart_str
+from typing import Optional
 
-from cookie_consent.cache import all_cookie_groups
-from cookie_consent.conf import settings
-from cookie_consent.util import get_cookie_dict_from_request, is_cookie_consent_enabled
+from .cache import all_cookie_groups
+from .conf import settings
+from .util import get_cookie_dict_from_request, is_cookie_consent_enabled
 
 
-class CleanCookiesMiddleware(object):
+def _should_delete_cookie(group_version: Optional[str]) -> bool:
+    # declined after it was accepted (and set) before
+    if group_version == settings.COOKIE_CONSENT_DECLINE:
+        return True
+
+    # if you need to opt-out instead of opt-in, then we only delete the cookie in the
+    # above scenario -> when the group is explicitly declined
+    if settings.COOKIE_CONSENT_OPT_OUT:
+        return False
+
+    # when we are opt-in and have no information whether the cookie group was accepted
+    # or declined, delete the cookie(s).
+    if group_version is None:
+        return True
+
+    return False
+
+
+class CleanCookiesMiddleware:
     """
-    Clean declined cookies or non accepted cookies if not
-    COOKIE_CONSENT_OPT_OUT set.
+    Clean declined or non-accepted cookies.
+
+    Note that this only applies if COOKIE_CONSENT_OPT_OUT is not set.
     """
 
     def __init__(self, get_response):
         self.get_response = get_response
 
+    def __call__(self, request):
+        response = self.get_response(request)
+        if is_cookie_consent_enabled(request):
+            self.process_response(request, response)
+        return response
+
     def process_response(self, request, response):
-        if not is_cookie_consent_enabled(request):
-            return response
         cookie_dic = get_cookie_dict_from_request(request)
+
+        cookies_to_delete = []
         for cookie_group in all_cookie_groups().values():
             if not cookie_group.is_deletable:
                 continue
+
             group_version = cookie_dic.get(cookie_group.varname, None)
             for cookie in cookie_group.cookie_set.all():
                 if cookie.name not in request.COOKIES:
                     continue
-                if group_version == settings.COOKIE_CONSENT_DECLINE:
-                    response.delete_cookie(
-                        smart_str(cookie.name), cookie.path, cookie.domain
-                    )
-                if (
-                    group_version < cookie.get_version()
-                    and not settings.COOKIE_CONSENT_OPT_OUT
-                ):
-                    response.delete_cookie(
-                        smart_str(cookie.name), cookie.path, cookie.domain
-                    )
+                if _should_delete_cookie(group_version):
+                    cookies_to_delete.append(cookie)
+
+        for cookie in cookies_to_delete:
+            response.delete_cookie(cookie.name, cookie.path, cookie.domain)
+
         return response
diff --git a/docs/api_middleware.rst b/docs/api_middleware.rst
index d53b471..c29d49c 100644
--- a/docs/api_middleware.rst
+++ b/docs/api_middleware.rst
@@ -5,5 +5,28 @@ Middleware
 CleanCookiesMiddleware
 ----------------------
 
+.. code-block:: python
+
+    MIDDLEWARE = [
+        "cookie_consent.middleware.CleanCookiesMiddleware",
+    ]
+
+
+This middleware will automatically delete previously accepted first party cookies when
+they are declined or not accepted/declined.
+
+If you have enabled the ``COOKIE_CONSENT_OPT_OUT`` setting, then the cookies will only
+be deleted if they are explicitly rejected.
+
+.. note:: First party cookies are created by the host domain, while third party cookies
+   are created by *other domains* than the one the user is visiting. For security
+   reasons, browsers only allow your server to set first-party cookies.
+
+   This gets even more confusing because third parties (such as analytics providers,
+   ad-services...) DO set first party cookies rather than third-party, and store/read
+   the information to then send it via another transport mechanism.
+
+**Reference**
+
 .. autoclass:: cookie_consent.middleware.CleanCookiesMiddleware
    :members:


Test patch:
diff --git a/testapp/settings.py b/testapp/settings.py
index b3ee7ff..ba4aa67 100644
--- a/testapp/settings.py
+++ b/testapp/settings.py
@@ -55,6 +55,7 @@
     "django.contrib.auth.middleware.AuthenticationMiddleware",
     "django.contrib.messages.middleware.MessageMiddleware",
     "django.middleware.clickjacking.XFrameOptionsMiddleware",
+    "cookie_consent.middleware.CleanCookiesMiddleware",
 ]
 
 # Use the default appconf settings. In tests, use @override_settings if you need
diff --git a/testapp/views.py b/testapp/views.py
index a3c2378..afd8b26 100644
--- a/testapp/views.py
+++ b/testapp/views.py
@@ -7,9 +7,16 @@
 class TestPageView(TemplateView):
     template_name = "test_page.html"
 
+    def _should_set_cookie(self) -> bool:
+        if "force" in self.request.GET:
+            return True
+
+        cookie_value = get_cookie_value_from_request(self.request, "optional")
+        return cookie_value is True
+
     def get(self, request, *args, **kwargs):
-        response = super(TestPageView, self).get(request, *args, **kwargs)
-        if get_cookie_value_from_request(request, "optional") is True:
+        response = super().get(request, *args, **kwargs)
+        if self._should_set_cookie():
             val = "optional cookie set from django"
             response.set_cookie("optional_test_cookie", val)
         return response
diff --git a/tests/test_middleware.py b/tests/test_middleware.py
new file mode 100644
index 0000000..58a80fe
--- /dev/null
+++ b/tests/test_middleware.py
@@ -0,0 +1,118 @@
+from django.test import TestCase, override_settings
+from django.test.client import RequestFactory
+from django.urls import reverse
+
+from cookie_consent.cache import delete_cache
+from cookie_consent.models import Cookie, CookieGroup
+
+factory = RequestFactory()
+
+
+@override_settings(COOKIE_CONSENT_OPT_OUT=False)
+class CleanCookiesMiddlewareTests(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        super().setUpTestData()
+
+        cls.cookie_group = CookieGroup.objects.create(
+            varname="optional",
+            name="Optional (test) cookies",
+        )
+        cls.cookie = Cookie.objects.create(
+            cookiegroup=cls.cookie_group,
+            name="optional_test_cookie",
+            domain="127.0.0.1",
+            path="/",
+        )
+
+    def setUp(self):
+        super().setUp()
+        self.addCleanup(delete_cache)
+
+    def _accept_and_set_cookie(self):
+        with self.subTest("initial setup"):
+            # ensure we start with cookies first being accepted
+            endpoint = reverse("cookie_consent_accept", kwargs={"varname": "optional"})
+
+            consent_response = self.client.post(
+                endpoint,
+                follow=True,
+                HTTP_X_REQUESTED_WITH="XMLHttpRequest",
+            )
+            self.assertEqual(consent_response.status_code, 200)
+
+            # hit the test page to set the cookie
+            self.client.get(reverse("test_page"))
+            self.assertIn("optional_test_cookie", self.client.cookies)
+
+    def assertCookieDeleted(self, name: str):
+        if name not in self.client.cookies:
+            self.fail(
+                "Cookie not present in client cookies, which is required to delete it"
+            )
+
+        # deleting a cookie is done by setting the expiry date to the past/max-age 0
+        # so that the browser effectively is instructed to delete the cookie
+        cookie = self.client.cookies[name]
+        cookie_dict = dict(cookie)
+        self.assertEqual(cookie_dict["max-age"], 0)
+        self.assertIn("1970", cookie_dict["expires"])
+        self.assertEqual(cookie.value, "")
+
+    def test_middleware_decline_previously_accepted_cookiegroup_cookies_are_deleted(
+        self,
+    ):
+        self._accept_and_set_cookie()
+
+        with self.subTest("decline prevously accepted group"):
+            url = reverse("cookie_consent_decline", kwargs={"varname": "optional"})
+
+            decline_response = self.client.post(url, follow=True)
+
+            self.assertEqual(decline_response.status_code, 200)
+
+        # fetch the test page and assert the middleware deleted the cookie
+        self.client.get(reverse("test_page"))
+
+        self.assertCookieDeleted("optional_test_cookie")
+
+    def test_middleware_no_cookie_consent_cookie_present_cookies_are_deleted(self):
+        self._accept_and_set_cookie()
+        # Delete cookie_consent cookie
+        del self.client.cookies["cookie_consent"]
+
+        # fetch the test page and assert the middleware deleted the cookie
+        self.client.get(reverse("test_page"))
+
+        # Check if cookie_consent cookie is deleted
+        self.assertCookieDeleted("optional_test_cookie")
+
+    def test_cookie_consent_disabled(self):
+        self._accept_and_set_cookie()
+
+        with override_settings(COOKIE_CONSENT_ENABLED=False):
+            self.client.get(reverse("test_page"))
+
+        cookie = self.client.cookies["optional_test_cookie"]
+        self.assertEqual(cookie.value, "optional cookie set from django")
+
+    def test_cookie_group_not_deletable(self):
+        self.cookie_group.is_deletable = False
+        self.cookie_group.save()
+        self._accept_and_set_cookie()
+
+        self.client.get(reverse("test_page"))
+
+        cookie = self.client.cookies["optional_test_cookie"]
+        self.assertEqual(cookie.value, "optional cookie set from django")
+
+    @override_settings(COOKIE_CONSENT_OPT_OUT=True)
+    def test_with_opt_out_behaviour(self):
+        # set the cookie
+        self.client.get(reverse("test_page"), {"force": "1"})
+
+        # call view to run middleware
+        self.client.get(reverse("test_page"))
+
+        cookie = self.client.cookies["optional_test_cookie"]
+        self.assertEqual(cookie.value, "optional cookie set from django")