251 lines
7.0 KiB
Python
251 lines
7.0 KiB
Python
|
|
"""Tests for reading open SonarQube findings."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import base64
|
||
|
|
|
||
|
|
import httpx
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from ariadne.services import hermes_sonar_client as module
|
||
|
|
|
||
|
|
|
||
|
|
CFG = {
|
||
|
|
"sonar_base_url": "http://sonarqube.quality.svc.cluster.local:9000/",
|
||
|
|
"sonar_token": "squ_token",
|
||
|
|
"timeout_seconds": 5,
|
||
|
|
}
|
||
|
|
|
||
|
|
ISSUE = {
|
||
|
|
"key": "AZ-1",
|
||
|
|
"component": "ariadne:ariadne/services/hermes_code_defects.py",
|
||
|
|
"line": 160,
|
||
|
|
"rule": "python:S1172",
|
||
|
|
"severity": "MAJOR",
|
||
|
|
"type": "CODE_SMELL",
|
||
|
|
"effort": "5min",
|
||
|
|
"message": 'Remove the unused function parameter "cfg".',
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
class _Response:
|
||
|
|
def __init__(self, status_code=200, payload=None, raises=False):
|
||
|
|
self.status_code = status_code
|
||
|
|
self._payload = payload if payload is not None else {"issues": [ISSUE]}
|
||
|
|
self._raises = raises
|
||
|
|
|
||
|
|
def json(self):
|
||
|
|
if self._raises:
|
||
|
|
raise ValueError("not json")
|
||
|
|
return self._payload
|
||
|
|
|
||
|
|
|
||
|
|
class _Client:
|
||
|
|
"""Stands in for httpx.Client, recording the single request made."""
|
||
|
|
|
||
|
|
last_url = ""
|
||
|
|
last_params: dict = {}
|
||
|
|
last_headers: dict = {}
|
||
|
|
response = _Response()
|
||
|
|
error: Exception | None = None
|
||
|
|
|
||
|
|
def __init__(self, *_args, **_kwargs):
|
||
|
|
pass
|
||
|
|
|
||
|
|
def __enter__(self):
|
||
|
|
return self
|
||
|
|
|
||
|
|
def __exit__(self, *_args):
|
||
|
|
return False
|
||
|
|
|
||
|
|
def get(self, url, headers=None, params=None):
|
||
|
|
_Client.last_url = url
|
||
|
|
_Client.last_params = params or {}
|
||
|
|
_Client.last_headers = headers or {}
|
||
|
|
if _Client.error is not None:
|
||
|
|
raise _Client.error
|
||
|
|
return _Client.response
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(autouse=True)
|
||
|
|
def _stub_client(monkeypatch):
|
||
|
|
_Client.response = _Response()
|
||
|
|
_Client.error = None
|
||
|
|
monkeypatch.setattr(module.httpx, "Client", _Client)
|
||
|
|
return _Client
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_project_returns_normalized_findings() -> None:
|
||
|
|
issues, error = module.fetch_issues(CFG, "ariadne")
|
||
|
|
|
||
|
|
assert error is None
|
||
|
|
assert issues == [
|
||
|
|
{
|
||
|
|
"key": "AZ-1",
|
||
|
|
"path": "ariadne/services/hermes_code_defects.py",
|
||
|
|
"line": 160,
|
||
|
|
"rule": "python:S1172",
|
||
|
|
"severity": "MAJOR",
|
||
|
|
"type": "CODE_SMELL",
|
||
|
|
"effort": "5min",
|
||
|
|
"message": 'Remove the unused function parameter "cfg".',
|
||
|
|
}
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
def test_the_token_is_sent_as_basic_auth_with_an_empty_password() -> None:
|
||
|
|
"""SonarQube accepts a user token only in that shape."""
|
||
|
|
|
||
|
|
module.fetch_issues(CFG, "ariadne")
|
||
|
|
|
||
|
|
expected = base64.b64encode(b"squ_token:").decode("ascii")
|
||
|
|
assert _Client.last_headers["Authorization"] == f"Basic {expected}"
|
||
|
|
|
||
|
|
|
||
|
|
def test_only_unresolved_findings_nobody_has_ruled_on_are_requested() -> None:
|
||
|
|
"""A won't-fix carries a human judgement; reopening it wastes tokens."""
|
||
|
|
|
||
|
|
module.fetch_issues(CFG, "ariadne")
|
||
|
|
|
||
|
|
assert _Client.last_params["resolved"] == "false"
|
||
|
|
assert _Client.last_params["statuses"] == "OPEN,CONFIRMED,REOPENED"
|
||
|
|
assert _Client.last_params["componentKeys"] == "ariadne"
|
||
|
|
|
||
|
|
|
||
|
|
def test_security_hotspots_are_never_requested() -> None:
|
||
|
|
"""Resolving a hotspot is a review decision, not a code change."""
|
||
|
|
|
||
|
|
module.fetch_issues(CFG, "ariadne")
|
||
|
|
|
||
|
|
assert "HOTSPOT" not in _Client.last_params["types"]
|
||
|
|
assert "SECURITY_HOTSPOT" not in _Client.last_params["types"]
|
||
|
|
assert set(_Client.last_params["types"].split(",")) == set(module.ALL_ISSUE_TYPES)
|
||
|
|
|
||
|
|
|
||
|
|
def test_the_double_slash_in_the_url_is_avoided() -> None:
|
||
|
|
module.fetch_issues(CFG, "ariadne")
|
||
|
|
|
||
|
|
assert _Client.last_url == (
|
||
|
|
"http://sonarqube.quality.svc.cluster.local:9000/api/issues/search"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_configured_types_and_severities_narrow_the_query() -> None:
|
||
|
|
module.fetch_issues(
|
||
|
|
{**CFG, "sonar_types": ["code_smell"], "sonar_severities": ["critical", "major"]},
|
||
|
|
"ariadne",
|
||
|
|
)
|
||
|
|
|
||
|
|
assert _Client.last_params["types"] == "CODE_SMELL"
|
||
|
|
assert _Client.last_params["severities"] == "CRITICAL,MAJOR"
|
||
|
|
|
||
|
|
|
||
|
|
def test_unrecognised_types_fall_back_to_every_type() -> None:
|
||
|
|
module.fetch_issues({**CFG, "sonar_types": ["nonsense"]}, "ariadne")
|
||
|
|
|
||
|
|
assert set(_Client.last_params["types"].split(",")) == set(module.ALL_ISSUE_TYPES)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
("size", "expected"),
|
||
|
|
[(None, "100"), (5, "5"), (9000, "500"), ("junk", "100")],
|
||
|
|
)
|
||
|
|
def test_the_page_size_is_clamped(size, expected) -> None:
|
||
|
|
module.fetch_issues({**CFG, "sonar_page_size": size}, "ariadne")
|
||
|
|
|
||
|
|
assert _Client.last_params["ps"] == expected
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_finding_with_no_line_is_dropped() -> None:
|
||
|
|
"""Nothing can be anchored to a file-level finding, so it is not offered."""
|
||
|
|
|
||
|
|
_Client.response = _Response(payload={"issues": [{**ISSUE, "line": None}]})
|
||
|
|
|
||
|
|
issues, error = module.fetch_issues(CFG, "ariadne")
|
||
|
|
|
||
|
|
assert error is None
|
||
|
|
assert issues == []
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
"issue",
|
||
|
|
[
|
||
|
|
{**ISSUE, "key": ""},
|
||
|
|
{**ISSUE, "component": "ariadne"},
|
||
|
|
{**ISSUE, "line": True},
|
||
|
|
"not-a-dict",
|
||
|
|
],
|
||
|
|
)
|
||
|
|
def test_an_unusable_finding_is_dropped(issue) -> None:
|
||
|
|
_Client.response = _Response(payload={"issues": [issue]})
|
||
|
|
|
||
|
|
assert module.fetch_issues(CFG, "ariadne")[0] == []
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_long_message_is_clipped() -> None:
|
||
|
|
_Client.response = _Response(payload={"issues": [{**ISSUE, "message": "x " * 500}]})
|
||
|
|
|
||
|
|
issues, _ = module.fetch_issues(CFG, "ariadne")
|
||
|
|
|
||
|
|
assert len(issues[0]["message"]) <= 400
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_missing_base_url_makes_no_request() -> None:
|
||
|
|
issues, error = module.fetch_issues({**CFG, "sonar_base_url": ""}, "ariadne")
|
||
|
|
|
||
|
|
assert issues == []
|
||
|
|
assert error == "sonar base url is empty"
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_missing_token_makes_no_request() -> None:
|
||
|
|
issues, error = module.fetch_issues({**CFG, "sonar_token": ""}, "ariadne")
|
||
|
|
|
||
|
|
assert issues == []
|
||
|
|
assert error == "sonar token is empty"
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_transport_failure_is_reported_not_raised() -> None:
|
||
|
|
_Client.error = httpx.ConnectError("refused")
|
||
|
|
|
||
|
|
issues, error = module.fetch_issues(CFG, "ariadne")
|
||
|
|
|
||
|
|
assert issues == []
|
||
|
|
assert "sonar fetch failed" in error
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_non_200_is_reported() -> None:
|
||
|
|
_Client.response = _Response(status_code=503)
|
||
|
|
|
||
|
|
issues, error = module.fetch_issues(CFG, "ariadne")
|
||
|
|
|
||
|
|
assert issues == []
|
||
|
|
assert error == "sonar fetch http 503"
|
||
|
|
|
||
|
|
|
||
|
|
def test_an_unparseable_body_is_reported() -> None:
|
||
|
|
_Client.response = _Response(raises=True)
|
||
|
|
|
||
|
|
issues, error = module.fetch_issues(CFG, "ariadne")
|
||
|
|
|
||
|
|
assert issues == []
|
||
|
|
assert "sonar response not json" in error
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_payload_that_is_not_a_dict_yields_nothing() -> None:
|
||
|
|
_Client.response = _Response(payload=["nope"])
|
||
|
|
|
||
|
|
assert module.fetch_issues(CFG, "ariadne") == ([], None)
|
||
|
|
|
||
|
|
|
||
|
|
def test_the_timeout_falls_back_when_unparseable() -> None:
|
||
|
|
assert module._timeout({"timeout_seconds": "soon"}) == module._DEFAULT_TIMEOUT_SECONDS
|
||
|
|
assert module._timeout({}) == module._DEFAULT_TIMEOUT_SECONDS
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
("component", "expected"),
|
||
|
|
[("proj:src/a.py", "src/a.py"), ("proj", ""), (None, ""), ("proj: a.py ", "a.py")],
|
||
|
|
)
|
||
|
|
def test_the_component_key_yields_the_repository_path(component, expected) -> None:
|
||
|
|
assert module.component_path(component) == expected
|