Repository: falconry/falcon

Issue:
Response cookies have an inconsistent same_site parameter
`Response.unset_cookie` has a `samesite` argument, and `Response.set_cookie` has a `same_site` argument, which does not match at all.


Discussion:
Agreed, this is inconsistent, well spotted.
We cannot just rename abruptly though, we will have to introduce the preferred name to one of the methods, and deprecate the other one.
We'll need to pick the preferred form from one of the below alternatives.

`same_site`, as in [`Response.set_cookie()`](https://falcon.readthedocs.io/en/stable/api/request_and_response_wsgi.html#falcon.Response.set_cookie):
* introduced earlier, and is presumably used more often, so more users are used to this form
* other parameters in this method use underscores as well: `max_age`, `http_only`, etc
* [falcon.testing.Cookie](https://falcon.readthedocs.io/en/stable/api/testing.html#falcon.testing.Cookie) also calls it [`same_site`](https://falcon.readthedocs.io/en/stable/api/testing.html#falcon.testing.Cookie.same_site) 

`samesite`, as in [`Response.unset_cookie()`](https://falcon.readthedocs.io/en/stable/api/request_and_response_wsgi.html#falcon.Response.unset_cookie):
* The stdlib's [Morsel](https://docs.python.org/3/library/http.cookies.html#http.cookies.Morsel) calls it just [`samesite`](https://docs.python.org/3/library/http.cookies.html#http.cookies.Morsel.samesite); OTOH, these are more like dict keys than attributes

Based on this, I think we should rename `samesite` ➡ `same_site` in  [`Response.unset_cookie()`](https://falcon.readthedocs.io/en/stable/api/request_and_response_wsgi.html#falcon.Response.unset_cookie), and leave the `samesite` parameter for compatibility which triggers a deprecation warning if set to anything but `None`. Thoughts @CaselIT?
Makes sense to me. I wonder how we ended up like this :(
Simply missed it I think...

> Errare humanum est

...As they used to say.



Patch:
diff --git a/docs/_newsfragments/2453.newandimproved.rst b/docs/_newsfragments/2453.newandimproved.rst
new file mode 100644
index 000000000..4dba18016
--- /dev/null
+++ b/docs/_newsfragments/2453.newandimproved.rst
@@ -0,0 +1,4 @@
+The :meth:`~falcon.Response.unset_cookie` method now accepts a ``same_site``
+parameter (with underscore) for consistency with :meth:`~falcon.Response.set_cookie`.
+The previous ``samesite`` parameter (without underscore) is now deprecated and will
+emit a deprecation warning when used.
\ No newline at end of file
diff --git a/docs/api/cookies.rst b/docs/api/cookies.rst
index 1b0a3e50e..ec7e69297 100644
--- a/docs/api/cookies.rst
+++ b/docs/api/cookies.rst
@@ -160,7 +160,7 @@ default, although this may change in a future release.
 
 When unsetting a cookie, :meth:`~falcon.Response.unset_cookie`,
 the default `SameSite` setting of the unset cookie is ``'Lax'``, but can be changed
-by setting the 'samesite' kwarg.
+by setting the 'same_site' kwarg.
 
 The Partitioned Attribute
 ~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/falcon/response.py b/falcon/response.py
index 209f6f0d0..a5e27412b 100644
--- a/falcon/response.py
+++ b/falcon/response.py
@@ -35,6 +35,7 @@
     TYPE_CHECKING,
     Union,
 )
+import warnings
 
 from falcon._typing import _UNSET
 from falcon._typing import RangeSetHeader
@@ -56,6 +57,7 @@
 from falcon.util import http_status_to_code
 from falcon.util import structures
 from falcon.util.deprecation import AttributeRemovedError
+from falcon.util.deprecation import DeprecatedWarning
 from falcon.util.uri import encode_check_escaped as uri_encode
 from falcon.util.uri import encode_value_check_escaped as uri_encode_value
 
@@ -563,9 +565,10 @@ def set_cookie(  # noqa: C901
     def unset_cookie(
         self,
         name: str,
-        samesite: str = 'Lax',
         domain: Optional[str] = None,
         path: Optional[str] = None,
+        same_site: str = 'Lax',
+        samesite: Optional[str] = None,
     ) -> None:
         """Unset a cookie in the response.
 
@@ -588,11 +591,6 @@ def unset_cookie(
             name (str): Cookie name
 
         Keyword Args:
-            samesite (str): Allows to override the default 'Lax' same_site
-                    setting for the unset cookie.
-
-                    .. versionadded:: 4.0
-
             domain (str): Restricts the cookie to a specific domain and
                     any subdomains of that domain. By default, the user
                     agent will return the cookie only to the origin server.
@@ -619,6 +617,16 @@ def unset_cookie(
 
                 (See also: RFC 6265, Section 4.1.2.4)
 
+            same_site (str): Allows to override the default 'Lax' same_site
+                    setting for the unset cookie.
+
+                    .. versionadded:: 4.1
+
+            samesite (str): Deprecated. Use `same_site` instead.
+
+                    .. deprecated:: 4.1
+                       Please use the ``same_site`` parameter instead.
+
         .. _Same-Site warnings:
             https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite#Fixing_common_warnings
         """  # noqa: E501
@@ -634,9 +642,22 @@ def unset_cookie(
         # thus removing it from future request objects.
         self._cookies[name]['expires'] = -1
 
+        # Handle deprecated samesite parameter
+        if samesite is not None:
+            warnings.warn(
+                'The "samesite" parameter is deprecated. '
+                'Please use "same_site" instead.',
+                DeprecatedWarning,
+                stacklevel=2,
+            )
+            # Use the deprecated parameter value if same_site was not explicitly set
+            same_site_value = samesite
+        else:
+            same_site_value = same_site
+
         # NOTE(CaselIT): Set SameSite to Lax to avoid setting invalid cookies.
         # See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite#Fixing_common_warnings  # noqa: E501
-        self._cookies[name]['samesite'] = samesite
+        self._cookies[name]['samesite'] = same_site_value
 
         if domain:
             self._cookies[name]['domain'] = domain


Test patch:
diff --git a/tests/test_cookies.py b/tests/test_cookies.py
index 657083689..009916130 100644
--- a/tests/test_cookies.py
+++ b/tests/test_cookies.py
@@ -3,12 +3,14 @@
 from datetime import timezone
 from http import cookies as http_cookies
 import re
+import warnings
 
 import pytest
 
 import falcon
 import falcon.testing as testing
 from falcon.util import http_date_to_dt
+from falcon.util.deprecation import DeprecatedWarning
 from falcon.util.misc import _utcnow
 
 UNICODE_TEST_STRING = 'Unicode_\xc3\xa6\xc3\xb8'
@@ -76,20 +78,20 @@ def on_get(self, req, resp):
         resp.unset_cookie('baz', domain='www.example.com')
         resp.unset_cookie('foobar', path='/foo', domain='www.example.com')
         resp.unset_cookie(
-            'barfoo', samesite='none', path='/foo', domain='www.example.com'
+            'barfoo', same_site='none', path='/foo', domain='www.example.com'
         )
 
 
 class CookieUnsetSameSite:
     def on_get(self, req, resp):
         # change lax to strict
-        resp.unset_cookie('foo', samesite='Strict')
+        resp.unset_cookie('foo', same_site='Strict')
         # change strict to lax
         resp.unset_cookie('bar')
         # change none to ''
-        resp.unset_cookie('baz', samesite='')
+        resp.unset_cookie('baz', same_site='')
         # change '' to none
-        resp.unset_cookie('barz', samesite='None')
+        resp.unset_cookie('barz', same_site='None')
 
 
 @pytest.fixture
@@ -190,11 +192,11 @@ def test_unset_cookies(client):
     result = client.simulate_get('/unset-cookie')
     assert len(result.cookies) == 5
 
-    def test(cookie, path, domain, samesite='Lax'):
+    def test(cookie, path, domain, same_site='Lax'):
         assert cookie.value == ''  # An unset cookie has an empty value
         assert cookie.domain == domain
         assert cookie.path == path
-        assert cookie.same_site == samesite
+        assert cookie.same_site == same_site
         assert cookie.expires < _utcnow()
 
     test(result.cookies['foo'], path=None, domain=None)
@@ -202,7 +204,10 @@ def test(cookie, path, domain, samesite='Lax'):
     test(result.cookies['baz'], path=None, domain='www.example.com')
     test(result.cookies['foobar'], path='/foo', domain='www.example.com')
     test(
-        result.cookies['barfoo'], path='/foo', domain='www.example.com', samesite='none'
+        result.cookies['barfoo'],
+        path='/foo',
+        domain='www.example.com',
+        same_site='none',
     )
 
 
@@ -230,20 +235,20 @@ def test_set(cookie, value, samesite=None):
     result_unset = client.simulate_get('/unset-cookie-same-site')
     assert len(result_unset.cookies) == 4
 
-    def test_unset(cookie, samesite='Lax'):
+    def test_unset(cookie, same_site='Lax'):
         assert cookie.value == ''  # An unset cookie has an empty value
-        assert cookie.same_site == samesite
+        assert cookie.same_site == same_site
         assert cookie.expires < _utcnow()
 
-    test_unset(result_unset.cookies['foo'], samesite='Strict')
-    # default: bar is unset with no samesite param, so should go to Lax
-    test_unset(result_unset.cookies['bar'], samesite='Lax')
+    test_unset(result_unset.cookies['foo'], same_site='Strict')
+    # default: bar is unset with no same_site param, so should go to Lax
+    test_unset(result_unset.cookies['bar'], same_site='Lax')
     test_unset(result_unset.cookies['bar'])  # default in test_unset
 
     test_unset(
-        result_unset.cookies['baz'], samesite=None
-    )  # baz gets unset to samesite = ''
-    test_unset(result_unset.cookies['barz'], samesite='None')
+        result_unset.cookies['baz'], same_site=None
+    )  # baz gets unset to same_site = ''
+    test_unset(result_unset.cookies['barz'], same_site='None')
     # test for false
     assert result_unset.cookies['baz'].same_site != 'Strict'
     assert result_unset.cookies['foo'].same_site != 'Lax'
@@ -530,3 +535,36 @@ def test_partitioned_value(client):
 
     cookie = result.cookies['baz']
     assert not cookie.partitioned
+
+
+def test_unset_cookie_deprecation_warning():
+    resp = falcon.Response()
+
+    # Test that using the deprecated 'samesite' parameter raises a warning
+    with pytest.warns(
+        DeprecatedWarning, match='The "samesite" parameter is deprecated'
+    ):
+        resp.unset_cookie('test', samesite='Strict')
+
+    # Verify the cookie was still set correctly with the deprecated parameter
+    morsel = resp._cookies['test']
+    assert morsel['samesite'] == 'Strict'
+
+    # Test that using the new 'same_site' parameter doesn't raise a warning
+    with warnings.catch_warnings():
+        warnings.simplefilter('error')
+        resp.unset_cookie('test2', same_site='Lax')
+
+    # Verify the cookie was set correctly with the new parameter
+    morsel2 = resp._cookies['test2']
+    assert morsel2['samesite'] == 'Lax'
+
+    # Test that when both parameters are provided, deprecated one is used with warning
+    with pytest.warns(
+        DeprecatedWarning, match='The "samesite" parameter is deprecated'
+    ):
+        resp.unset_cookie('test3', samesite='None', same_site='Strict')
+
+    # Verify the deprecated parameter value was used
+    morsel3 = resp._cookies['test3']
+    assert morsel3['samesite'] == 'None'