From aa943d084150867a635552c453ea19032058ec00 Mon Sep 17 00:00:00 2001 From: volcano303 <75143900+volcano303@users.noreply.github.com> Date: Tue, 12 May 2026 20:13:04 +0200 Subject: [PATCH 1/2] fix(cli): reject viewer-null PAT preflight responses --- gittensor/cli/miner_commands/post.py | 27 ++++++++++++++-- tests/cli/test_miner_commands.py | 47 +++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/gittensor/cli/miner_commands/post.py b/gittensor/cli/miner_commands/post.py index 40551ded..ab98bb0f 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() @@ -215,9 +218,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 fc610898..b71f61a0 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: Exception | None = 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) From 12419536a4773c3dd51f563e514b649790982a0a Mon Sep 17 00:00:00 2001 From: volcano303 <75143900+volcano303@users.noreply.github.com> Date: Sun, 17 May 2026 15:48:31 +0200 Subject: [PATCH 2/2] test: avoid pyright union syntax warning --- tests/cli/test_miner_commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cli/test_miner_commands.py b/tests/cli/test_miner_commands.py index aac5a67c..39baddc8 100644 --- a/tests/cli/test_miner_commands.py +++ b/tests/cli/test_miner_commands.py @@ -32,7 +32,7 @@ def _fake_metagraph(rows: list[tuple[float, bool, float]]): ) -def _mock_http_response(status_code: int = 200, payload=None, json_error: Exception | None = None): +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: