diff --git a/gittensor/cli/miner_commands/post.py b/gittensor/cli/miner_commands/post.py index 233ce286..933f06aa 100644 --- a/gittensor/cli/miner_commands/post.py +++ b/gittensor/cli/miner_commands/post.py @@ -39,6 +39,9 @@ 'rejected': '[red]✗[/red]', 'no_response': '[yellow]—[/yellow]', } +_PAT_GRAPHQL_ACCESS_ERROR = ( + '[red]PAT lacks GraphQL API access. Fine-grained PATs need "Public Repositories (read-only)" permission.[/red]' +) @click.command() @@ -214,9 +217,27 @@ def _validate_pat_locally(pat: str) -> str | None: timeout=GITHUB_HTTP_TIMEOUT_SECONDS, ) if gql_resp.status_code != 200: - err_console.print( - '[red]PAT lacks GraphQL API access. Fine-grained PATs need "Public Repositories (read-only)" permission.[/red]' - ) + err_console.print(_PAT_GRAPHQL_ACCESS_ERROR) + return None + + try: + gql_data = gql_resp.json() + except ValueError: + return None + + if not isinstance(gql_data, dict): + err_console.print(_PAT_GRAPHQL_ACCESS_ERROR) + return None + + errors = gql_data.get('errors') + if errors: + err_console.print(_PAT_GRAPHQL_ACCESS_ERROR) + return None + + response_data = gql_data.get('data') + viewer = response_data.get('viewer') if isinstance(response_data, dict) else None + if viewer is None: + err_console.print(_PAT_GRAPHQL_ACCESS_ERROR) return None return login diff --git a/tests/cli/test_miner_commands.py b/tests/cli/test_miner_commands.py index c7ec1140..39baddc8 100644 --- a/tests/cli/test_miner_commands.py +++ b/tests/cli/test_miner_commands.py @@ -4,7 +4,7 @@ import json from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from click.testing import CliRunner @@ -18,6 +18,7 @@ _pat_post_row_category, _require_validator_axons, ) +from gittensor.cli.miner_commands.post import _validate_pat_locally def _fake_metagraph(rows: list[tuple[float, bool, float]]): @@ -31,11 +32,55 @@ def _fake_metagraph(rows: list[tuple[float, bool, float]]): ) +def _mock_http_response(status_code: int = 200, payload=None, json_error=None): + response = MagicMock() + response.status_code = status_code + if json_error is not None: + response.json.side_effect = json_error + else: + response.json.return_value = payload if payload is not None else {} + return response + + @pytest.fixture def runner(): return CliRunner() +class TestValidatePatLocally: + @patch('gittensor.cli.miner_commands.post.requests.post') + @patch('gittensor.cli.miner_commands.post.requests.get') + def test_valid_viewer_returns_login(self, mock_get, mock_post): + mock_get.return_value = _mock_http_response(200, {'login': 'octocat'}) + mock_post.return_value = _mock_http_response(200, {'data': {'viewer': {'login': 'octocat'}}}) + + assert _validate_pat_locally('ghp_valid') == 'octocat' + + @pytest.mark.parametrize( + 'payload', + [ + {'data': {'viewer': None}}, + {'data': None}, + {'errors': [{'message': 'Bad credentials'}]}, + ], + ) + @patch('gittensor.cli.miner_commands.post.requests.post') + @patch('gittensor.cli.miner_commands.post.requests.get') + def test_graphql_body_rejections_return_none(self, mock_get, mock_post, payload): + mock_get.return_value = _mock_http_response(200, {'login': 'octocat'}) + mock_post.return_value = _mock_http_response(200, payload) + + assert _validate_pat_locally('ghp_scopeless') is None + + @patch('gittensor.cli.miner_commands.post.requests.post') + @patch('gittensor.cli.miner_commands.post.requests.get') + def test_graphql_json_decode_error_returns_none(self, mock_get, mock_post): + mock_get.return_value = _mock_http_response(200, {'login': 'octocat'}) + mock_post.return_value = _mock_http_response(200, json_error=ValueError('not json')) + + assert _validate_pat_locally('ghp_bad_json') is None + + class TestMinerPost: @patch('gittensor.cli.miner_commands.post.click.prompt', return_value='ghp_fake') @patch('gittensor.cli.miner_commands.post._validate_pat_locally', return_value=None)