diff --git a/CHANGES b/CHANGES index c3823d65..f010bf19 100644 --- a/CHANGES +++ b/CHANGES @@ -1,6 +1,8 @@ Unreleased ---------- +* Fixed matching and passthrough registration for Unicode hostnames with ports + or userinfo, preserving credentials and ports during hostname encoding. * 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 diff --git a/responses/__init__.py b/responses/__init__.py index 5b1546a3..3a2442af 100644 --- a/responses/__init__.py +++ b/responses/__init__.py @@ -189,12 +189,15 @@ def _clean_unicode(url: str) -> str: urllist = list(urlsplit(url)) netloc = urllist[1] if _has_unicode(netloc): - domains = netloc.split(".") + # Encode only the hostname, keeping userinfo and the port out of punycode. + userinfo, at, hostport = netloc.rpartition("@") + host, colon, port = hostport.partition(":") + domains = host.split(".") for i, d in enumerate(domains): if _has_unicode(d): d = "xn--" + d.encode("punycode").decode("ascii") domains[i] = d - urllist[1] = ".".join(domains) + urllist[1] = userinfo + at + ".".join(domains) + colon + port url = urlunsplit(urllist) # Clean up path/query/params, which use url-encoding to handle unicode chars diff --git a/responses/tests/test_responses.py b/responses/tests/test_responses.py index bc8f1e04..fac069b8 100644 --- a/responses/tests/test_responses.py +++ b/responses/tests/test_responses.py @@ -1387,6 +1387,77 @@ def run(): assert_reset() +@pytest.mark.parametrize( + "url", + [ + "https://example.рф:8443/path", + "https://пример.рф:8443/path", + "https://user:pass@пример.рф/path", + "https://user:pass@пример.рф:8443/path", + "https://üser:päss@example.com:8443/path", + "https://üser:päss@пример.рф:8443/path", + "https://пример.рф:8443/путь?key=значение", + "https://пример.рф/path", + "https://user:pass@example.com:8443/path", + "https://üser:päss@[::1]:8443/path", + ], +) +def test_unicode_url_authority(url): # type: ignore[misc] + with responses.RequestsMock() as mock: + mock.get(url, body="matched") + + response = requests.get(url) + + assert_response(response, "matched") + assert len(mock.calls) == 1 + + +@pytest.mark.parametrize( + ("prefix", "expected"), + [ + ("https://пример.рф:8443", "https://xn--e1afmkfd.xn--p1ai:8443"), + ( + "https://user:pass@пример.рф:8443/path", + "https://user:pass@xn--e1afmkfd.xn--p1ai:8443/path", + ), + ( + "https://üser:päss@example.com:8443/path", + "https://%C3%BCser:p%C3%A4ss@example.com:8443/path", + ), + ], +) +def test_unicode_authority_passthru(prefix, expected): # type: ignore[misc] + real_send = Mock(return_value=requests.Response()) + with responses.RequestsMock(real_adapter_send=real_send) as mock: + mock.add_passthru(prefix) + assert mock.passthru_prefixes == (expected,) + + requests.get(prefix + "/child") + + real_send.assert_called_once() + assert real_send.call_args[0][1].url == expected + "/child" + + +@pytest.mark.parametrize( + "url", + [ + "https://user:pass@пример.рф:8444/path", + "https://other:pass@пример.рф:8443/path", + "https://user:other@пример.рф:8443/path", + "https://user:pass@пример.рф:8443/other", + ], +) +def test_unicode_authority_keeps_url_components_significant(url): # type: ignore[misc] + response = responses.Response( + responses.GET, "https://user:pass@пример.рф:8443/path" + ) + + matched, reason = response.matches(requests.Request("GET", url).prepare()) + + assert not matched + assert reason == "URL does not match" + + def test_handles_unicode_body(): url = "http://example.com/test"