diff --git a/tests/unit/marketplace/test_marketplace_client.py b/tests/unit/marketplace/test_marketplace_client.py index d3e576de5..443d97f14 100644 --- a/tests/unit/marketplace/test_marketplace_client.py +++ b/tests/unit/marketplace/test_marketplace_client.py @@ -1,8 +1,10 @@ """Tests for marketplace client -- HTTP mock, caching, TTL, auth, auto-detection, proxy.""" import json +import re import time from unittest.mock import MagicMock, patch +from urllib.parse import urlparse import pytest @@ -11,6 +13,22 @@ from apm_cli.marketplace.models import MarketplaceSource +def _quoted_hosts(text: str) -> set[str]: + """Extract host tokens from `Host ''` patterns in error text. + + Each token is normalised through ``urllib.parse.urlparse`` so callers + compare on parsed hostnames (set equality), not raw substrings -- which + is what CodeQL's ``py/incomplete-url-substring-sanitization`` rule + requires (see ``.github/instructions/tests.instructions.md``). + """ + hosts: set[str] = set() + for m in re.finditer(r"Host '([^']+)'", text, re.IGNORECASE): + parsed = urlparse(f"https://{m.group(1)}") + if parsed.hostname: + hosts.add(parsed.hostname) + return hosts + + @pytest.fixture(autouse=True) def _isolate_cache(tmp_path, monkeypatch): """Point cache and config to temp directories.""" @@ -494,7 +512,7 @@ def test_generic_host_rejected_before_request(self): # No HTTP request should have been issued (no credential leakage). mock_get.assert_not_called() - assert "gitlab.com" in str(excinfo.value) + assert _quoted_hosts(str(excinfo.value)) == {"gitlab.com"} assert "not a supported marketplace source" in str(excinfo.value) def test_github_host_passes_guard(self): diff --git a/tests/unit/marketplace/test_marketplace_commands.py b/tests/unit/marketplace/test_marketplace_commands.py index 440552867..f23e76955 100644 --- a/tests/unit/marketplace/test_marketplace_commands.py +++ b/tests/unit/marketplace/test_marketplace_commands.py @@ -1,7 +1,9 @@ """Tests for marketplace CLI commands using CliRunner.""" import json # noqa: F401 +import re from unittest.mock import MagicMock, patch # noqa: F401 +from urllib.parse import urlparse import pytest from click.testing import CliRunner @@ -13,6 +15,22 @@ ) +def _quoted_hosts(text: str) -> set[str]: + """Extract host tokens from `Host ''` patterns in error text. + + Each token is normalised through ``urllib.parse.urlparse`` so callers + compare on parsed hostnames (set equality), not raw substrings -- which + is what CodeQL's ``py/incomplete-url-substring-sanitization`` rule + requires (see ``.github/instructions/tests.instructions.md``). + """ + hosts: set[str] = set() + for m in re.finditer(r"Host '([^']+)'", text, re.IGNORECASE): + parsed = urlparse(f"https://{m.group(1)}") + if parsed.hostname: + hosts.add(parsed.hostname) + return hosts + + @pytest.fixture def runner(): return CliRunner() @@ -297,7 +315,7 @@ def test_add_rejects_non_github_host_with_actionable_error(self, runner): marketplace, ["add", "https://gitlab.com/acme/team/plugin-marketplace"] ) assert result.exit_code != 0 - assert "gitlab.com" in result.output + assert _quoted_hosts(result.output) == {"gitlab.com"} assert "not supported" in result.output.lower() def test_add_rejects_non_github_host_shorthand(self, runner): @@ -305,7 +323,7 @@ def test_add_rejects_non_github_host_shorthand(self, runner): result = runner.invoke(marketplace, ["add", "gitlab.com/acme/team/plugin-marketplace"]) assert result.exit_code != 0 - assert "gitlab.com" in result.output + assert _quoted_hosts(result.output) == {"gitlab.com"} assert "not supported" in result.output.lower() def test_add_rejects_http_url(self, runner): @@ -399,7 +417,7 @@ def test_untrusted_host_error_has_action_in_first_sentence(self, runner): # leak", etc.) must NOT appear in the default error path. first_line = next((line for line in result.output.splitlines() if line.strip()), "").lower() assert "not supported" in first_line - assert "gitlab.com" in first_line + assert _quoted_hosts(first_line) == {"gitlab.com"} assert "credential" not in result.output.lower() assert "leak" not in result.output.lower()