diff --git a/CHANGES b/CHANGES index fcab1798..090b3539 100644 --- a/CHANGES +++ b/CHANGES @@ -7,6 +7,9 @@ Unreleased This also applies to headers returned by callbacks. * Fixed ``urlencoded_params_matcher`` rejecting matching form data supplied as bytes or a binary file-like object. +* Fixed ``urlencoded_params_matcher`` accepting, under ``strict_match``, a + request body with repeated keys that the params dict cannot represent (for + example ``a=1&a=2`` against ``{"a": "2"}``). * Added ``responses.CallbackRequest`` so callback and matcher annotations can include the ``params`` and ``req_kwargs`` attributes added by ``responses``. * Fixed non-strict `json_params_matcher` raising `TypeError` when an actual diff --git a/responses/matchers.py b/responses/matchers.py index 15df1884..e4db9aca 100644 --- a/responses/matchers.py +++ b/responses/matchers.py @@ -94,11 +94,28 @@ def match(request: PreparedRequest) -> Tuple[bool, str]: if not params and request_body: valid = False + # `dict(parse_qsl(...))` above collapses repeated keys (last value wins), + # so a strict match would otherwise accept a body carrying data the dict + # params cannot represent (e.g. body "a=1&a=2" against {"a": "2"}). + repeated_keys = bool( + strict_match + and request_body + and len(parse_qsl(request_body, keep_blank_values=allow_blank)) + != len(qsl_body) + ) + if repeated_keys: + valid = False + if not valid: reason = ( f"request.body doesn't match: {qsl_body} doesn't match {match_params}" ) - if strict_match: + if repeated_keys: + reason += ( + "\nNote: the request body has repeated keys that a strict " + "dict match cannot represent." + ) + elif strict_match: reason += ( "\nNote: You're using strict parameter check. " "To try a partial match, use strict_match=False" diff --git a/responses/tests/test_matchers.py b/responses/tests/test_matchers.py index 4f67810c..18770a3f 100644 --- a/responses/tests/test_matchers.py +++ b/responses/tests/test_matchers.py @@ -447,6 +447,33 @@ def run(): assert_reset() +def test_urlencoded_params_matcher_strict_repeated_keys(): + """Strict matching must reject a body with repeated keys the params dict + cannot represent (e.g. body "a=1&a=2" against {"a": "2"}).""" + + @responses.activate + def run(): + responses.add( + method=responses.POST, + url="http://example.com/", + body="body1", + match=[matchers.urlencoded_params_matcher({"a": "2"})], + ) + + # data as a list of pairs encodes to "a=1&a=2"; the stray a=1 must fail + with pytest.raises(ConnectionError) as excinfo: + requests.request( + "POST", + "http://example.com/", + headers={"Content-Type": "x-www-form-urlencoded"}, + data=[("a", "1"), ("a", "2")], + ) + assert "repeated keys" in str(excinfo.value) + + run() + assert_reset() + + def test_query_params_numbers(): @responses.activate def run():