from __future__ import annotations from datetime import UTC, datetime, timedelta from typing import Any from unittest.mock import MagicMock import pytest from github import GithubException, RateLimitExceededException from vuln_scan.github.base_client import ( AuthConfig, GitHubBaseClient, GitHubGraphQLError, ) # -------------------------------------------------------- # Fakes # -------------------------------------------------------- class _FakeAuth: class AppAuth: def __init__(self, *, app_id: int, private_key: str) -> None: self.app_id = app_id self.private_key = private_key class AppInstallationAuth: def __init__(self, *, app_auth: object, installation_id: int) -> None: self.app_auth = app_auth self.installation_id = installation_id class Token: def __init__(self, value: str) -> None: self.value = value class _LockThatSeedsGithub: def __init__(self, auth: AuthConfig, seeded: object) -> None: self._auth = auth self._seeded = seeded def __enter__(self) -> None: self._auth._github = self._seeded # type: ignore[assignment] def __exit__(self, exc_type, exc, tb) -> bool: return False class _FakeRequester: def __init__( self, *, result: dict[str, Any] | None = None, headers: dict[str, str] | None = None, should_raise: Exception | None = None, ) -> None: self._result = result self._headers = headers or {} self._should_raise = should_raise self.calls: list[tuple[str, str, dict[str, Any]]] = [] def requestJsonAndCheck( self, method: str, url: str, input: dict[str, Any], ) -> tuple[dict[str, str], dict[str, Any] | None]: self.calls.append((method, url, input)) if self._should_raise is not None: raise self._should_raise return self._headers, self._result class _FakeGithub: def __init__(self, requester: _FakeRequester) -> None: self.requester = requester class _HeadersException: def __init__(self, headers: dict[str, str] | None) -> None: self.headers = headers # -------------------------------------------------------- # Tiny local helper # -------------------------------------------------------- def _client_without_post_init(monkeypatch: pytest.MonkeyPatch) -> GitHubBaseClient: monkeypatch.setattr(GitHubBaseClient, "__post_init__", lambda self: None) return GitHubBaseClient(auth_config=AuthConfig(token="t")) # -------------------------------------------------------- # AuthConfig.build_github tests # -------------------------------------------------------- def test_build_github_uses_github_app_installation_auth(monkeypatch: pytest.MonkeyPatch) -> None: captured: dict[str, Any] = {} def fake_github(*, auth: object) -> object: captured["auth"] = auth return object() monkeypatch.setattr("vuln_scan.github.base_client.Auth", _FakeAuth) monkeypatch.setattr("vuln_scan.github.base_client.Github", fake_github) cfg = AuthConfig(app_id=123, private_key="pem-data", installation_id=456) gh = cfg.build_github() assert gh is cfg._github assert isinstance(captured["auth"], _FakeAuth.AppInstallationAuth) assert captured["auth"].installation_id == 456 assert isinstance(captured["auth"].app_auth, _FakeAuth.AppAuth) assert captured["auth"].app_auth.app_id == 123 assert captured["auth"].app_auth.private_key == "pem-data" def test_build_github_uses_token_auth(monkeypatch: pytest.MonkeyPatch) -> None: captured: dict[str, Any] = {} def fake_github(*, auth: object) -> object: captured["auth"] = auth return object() monkeypatch.setattr("vuln_scan.github.base_client.Auth", _FakeAuth) monkeypatch.setattr("vuln_scan.github.base_client.Github", fake_github) cfg = AuthConfig(token="secret") gh = cfg.build_github() assert gh is cfg._github assert isinstance(captured["auth"], _FakeAuth.Token) assert captured["auth"].value == "secret" def test_build_github_returns_cached_instance_inside_lock() -> None: auth = AuthConfig(token="t") sentinel = object() assert auth._github is None auth._lock = _LockThatSeedsGithub(auth, sentinel) # type: ignore[assignment] assert auth.build_github() is sentinel def test_build_github_returns_cached_instance() -> None: auth = AuthConfig(token="t") sentinel = object() auth._github = sentinel # type: ignore[assignment] assert auth.build_github() is sentinel def test_build_github_raises_when_no_credentials() -> None: auth = AuthConfig(token=None, app_id=None, private_key=None, installation_id=None) with pytest.raises( ValueError, match=r"Provide either token or app_id \+ private_key \+ installation_id", ): auth.build_github() # -------------------------------------------------------- # Fixtures / helpers for GitHubBaseClient tests # -------------------------------------------------------- @pytest.fixture def _make_client(monkeypatch: pytest.MonkeyPatch): def _factory( *, result: dict[str, Any] | None = None, headers: dict[str, str] | None = None, should_raise: Exception | None = None, ) -> tuple[GitHubBaseClient, _FakeRequester]: requester = _FakeRequester( result=result, headers=headers, should_raise=should_raise, ) fake_gh = _FakeGithub(requester) monkeypatch.setattr( GitHubBaseClient, "__post_init__", lambda self: setattr(self, "_gh", fake_gh), ) return GitHubBaseClient(auth_config=AuthConfig(token="t")), requester return _factory # -------------------------------------------------------- # GitHubBaseClient.__post_init__ test # -------------------------------------------------------- def test_post_init_builds_github_from_auth_config() -> None: fake_gh = object() auth = AuthConfig(token="t") auth.build_github = MagicMock(return_value=fake_gh) # type: ignore[method-assign] client = GitHubBaseClient(auth_config=auth) assert client._gh is fake_gh auth.build_github.assert_called_once_with() # -------------------------------------------------------- # _graphql_once tests # -------------------------------------------------------- @pytest.mark.parametrize( "query, variables, expected_vars, expected_data", [ pytest.param("query { x }", None, {}, {"x": 1}, id="default-empty-variables"), pytest.param( "query { ok }", {"cursor": "abc"}, {"cursor": "abc"}, {"ok": True}, id="explicit-variables", ), ], ) def test_graphql_once_success( _make_client, query: str, variables: dict[str, Any] | None, expected_vars: dict[str, Any], expected_data: dict[str, Any], ) -> None: client, requester = _make_client( result={"data": expected_data}, headers={"x-ratelimit-remaining": "4999"}, ) result = client._graphql_once(query, variables) assert result == expected_data assert requester.calls[0] == ( "POST", "/graphql", {"query": query, "variables": expected_vars}, ) @pytest.mark.parametrize( "result, should_raise, expected_exc, match", [ pytest.param( None, None, RuntimeError, "Empty response from GitHub GraphQL", id="empty-response" ), pytest.param( {"errors": [{"message": "bad"}]}, None, GitHubGraphQLError, "GitHub GraphQL error", id="graphql-errors-payload", ), pytest.param( {"data": []}, None, RuntimeError, "Invalid GraphQL response format", id="invalid-payload-type", ), pytest.param( None, ValueError("boom"), ValueError, "boom", id="request-exception-propagates", ), ], ) def test_graphql_once_errors( _make_client, result: dict[str, Any] | None, should_raise: Exception | None, expected_exc: type[Exception], match: str, ) -> None: client, _ = _make_client(result=result, headers={}, should_raise=should_raise) with pytest.raises(expected_exc, match=match): client._graphql_once("query") def test_graphql_once_exposes_graphql_errors_list(_make_client) -> None: client, _ = _make_client(result={"errors": [{"message": "bad"}]}, headers={}) with pytest.raises(GitHubGraphQLError) as exc_info: client._graphql_once("query") assert exc_info.value.errors == [{"message": "bad"}] # -------------------------------------------------------- # _graphql_with_retry tests # -------------------------------------------------------- def test_graphql_with_retry_returns_immediately_on_success( monkeypatch: pytest.MonkeyPatch, ) -> None: client = _client_without_post_init(monkeypatch) client._graphql_once = MagicMock(return_value={"ok": True}) # type: ignore[method-assign] assert client._graphql_with_retry("query") == {"ok": True} client._graphql_once.assert_called_once_with("query", None) def test_graphql_with_retry_retries_rate_limit_and_then_succeeds( monkeypatch: pytest.MonkeyPatch, ) -> None: client = _client_without_post_init(monkeypatch) exc = RateLimitExceededException( status=403, data={"message": "rate limit exceeded"}, headers={} ) exc.reset_time = datetime.now(UTC) + timedelta(seconds=5) # type: ignore[attr-defined] client._graphql_once = MagicMock(side_effect=[exc, {"ok": True}]) # type: ignore[method-assign] sleep_calls: list[float] = [] monkeypatch.setattr( "vuln_scan.github.base_client.time.sleep", lambda seconds: sleep_calls.append(seconds) ) result = client._graphql_with_retry("query") assert result == {"ok": True} assert len(sleep_calls) == 1 assert sleep_calls[0] >= 0 def test_graphql_with_retry_breaks_on_last_attempt_rate_limit_without_sleep( monkeypatch: pytest.MonkeyPatch, ) -> None: client = _client_without_post_init(monkeypatch) exc = RateLimitExceededException( status=403, data={"message": "rate limit exceeded"}, headers={} ) exc.reset_time = datetime.now(UTC) + timedelta(seconds=5) # type: ignore[attr-defined] client._graphql_once = MagicMock(side_effect=exc) # type: ignore[method-assign] sleep_calls: list[float] = [] monkeypatch.setattr( "vuln_scan.github.base_client.time.sleep", lambda seconds: sleep_calls.append(seconds) ) with pytest.raises(RateLimitExceededException): client._graphql_with_retry("query", max_retries=0) assert client._graphql_once.call_count == 1 assert sleep_calls == [] def test_graphql_with_retry_retries_retryable_github_exception_then_succeeds( monkeypatch: pytest.MonkeyPatch, ) -> None: client = _client_without_post_init(monkeypatch) exc = GithubException(status=503, data={"message": "unavailable"}, headers={}) client._graphql_once = MagicMock(side_effect=[exc, {"ok": True}]) # type: ignore[method-assign] sleep_calls: list[float] = [] monkeypatch.setattr( "vuln_scan.github.base_client.time.sleep", lambda seconds: sleep_calls.append(seconds) ) monkeypatch.setattr("vuln_scan.github.base_client.random.uniform", lambda a, b: 0.5) result = client._graphql_with_retry("query") assert result == {"ok": True} assert len(sleep_calls) == 1 assert sleep_calls[0] == 2.5 def test_graphql_with_retry_breaks_on_last_attempt_github_exception_without_sleep( monkeypatch: pytest.MonkeyPatch, ) -> None: client = _client_without_post_init(monkeypatch) exc = GithubException(status=503, data={"message": "unavailable"}, headers={}) client._graphql_once = MagicMock(side_effect=exc) # type: ignore[method-assign] sleep_calls: list[float] = [] monkeypatch.setattr( "vuln_scan.github.base_client.time.sleep", lambda seconds: sleep_calls.append(seconds) ) with pytest.raises(GithubException): client._graphql_with_retry("query", max_retries=0) assert client._graphql_once.call_count == 1 assert sleep_calls == [] def test_graphql_with_retry_retries_retryable_graphql_error_then_succeeds( monkeypatch: pytest.MonkeyPatch, ) -> None: client = _client_without_post_init(monkeypatch) exc = GitHubGraphQLError([{"message": "Something went wrong"}]) client._graphql_once = MagicMock(side_effect=[exc, {"ok": True}]) # type: ignore[method-assign] sleep_calls: list[float] = [] monkeypatch.setattr( "vuln_scan.github.base_client.time.sleep", lambda seconds: sleep_calls.append(seconds) ) monkeypatch.setattr("vuln_scan.github.base_client.random.uniform", lambda a, b: 0.25) result = client._graphql_with_retry("query") assert result == {"ok": True} assert len(sleep_calls) == 1 assert sleep_calls[0] == 2.25 def test_graphql_with_retry_breaks_on_last_attempt_graphql_error_without_sleep( monkeypatch: pytest.MonkeyPatch, ) -> None: client = _client_without_post_init(monkeypatch) exc = GitHubGraphQLError([{"message": "Something went wrong"}]) client._graphql_once = MagicMock(side_effect=exc) # type: ignore[method-assign] sleep_calls: list[float] = [] monkeypatch.setattr( "vuln_scan.github.base_client.time.sleep", lambda seconds: sleep_calls.append(seconds) ) with pytest.raises(GitHubGraphQLError): client._graphql_with_retry("query", max_retries=0) assert client._graphql_once.call_count == 1 assert sleep_calls == [] def test_graphql_with_retry_raises_non_retryable_github_exception( monkeypatch: pytest.MonkeyPatch, ) -> None: client = _client_without_post_init(monkeypatch) exc = GithubException(status=404, data={"message": "not found"}, headers={}) client._graphql_once = MagicMock(side_effect=exc) # type: ignore[method-assign] with pytest.raises(GithubException): client._graphql_with_retry("query") def test_graphql_with_retry_raises_non_retryable_graphql_error( monkeypatch: pytest.MonkeyPatch, ) -> None: client = _client_without_post_init(monkeypatch) exc = GitHubGraphQLError([{"message": "Field does not exist"}]) client._graphql_once = MagicMock(side_effect=exc) # type: ignore[method-assign] with pytest.raises(GitHubGraphQLError): client._graphql_with_retry("query") def test_graphql_with_retry_raises_last_exception_after_exhausting_retries( monkeypatch: pytest.MonkeyPatch, ) -> None: client = _client_without_post_init(monkeypatch) exc = GithubException(status=503, data={"message": "unavailable"}, headers={}) client._graphql_once = MagicMock(side_effect=exc) # type: ignore[method-assign] monkeypatch.setattr("vuln_scan.github.base_client.time.sleep", lambda seconds: None) monkeypatch.setattr("vuln_scan.github.base_client.random.uniform", lambda a, b: 0.0) with pytest.raises(GithubException): client._graphql_with_retry("query", max_retries=2) assert client._graphql_once.call_count == 3 def test_graphql_with_retry_does_not_sleep_on_last_attempt( monkeypatch: pytest.MonkeyPatch, ) -> None: client = _client_without_post_init(monkeypatch) exc = GithubException(status=503, data={"message": "unavailable"}, headers={}) client._graphql_once = MagicMock(side_effect=exc) # type: ignore[method-assign] sleep_calls: list[float] = [] monkeypatch.setattr( "vuln_scan.github.base_client.time.sleep", lambda seconds: sleep_calls.append(seconds) ) monkeypatch.setattr("vuln_scan.github.base_client.random.uniform", lambda a, b: 0.0) with pytest.raises(GithubException): client._graphql_with_retry("query", max_retries=1) assert len(sleep_calls) == 1 # -------------------------------------------------------- # _is_retryable_github_exception # -------------------------------------------------------- @pytest.mark.parametrize( "exc, expected", [ pytest.param( GithubException(status=429, data={"message": "too many requests"}, headers={}), True, id="retryable-status-429", ), pytest.param( GithubException(status=500, data={"message": "server error"}, headers={}), True, id="retryable-status-500", ), pytest.param( GithubException(status=403, data={"message": "secondary rate limit"}, headers={}), True, id="secondary-rate-limit-message", ), pytest.param( GithubException(status=403, data={"message": "abuse detection mechanism"}, headers={}), True, id="abuse-detection-message", ), pytest.param( GithubException(status=403, data={"message": "temporarily unavailable"}, headers={}), True, id="temporarily-unavailable-message", ), pytest.param( GithubException(status=404, data={"message": "not found"}, headers={}), False, id="non-retryable", ), ], ) def test_is_retryable_github_exception( monkeypatch: pytest.MonkeyPatch, exc: GithubException, expected: bool, ) -> None: client = _client_without_post_init(monkeypatch) assert client._is_retryable_github_exception(exc) is expected # -------------------------------------------------------- # _is_retryable_graphql_error # -------------------------------------------------------- @pytest.mark.parametrize( "errors, expected", [ pytest.param([{"message": "rate limit exceeded"}], True, id="message-rate-limit"), pytest.param( [{"message": "secondary rate limit"}], True, id="message-secondary-rate-limit" ), pytest.param( [{"message": "Something went wrong"}], True, id="message-something-went-wrong" ), pytest.param([{"type": "RATE_LIMIT"}], True, id="type-rate-limit"), pytest.param([{"type": "SERVICE_UNAVAILABLE"}], True, id="type-service-unavailable"), pytest.param([{"message": "field does not exist"}], False, id="non-retryable-message"), pytest.param(["bad-shape"], False, id="non-dict-entry"), ], ) def test_is_retryable_graphql_error( monkeypatch: pytest.MonkeyPatch, errors: list[Any], expected: bool, ) -> None: client = _client_without_post_init(monkeypatch) assert client._is_retryable_graphql_error(GitHubGraphQLError(errors)) is expected # -------------------------------------------------------- # _compute_github_exception_wait # -------------------------------------------------------- def test_compute_github_exception_wait_uses_retry_after_header( monkeypatch: pytest.MonkeyPatch, ) -> None: client = _client_without_post_init(monkeypatch) exc = GithubException(status=429, data={"message": "retry"}, headers={"Retry-After": "7"}) wait = client._compute_github_exception_wait( exc=exc, attempt=0, backoff_base=2.0, backoff_max=60.0, ) assert wait == 8.0 def test_compute_github_exception_wait_uses_backoff_with_jitter( monkeypatch: pytest.MonkeyPatch, ) -> None: client = _client_without_post_init(monkeypatch) exc = GithubException(status=503, data={"message": "retry"}, headers={}) monkeypatch.setattr("vuln_scan.github.base_client.random.uniform", lambda a, b: 0.75) wait = client._compute_github_exception_wait( exc=exc, attempt=1, backoff_base=2.0, backoff_max=60.0, ) assert wait == 4.75 def test_compute_github_exception_wait_respects_max( monkeypatch: pytest.MonkeyPatch, ) -> None: client = _client_without_post_init(monkeypatch) exc = GithubException(status=503, data={"message": "retry"}, headers={}) monkeypatch.setattr("vuln_scan.github.base_client.random.uniform", lambda a, b: 10.0) wait = client._compute_github_exception_wait( exc=exc, attempt=10, backoff_base=2.0, backoff_max=5.0, ) assert wait == 5.0 # -------------------------------------------------------- # _compute_rate_limit_wait # -------------------------------------------------------- def test_compute_rate_limit_wait_with_datetime( monkeypatch: pytest.MonkeyPatch, ) -> None: client = _client_without_post_init(monkeypatch) monkeypatch.setattr("vuln_scan.github.base_client.time.time", lambda: 1000.0) reset_at = datetime.fromtimestamp(1005.0, tz=UTC) wait = client._compute_rate_limit_wait( reset_at=reset_at, attempt=0, backoff_base=2.0, backoff_max=60.0, ) assert wait == 6.0 def test_compute_rate_limit_wait_with_naive_datetime( monkeypatch: pytest.MonkeyPatch, ) -> None: client = _client_without_post_init(monkeypatch) monkeypatch.setattr("vuln_scan.github.base_client.time.time", lambda: 1000.0) reset_at = datetime.fromtimestamp(1005.0, UTC).replace(tzinfo=None) wait = client._compute_rate_limit_wait( reset_at=reset_at, attempt=0, backoff_base=2.0, backoff_max=60.0, ) assert wait == 6.0 def test_compute_rate_limit_wait_with_numeric_timestamp( monkeypatch: pytest.MonkeyPatch, ) -> None: client = _client_without_post_init(monkeypatch) monkeypatch.setattr("vuln_scan.github.base_client.time.time", lambda: 1000.0) wait = client._compute_rate_limit_wait( reset_at=1005.0, attempt=0, backoff_base=2.0, backoff_max=60.0, ) assert wait == 6.0 def test_compute_rate_limit_wait_falls_back_to_backoff( monkeypatch: pytest.MonkeyPatch, ) -> None: client = _client_without_post_init(monkeypatch) wait = client._compute_rate_limit_wait( reset_at=None, attempt=2, backoff_base=2.0, backoff_max=60.0, ) assert wait == 8.0 def test_compute_rate_limit_wait_respects_max( monkeypatch: pytest.MonkeyPatch, ) -> None: client = _client_without_post_init(monkeypatch) monkeypatch.setattr("vuln_scan.github.base_client.time.time", lambda: 1000.0) wait = client._compute_rate_limit_wait( reset_at=2000.0, attempt=0, backoff_base=2.0, backoff_max=5.0, ) assert wait == 5.0 # -------------------------------------------------------- # _parse_retry_after # -------------------------------------------------------- @pytest.mark.parametrize( "headers, expected", [ pytest.param({"retry-after": "5"}, 5, id="lowercase"), pytest.param({"Retry-After": "7"}, 7, id="canonical"), pytest.param({"retry-after": "bad"}, None, id="invalid-value"), pytest.param({}, None, id="missing"), pytest.param(None, None, id="none"), ], ) def test_parse_retry_after( monkeypatch: pytest.MonkeyPatch, headers: dict[str, str] | None, expected: int | None, ) -> None: client = _client_without_post_init(monkeypatch) exc = _HeadersException(headers) assert client._parse_retry_after(exc) == expected def test_graphql_with_retry_raises_runtime_error_when_no_exception_captured( monkeypatch: pytest.MonkeyPatch, ) -> None: client = _client_without_post_init(monkeypatch) class _EmptyRange: def __call__(self, *args, **kwargs): return iter(()) monkeypatch.setitem(client._graphql_with_retry.__globals__, "range", _EmptyRange()) with pytest.raises( RuntimeError, match="GitHub GraphQL request failed without a captured exception", ): client._graphql_with_retry("query")