All checks were successful
Tests / Declarative: Post Actions passed: 1387
The pull request named the incident - sonar/ariadne/AZ2y0FYFKy9i4pkIpNlV - and left the reviewer to find what that key meant. Their first question is what the finding actually said: which rule, how severe, what the offending lines look like in context. An opaque key is not an answer to that. The link is derived from the incident id rather than threaded through the flow. The id already carries the project and the key, so a second path for them to travel would only be a second thing that can disagree with the first. A build-driven repair has no finding, so it gets no line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
275 lines
7.8 KiB
Python
275 lines
7.8 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
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("incident", "expected"),
|
|
[
|
|
(
|
|
"sonar/ariadne/AZ2y0FYFKy9i4pkIpNlV",
|
|
"https://quality.bstein.dev/project/issues"
|
|
"?resolved=false&id=ariadne&open=AZ2y0FYFKy9i4pkIpNlV",
|
|
),
|
|
# A build-driven repair has no finding to point at.
|
|
("ariadne/408", ""),
|
|
("sonar/ariadne", ""),
|
|
("sonar//key", ""),
|
|
("sonar/proj/ ", ""),
|
|
("", ""),
|
|
],
|
|
)
|
|
def test_the_finding_link_is_derived_from_the_incident_id(incident, expected) -> None:
|
|
assert module.issue_url(incident, "https://quality.bstein.dev/") == expected
|
|
|
|
|
|
def test_no_finding_link_without_a_configured_console() -> None:
|
|
assert module.issue_url("sonar/ariadne/AZ1", "") == ""
|