Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 16 additions & 12 deletions gittensor/cli/miner_commands/post.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
_resolve_endpoint,
_status,
console,
err_console,
)
from gittensor.constants import BASE_GITHUB_API_URL, GITHUB_HTTP_TIMEOUT_SECONDS, GRAPHQL_VIEWER_QUERY
from gittensor.utils.github_api_tools import make_graphql_headers, make_headers
Expand Down Expand Up @@ -96,10 +95,10 @@ def miner_post(wallet_name, wallet_hotkey, netuid, network, rpc_url, pat, min_vt

# 1b. Validate PAT locally
with _status('[bold]Validating PAT...'):
github_login = _validate_pat_locally(pat)
github_login, pat_error = _validate_pat_locally(pat)

if github_login is None:
_error('GitHub PAT is invalid or expired. Check your GITTENSOR_MINER_PAT.', json_mode)
_error(pat_error or 'GitHub PAT is invalid or expired. Check your GITTENSOR_MINER_PAT.', json_mode)
sys.exit(1)

_print(f'[green]PAT is valid.[/green] GitHub account: [bold]@{github_login}[/bold]')
Expand Down Expand Up @@ -192,19 +191,25 @@ async def _broadcast():
_render_skipped_validators(excluded, json_mode)


def _validate_pat_locally(pat: str) -> str | None:
def _validate_pat_locally(pat: str) -> tuple[str | None, str | None]:
"""Validate PAT mirrors the validator-side checks: user identity + GraphQL access.

Returns the GitHub login on success, or None if the PAT is invalid.
Returns ``(login, None)`` on success, or ``(None, reason)`` where ``reason``
states why validation failed. The three failure causes need distinct
messages: an invalid/expired token, a token that authenticates but lacks
GraphQL access (typical for fine-grained PATs), and a network failure
reaching GitHub (the PAT itself may be fine — retry, don't rotate).
"""
try:
# Check basic auth and extract login
user_resp = requests.get(
f'{BASE_GITHUB_API_URL}/user', headers=make_headers(pat), timeout=GITHUB_HTTP_TIMEOUT_SECONDS
)
if user_resp.status_code != 200:
return None
return None, 'GitHub PAT is invalid or expired. Check your GITTENSOR_MINER_PAT.'
login: str | None = user_resp.json().get('login') or None
if login is None:
return None, 'GitHub PAT is invalid or expired. Check your GITTENSOR_MINER_PAT.'

# Check GraphQL access (same test the validator runs during PAT broadcast)
gql_resp = requests.post(
Expand All @@ -214,11 +219,10 @@ 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]'
return None, (
'PAT lacks GraphQL API access. Fine-grained PATs need "Public Repositories (read-only)" permission.'
)
return None

return login
except requests.RequestException:
return None
return login, None
except requests.RequestException as e:
return None, f'Could not reach GitHub to validate the PAT ({e}). Check your connection and retry.'
80 changes: 76 additions & 4 deletions tests/cli/test_miner_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from unittest.mock import patch

import pytest
import requests
from click.testing import CliRunner

from gittensor import __version__
Expand All @@ -19,6 +20,7 @@
_require_validator_axons,
_resolve_endpoint,
)
from gittensor.cli.miner_commands.post import _validate_pat_locally
from gittensor.constants import NETWORK_MAP


Expand All @@ -40,7 +42,10 @@ def runner():

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)
@patch(
'gittensor.cli.miner_commands.post._validate_pat_locally',
return_value=(None, 'GitHub PAT is invalid or expired. Check your GITTENSOR_MINER_PAT.'),
)
def test_no_pat_prompts_interactively(self, mock_validate, mock_prompt, runner, monkeypatch):
monkeypatch.delenv('GITTENSOR_MINER_PAT', raising=False)
runner.invoke(cli, ['miner', 'post', '--wallet', 'test', '--hotkey', 'test'])
Expand All @@ -53,15 +58,21 @@ def test_no_pat_json_mode_exits(self, runner, monkeypatch):
output = json.loads(result.stdout)
assert output['success'] is False

@patch('gittensor.cli.miner_commands.post._validate_pat_locally', return_value=None)
@patch(
'gittensor.cli.miner_commands.post._validate_pat_locally',
return_value=(None, 'GitHub PAT is invalid or expired. Check your GITTENSOR_MINER_PAT.'),
)
def test_pat_flag_used(self, mock_validate, runner, monkeypatch):
monkeypatch.delenv('GITTENSOR_MINER_PAT', raising=False)
result = runner.invoke(cli, ['miner', 'post', '--pat', 'ghp_test123', '--wallet', 'test', '--hotkey', 'test'])
assert result.exit_code != 0
assert 'invalid' in result.stderr.lower() or 'expired' in result.stderr.lower()
mock_validate.assert_called_once_with('ghp_test123')

@patch('gittensor.cli.miner_commands.post._validate_pat_locally', return_value=None)
@patch(
'gittensor.cli.miner_commands.post._validate_pat_locally',
return_value=(None, 'GitHub PAT is invalid or expired. Check your GITTENSOR_MINER_PAT.'),
)
def test_invalid_pat_exits(self, mock_validate, runner, monkeypatch):
monkeypatch.setenv('GITTENSOR_MINER_PAT', 'ghp_invalid')
result = runner.invoke(cli, ['miner', 'post', '--wallet', 'test', '--hotkey', 'test'])
Expand Down Expand Up @@ -101,7 +112,7 @@ async def __call__(self, **kwargs):
return responses

with (
patch('gittensor.cli.miner_commands.post._validate_pat_locally', return_value='testuser'),
patch('gittensor.cli.miner_commands.post._validate_pat_locally', return_value=('testuser', None)),
patch(
'gittensor.cli.miner_commands.post._connect_bittensor',
return_value=(wallet, object(), metagraph, FakeDendrite()),
Expand Down Expand Up @@ -374,3 +385,64 @@ 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:
"""_validate_pat_locally must report WHY validation failed, not just fail.

The three failure causes need distinct messages: an invalid/expired token,
a token that authenticates but lacks GraphQL access (typical for
fine-grained PATs), and a network failure reaching GitHub (retryable —
rotating the PAT won't help).
"""

def test_invalid_token_reports_invalid(self):
with patch('gittensor.cli.miner_commands.post.requests') as mock:
mock.RequestException = requests.RequestException
mock.get.return_value = SimpleNamespace(status_code=401)
login, error = _validate_pat_locally('ghp_bad')
assert login is None
assert error is not None and 'invalid or expired' in error

def test_missing_graphql_access_reports_graphql_cause(self):
with patch('gittensor.cli.miner_commands.post.requests') as mock:
mock.RequestException = requests.RequestException
mock.get.return_value = SimpleNamespace(status_code=200, json=lambda: {'login': 'someuser'})
mock.post.return_value = SimpleNamespace(status_code=403)
login, error = _validate_pat_locally('github_pat_finegrained')
assert login is None
assert error is not None and 'GraphQL' in error
assert 'invalid or expired' not in error

def test_network_failure_reports_connectivity_not_invalid(self):
with patch('gittensor.cli.miner_commands.post.requests') as mock:
mock.RequestException = requests.RequestException
mock.get.side_effect = requests.RequestException('connection refused')
login, error = _validate_pat_locally('ghp_fine')
assert login is None
assert error is not None and 'reach GitHub' in error
assert 'invalid or expired' not in error

def test_valid_token_returns_login_and_no_error(self):
with patch('gittensor.cli.miner_commands.post.requests') as mock:
mock.RequestException = requests.RequestException
mock.get.return_value = SimpleNamespace(status_code=200, json=lambda: {'login': 'someuser'})
mock.post.return_value = SimpleNamespace(status_code=200)
login, error = _validate_pat_locally('ghp_good')
assert login == 'someuser'
assert error is None

def test_post_json_mode_surfaces_specific_reason(self, monkeypatch):
"""The machine-readable --json error must carry the real cause, not a
generic 'invalid or expired' verdict for a PAT that authenticated fine."""
monkeypatch.delenv('GITTENSOR_MINER_PAT', raising=False)
runner = CliRunner()
reason = 'PAT lacks GraphQL API access. Fine-grained PATs need "Public Repositories (read-only)" permission.'
with patch('gittensor.cli.miner_commands.post._validate_pat_locally', return_value=(None, reason)):
result = runner.invoke(
cli, ['miner', 'post', '--json', '--pat', 'github_pat_x', '--wallet', 'test', '--hotkey', 'test']
)
assert result.exit_code != 0
output = json.loads(result.stdout)
assert output['success'] is False
assert 'GraphQL' in output['error']['message']