Skip to content
Merged
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
36 changes: 36 additions & 0 deletions parallel_web_tools/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,35 @@ def _extract_api_message(error: Exception) -> str:
return str(error)


def _legacy_credentials_in_use() -> bool:
"""True when the active API key comes from the v0→v1 migrated ``legacy`` org.

Env-var keys win over stored creds in :func:`resolve_api_key`, so we only
flag legacy state when no ``PARALLEL_API_KEY`` is set — otherwise the user's
error has nothing to do with their on-disk credentials.
"""
if os.environ.get("PARALLEL_API_KEY"):
return False
from parallel_web_tools.core import credentials

creds = credentials.load()
if creds is None:
return False
return creds.selected_org_id == credentials.LEGACY_ORG_ID


def _is_legacy_account_api_failure(error: Exception) -> bool:
"""Detect a legacy user attempting an Account API operation.

``ReauthenticationRequired`` fires from :func:`get_control_api_access_token`
whenever there are no usable control-API tokens. For a v0→v1 migrated user
this is expected: their stored ``api_key`` works for the standard Parallel
API, but they never went through the device flow that mints control tokens,
so any Account API call fails here.
"""
return isinstance(error, ReauthenticationRequired) and _legacy_credentials_in_use()


def _handle_error(
error: Exception,
output_json: bool = False,
Expand All @@ -186,6 +215,13 @@ def _handle_error(
Rich-formatted error message.
"""
message = _extract_api_message(error)
if _is_legacy_account_api_failure(error):
message = (
"This operation requires Account API credentials, but your stored "
"credentials only authorize the standard Parallel API. Re-run "
"'parallel-cli login' to authenticate against the Account API."
)
exit_code = EXIT_AUTH_ERROR
if output_json:
error_data = {"error": {"message": message, "type": type(error).__name__}}
print(json.dumps(error_data, indent=2))
Expand Down
51 changes: 51 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,57 @@ def test_handle_error_default_exit_code(self):

assert exc_info.value.code == EXIT_API_ERROR

def test_handle_error_legacy_account_api_failure(self, capsys, tmp_path, monkeypatch):
"""ReauthenticationRequired on a legacy-migrated creds file should explain the API split."""
from parallel_web_tools.core import credentials
from parallel_web_tools.core.auth import ReauthenticationRequired

monkeypatch.delenv("PARALLEL_API_KEY", raising=False)
auth_file = tmp_path / "auth.json"
monkeypatch.setattr(credentials, "CREDENTIALS_FILE", auth_file)
monkeypatch.setattr(credentials, "AUTH_FILE", auth_file)
credentials.save(
credentials.Credentials(
selected_org_id=credentials.LEGACY_ORG_ID,
orgs={credentials.LEGACY_ORG_ID: credentials.OrgCredentials(api_key="old-v0-key")},
)
)

error = ReauthenticationRequired("no refresh token available; run 'parallel-cli login'")

with pytest.raises(SystemExit) as exc_info:
_handle_error(error, output_json=True)

assert exc_info.value.code == EXIT_AUTH_ERROR
output = json.loads(capsys.readouterr().out)
assert "Account API credentials" in output["error"]["message"]
assert "parallel-cli login" in output["error"]["message"]

def test_handle_error_legacy_message_skipped_when_env_var_set(self, capsys, tmp_path, monkeypatch):
"""Env-var keys override stored creds, so we shouldn't blame legacy creds."""
from parallel_web_tools.core import credentials
from parallel_web_tools.core.auth import ReauthenticationRequired

monkeypatch.setenv("PARALLEL_API_KEY", "env-key")
auth_file = tmp_path / "auth.json"
monkeypatch.setattr(credentials, "CREDENTIALS_FILE", auth_file)
monkeypatch.setattr(credentials, "AUTH_FILE", auth_file)
credentials.save(
credentials.Credentials(
selected_org_id=credentials.LEGACY_ORG_ID,
orgs={credentials.LEGACY_ORG_ID: credentials.OrgCredentials(api_key="old-v0-key")},
)
)

error = ReauthenticationRequired("authorization grant has expired")

with pytest.raises(SystemExit):
_handle_error(error, output_json=True)

output = json.loads(capsys.readouterr().out)
assert "Account API credentials" not in output["error"]["message"]
assert "authorization grant has expired" in output["error"]["message"]


class TestWriteJsonOutput:
"""Tests for write_json_output helper function."""
Expand Down
Loading