diff --git a/parallel_web_tools/cli/commands.py b/parallel_web_tools/cli/commands.py index 6bc1c27..e99b7ee 100644 --- a/parallel_web_tools/cli/commands.py +++ b/parallel_web_tools/cli/commands.py @@ -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, @@ -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)) diff --git a/tests/test_cli.py b/tests/test_cli.py index 3e503a4..1e540e5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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."""