From b0e4398f2976a8c72eef628665a443ff4e9a6b1f Mon Sep 17 00:00:00 2001 From: rusetiq Date: Mon, 5 Oct 2026 22:39:47 +0400 Subject: [PATCH] Preserve blank values in fragment identifier matching --- CHANGES | 2 ++ responses/matchers.py | 6 +++-- responses/tests/test_matchers.py | 38 ++++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/CHANGES b/CHANGES index fcab1798..c3823d65 100644 --- a/CHANGES +++ b/CHANGES @@ -1,6 +1,8 @@ Unreleased ---------- +* Fixed ``fragment_identifier_matcher`` ignoring blank-valued fragment + parameters, which allowed missing or different parameters to match. * Added Python 3.14 support (classifiers, tox envlist, and CI test matrix). See #810 * Match response ``Content-Type`` header names case-insensitively so lowercase headers do not produce duplicate content types or incorrect response decoding. diff --git a/responses/matchers.py b/responses/matchers.py index 15df1884..893b7f25 100644 --- a/responses/matchers.py +++ b/responses/matchers.py @@ -180,8 +180,10 @@ def match(request: PreparedRequest) -> Tuple[bool, str]: if identifier: if "=" in identifier: # Query-string-style fragment: compare order-insensitively. - url_fragment_qsl = sorted(parse_qsl(url_fragment)) # type: ignore[type-var] - identifier_qsl = sorted(parse_qsl(identifier)) + url_fragment_qsl = sorted( + parse_qsl(url_fragment, keep_blank_values=True) # type: ignore[type-var] + ) + identifier_qsl = sorted(parse_qsl(identifier, keep_blank_values=True)) valid = identifier_qsl == url_fragment_qsl else: # Opaque fragment (e.g. "/users/5"): parse_qsl() yields no diff --git a/responses/tests/test_matchers.py b/responses/tests/test_matchers.py index 4f67810c..77fdee0a 100644 --- a/responses/tests/test_matchers.py +++ b/responses/tests/test_matchers.py @@ -1033,6 +1033,44 @@ def run(): assert_reset() +@pytest.mark.parametrize( + "expected, actual, matches", + [ + ("flag=", "flag=", True), + ("flag=", "other=", False), + ("flag=", "", False), + ("flag=&value=1", "value=1&flag=", True), + ("flag=&value=1", "value=1", False), + ("flag=&value=1", "flag=present&value=1", False), + ("flag=&flag=1", "flag=1&flag=", True), + ("flag=&flag=1", "flag=1", False), + ], +) +def test_fragment_identifier_matcher_blank_values( + expected: str, actual: str, matches: bool +) -> None: + request = requests.Request("GET", f"http://example.com/#{actual}").prepare() + valid, reason = matchers.fragment_identifier_matcher(expected)(request) + assert valid is matches + if matches: + assert reason == "" + else: + assert "URL fragment identifier is different" in reason + + +@responses.activate +def test_fragment_identifier_matcher_blank_values_in_requests() -> None: + responses.add( + responses.GET, + "http://example.com/", + match=[matchers.fragment_identifier_matcher("flag=&value=1")], + body=b"test", + ) + with pytest.raises(ConnectionError, match="URL fragment identifier is different"): + requests.get("http://example.com/#value=1") + assert_response(requests.get("http://example.com/#value=1&flag="), "test") + + def test_fragment_identifier_matcher_opaque(): """Opaque (non key=value) fragments must compare verbatim.