diff --git a/CHANGELOG.md b/CHANGELOG.md index 3955961b..da952027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - `cloudsmith credential-helper install` and `uninstall` no longer rewrite a config file when they make no change to its data. Previously, the CLI always rewrote a file in a different formatting, such as the tab-indented `config.json` that Docker writes. The command also made a `.bak` copy and reported changes that it did not make. - When the API host name or the proxy host name does not exist, for example because of a typo in `--api-host`, commands now fail immediately. Previously, they retried for approximately 62 seconds without output. The CLI still retries a DNS failure that the operating system reports as temporary. - When the CLI waits before it retries a failed connection or a 5xx response, it now writes `Request failed: retrying in N second(s) ...` to stderr. Previously, it waited without output. +- When the API host returns an HTML page instead of an API response, the CLI now shows an error and exits with code 1. Examples are a proxy login page, a captive portal, or an `--api-host` that points to a website. Before, commands such as `entitlements list`, `check service`, and `metadata list` stopped with an unexpected error and sent an error report. - `cloudsmith check rates` no longer stops with `TypeError` when the API does not give a value for an interval, limit, remaining count, reset time or throttled status. The command now shows `None` for that value. ## [1.30.0] - 2026-10-22 diff --git a/cloudsmith_cli/cli/tests/commands/test_entitlements.py b/cloudsmith_cli/cli/tests/commands/test_entitlements.py index 3e61748a..8c4e82b4 100644 --- a/cloudsmith_cli/cli/tests/commands/test_entitlements.py +++ b/cloudsmith_cli/cli/tests/commands/test_entitlements.py @@ -1,9 +1,13 @@ +import httpretty +import httpretty.core import pytest from ....core.pagination import PageInfo from ...commands.entitlements import list_ as list_entitlements from ...commands.entitlements import print_entitlements +from ...commands.main import main from ...config import Options +from .conftest import FAKE_API_HOST @pytest.mark.usefixtures("set_api_key_env_var", "set_api_host_env_var") @@ -24,6 +28,32 @@ def test_entitlements_list_with_show_all(runner, organization, tmp_repository): assert "Invalid value for '--show-all'" not in result.output +@httpretty.activate(allow_net_connect=False) +def test_entitlements_list_html_response_fails_cleanly(runner, monkeypatch): + """A proxy login page instead of the API gives an error, not a crash.""" + monkeypatch.setattr( + httpretty.core.fakesock.socket, + "shutdown", + lambda self, how: None, + raising=False, + ) + httpretty.register_uri( + httpretty.GET, + "https://api.example.com/entitlements/o/r/", + body="Proxy login", + content_type="text/html", + ) + + result = runner.invoke( + main, + ["entitlements", "list", "o/r", "-k", "key", "--api-host", FAKE_API_HOST], + ) + + assert result.return_value == 1 + assert "Failed to get list of entitlements!" in result.output + assert "HTML page" in result.output + + def _make_entitlement(name): return { "name": name, diff --git a/cloudsmith_cli/core/rest.py b/cloudsmith_cli/core/rest.py index f436b8a6..226dae2f 100644 --- a/cloudsmith_cli/core/rest.py +++ b/cloudsmith_cli/core/rest.py @@ -163,10 +163,24 @@ def request( resp.encoding = resp.apparent_encoding or "utf-8" rest_resp = RestResponse(resp) - if _preload_content: - logger.debug("response body: %s", rest_resp.data) - if not 200 <= rest_resp.status <= 299: raise ApiException(http_resp=rest_resp) + _raise_for_html(rest_resp) + + if _preload_content: + logger.debug("response body: %s", rest_resp.data) + return rest_resp + + +def _raise_for_html(rest_resp: RestResponse) -> None: + """Reject an HTML page, such as a proxy login page, that has a 2xx status.""" + content_type = rest_resp.getheader("Content-Type") or "" + media_type = content_type.split(";", 1)[0].strip().lower() + if media_type == "text/html": + raise ApiException( + status=0, + reason="The API host returned an HTML page, not an API response. " + "Check the API host and your proxy settings.", + ) diff --git a/cloudsmith_cli/core/tests/test_rest.py b/cloudsmith_cli/core/tests/test_rest.py index 1d57f78a..629d4bfe 100644 --- a/cloudsmith_cli/core/tests/test_rest.py +++ b/cloudsmith_cli/core/tests/test_rest.py @@ -114,6 +114,41 @@ def test_rejects_an_invalid_timeout(self): assert raised.value.status == 0 + @httpretty.activate(allow_net_connect=False) + def test_html_page_is_not_an_api_response(self): + """A proxy login page with status 200 raises an API error with status 0.""" + initialise_api() + client = RestClient(backoff_factor=0) + url = "https://test.site" + httpretty.register_uri( + "GET", url, body="Proxy login", content_type="text/html" + ) + + with pytest.raises(ApiException) as raised: + client.request("GET", url) + + assert raised.value.status == 0 + assert "HTML page" in raised.value.reason + + @httpretty.activate(allow_net_connect=False) + def test_non_utf8_html_page_is_not_an_api_response(self): + """An HTML page that is not UTF-8 raises an API error, not a decode error.""" + initialise_api() + client = RestClient(backoff_factor=0) + url = "https://test.site" + httpretty.register_uri( + "GET", + url, + body="Connexion au proxy requise: é".encode("iso-8859-1"), + content_type="text/html; charset=iso-8859-1", + ) + + with pytest.raises(ApiException) as raised: + client.request("GET", url) + + assert raised.value.status == 0 + assert "HTML page" in raised.value.reason + class TestCreateRequestsSession: @pytest.fixture(autouse=True)