diff --git a/gittensor/cli/miner_commands/post.py b/gittensor/cli/miner_commands/post.py index 40551ded..3fdb6dc1 100644 --- a/gittensor/cli/miner_commands/post.py +++ b/gittensor/cli/miner_commands/post.py @@ -206,6 +206,8 @@ def _validate_pat_locally(pat: str) -> str | None: if user_resp.status_code != 200: return None login: str | None = user_resp.json().get('login') or None + if not login: + return None # Check GraphQL access (same test the validator runs during PAT broadcast) gql_resp = requests.post( @@ -220,6 +222,20 @@ def _validate_pat_locally(pat: str) -> str | None: ) return None + payload = gql_resp.json() + if payload.get('errors'): + err_console.print( + '[red]PAT lacks GraphQL API access. Fine-grained PATs need "Public Repositories (read-only)" permission.[/red]' + ) + return None + + viewer = (payload.get('data') or {}).get('viewer') + if viewer is None: + err_console.print( + '[red]PAT lacks GraphQL API access. Fine-grained PATs need "Public Repositories (read-only)" permission.[/red]' + ) + return None + return login except requests.RequestException: return None diff --git a/tests/cli/test_miner_commands.py b/tests/cli/test_miner_commands.py index fc610898..a8bbbfed 100644 --- a/tests/cli/test_miner_commands.py +++ b/tests/cli/test_miner_commands.py @@ -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]]): @@ -308,3 +309,26 @@ def test_no_response_is_not_collapsed_into_rejected(self): counts = _pat_post_aggregate_counts(results) assert counts['rejected'] == 1 assert counts['no_response'] == 1 + + +class TestValidatePatLocally: + @patch('gittensor.cli.miner_commands.post.requests.post') + @patch('gittensor.cli.miner_commands.post.requests.get') + def test_rejects_graphql_200_with_errors(self, mock_get, mock_post): + mock_get.return_value = SimpleNamespace(status_code=200, json=lambda: {'login': 'miner1'}) + mock_post.return_value = SimpleNamespace(status_code=200, json=lambda: {'errors': [{'message': 'forbidden'}]}) + assert _validate_pat_locally('ghp_test') is None + + @patch('gittensor.cli.miner_commands.post.requests.post') + @patch('gittensor.cli.miner_commands.post.requests.get') + def test_rejects_graphql_200_with_null_viewer(self, mock_get, mock_post): + mock_get.return_value = SimpleNamespace(status_code=200, json=lambda: {'login': 'miner1'}) + mock_post.return_value = SimpleNamespace(status_code=200, json=lambda: {'data': {'viewer': None}}) + assert _validate_pat_locally('ghp_test') is None + + @patch('gittensor.cli.miner_commands.post.requests.post') + @patch('gittensor.cli.miner_commands.post.requests.get') + def test_accepts_graphql_200_with_viewer(self, mock_get, mock_post): + mock_get.return_value = SimpleNamespace(status_code=200, json=lambda: {'login': 'miner1'}) + mock_post.return_value = SimpleNamespace(status_code=200, json=lambda: {'data': {'viewer': {'login': 'miner1'}}}) + assert _validate_pat_locally('ghp_test') == 'miner1'