from __future__ import annotations from types import SimpleNamespace import pytest from hermes_cli import auth as auth_mod from hermes_cli.auth import AuthError, resolve_spotify_runtime_credentials def test_store_provider_state_can_skip_active_provider() -> None: auth_store = {"active_provider": "nous", "providers": {}} auth_mod._store_provider_state( auth_store, "spotify", {"access_token": "abc"}, set_active=False, ) assert auth_store["active_provider"] == "nous" assert auth_store["providers"]["spotify"]["access_token"] == "abc" def test_resolve_spotify_runtime_credentials_refreshes_without_changing_active_provider( tmp_path, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("HERMES_HOME", str(tmp_path)) with auth_mod._auth_store_lock(): store = auth_mod._load_auth_store() store["active_provider"] = "nous" auth_mod._store_provider_state( store, "spotify", { "client_id": "spotify-client", "redirect_uri": "http://127.0.0.1:43827/spotify/callback", "api_base_url": auth_mod.DEFAULT_SPOTIFY_API_BASE_URL, "accounts_base_url": auth_mod.DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL, "scope": auth_mod.DEFAULT_SPOTIFY_SCOPE, "access_token": "expired-token", "refresh_token": "refresh-token", "token_type": "Bearer", "expires_at": "2000-01-01T00:00:00+00:00", }, set_active=False, ) auth_mod._save_auth_store(store) monkeypatch.setattr( auth_mod, "_refresh_spotify_oauth_state", lambda state, timeout_seconds=20.0: { **state, "access_token": "fresh-token", "expires_at": "2099-01-01T00:00:00+00:00", }, ) creds = auth_mod.resolve_spotify_runtime_credentials() assert creds["access_token"] == "fresh-token" persisted = auth_mod.get_provider_auth_state("spotify") assert persisted is not None assert persisted["access_token"] == "fresh-token" assert auth_mod.get_active_provider() == "nous" def test_auth_spotify_status_command_reports_logged_in(capsys, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( auth_mod, "get_auth_status", lambda provider=None: { "logged_in": True, "auth_type": "oauth_pkce", "client_id": "spotify-client", "redirect_uri": "http://127.0.0.1:43827/spotify/callback", "scope": "user-library-read", }, ) from hermes_cli.auth_commands import auth_status_command auth_status_command(SimpleNamespace(provider="spotify")) output = capsys.readouterr().out assert "spotify: logged in" in output assert "client_id: spotify-client" in output def test_spotify_logout_does_not_reset_model_provider( tmp_path, monkeypatch: pytest.MonkeyPatch, capsys, ) -> None: monkeypatch.setenv("HERMES_HOME", str(tmp_path)) config_path = tmp_path / "config.yaml" config_path.write_text( "model:\n" " default: gemini-3-flash\n" " provider: custom:local\n" " base_url: http://localhost:11434/v1\n" " api_key: ${LOCAL_API_KEY}\n", encoding="utf-8", ) with auth_mod._auth_store_lock(): store = auth_mod._load_auth_store() auth_mod._store_provider_state( store, "spotify", { "client_id": "spotify-client", "access_token": "access-token", "refresh_token": "refresh-token", "expires_at": "2099-01-01T00:00:00+00:00", }, set_active=False, ) auth_mod._save_auth_store(store) auth_mod.logout_command(SimpleNamespace(provider="spotify")) output = capsys.readouterr().out assert "Logged out of Spotify." in output assert "Model provider configuration was unchanged." in output assert auth_mod.get_provider_auth_state("spotify") is None assert config_path.read_text(encoding="utf-8") == ( "model:\n" " default: gemini-3-flash\n" " provider: custom:local\n" " base_url: http://localhost:11434/v1\n" " api_key: ${LOCAL_API_KEY}\n" ) def test_spotify_interactive_setup_persists_client_id( tmp_path, monkeypatch: pytest.MonkeyPatch, capsys, ) -> None: """The wizard writes HERMES_SPOTIFY_CLIENT_ID to .env and returns the value.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.setattr("builtins.input", lambda prompt="": "wizard-client-123") # Prevent actually opening the browser during tests. monkeypatch.setattr(auth_mod, "webbrowser", SimpleNamespace(open=lambda *_a, **_k: False)) monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True) result = auth_mod._spotify_interactive_setup( redirect_uri_hint=auth_mod.DEFAULT_SPOTIFY_REDIRECT_URI, ) assert result == "wizard-client-123" env_path = tmp_path / ".env" assert env_path.exists() env_text = env_path.read_text() assert "HERMES_SPOTIFY_CLIENT_ID=wizard-client-123" in env_text # Default redirect URI should NOT be persisted. assert "HERMES_SPOTIFY_REDIRECT_URI" not in env_text # Docs URL should appear in wizard output so users can find the guide. output = capsys.readouterr().out assert auth_mod.SPOTIFY_DOCS_URL in output def test_spotify_interactive_setup_empty_aborts( tmp_path, monkeypatch: pytest.MonkeyPatch, ) -> None: """Empty input aborts cleanly instead of persisting an empty client_id.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.setattr("builtins.input", lambda prompt="": "") monkeypatch.setattr(auth_mod, "webbrowser", SimpleNamespace(open=lambda *_a, **_k: False)) monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True) with pytest.raises(SystemExit): auth_mod._spotify_interactive_setup( redirect_uri_hint=auth_mod.DEFAULT_SPOTIFY_REDIRECT_URI, ) env_path = tmp_path / ".env" if env_path.exists(): assert "HERMES_SPOTIFY_CLIENT_ID" not in env_path.read_text() # --------------------------------------------------------------------------- # Quarantine: terminal refresh failure clears dead tokens (#28139) # --------------------------------------------------------------------------- _STALE_SPOTIFY_STATE = { "client_id": "test-client", "redirect_uri": "http://127.0.0.1:43827/spotify/callback", "api_base_url": auth_mod.DEFAULT_SPOTIFY_API_BASE_URL, "accounts_base_url": auth_mod.DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL, "scope": auth_mod.DEFAULT_SPOTIFY_SCOPE, "granted_scope": auth_mod.DEFAULT_SPOTIFY_SCOPE, "token_type": "Bearer", "access_token": "dead-access-token", "refresh_token": "dead-refresh-token", "expires_at": "2000-01-01T00:00:00+00:00", "expires_in": 3600, "obtained_at": "2000-01-01T00:00:00+00:00", "auth_type": "oauth_pkce", } def _seed_spotify_state(tmp_path, state: dict) -> None: with auth_mod._auth_store_lock(): store = auth_mod._load_auth_store() store["active_provider"] = "nous" auth_mod._store_provider_state(store, "spotify", state, set_active=False) auth_mod._save_auth_store(store) def test_resolve_credentials_quarantines_dead_tokens_on_terminal_refresh_failure( tmp_path, monkeypatch: pytest.MonkeyPatch, ) -> None: """Terminal refresh failure (relogin_required=True + refresh_token present) must clear access_token/refresh_token/expires_* from auth.json and write a last_auth_error marker so subsequent calls fail fast without a network retry. Mirrors Nous / xAI-OAuth / Codex-OAuth / MiniMax quarantine pattern. """ monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _seed_spotify_state(tmp_path, dict(_STALE_SPOTIFY_STATE)) def _terminal_refresh(_state, **_kw): raise AuthError( "Spotify token refresh failed. Run `hermes auth spotify` again.", provider="spotify", code="spotify_refresh_failed", relogin_required=True, ) monkeypatch.setattr(auth_mod, "_refresh_spotify_oauth_state", _terminal_refresh) with pytest.raises(AuthError) as exc_info: resolve_spotify_runtime_credentials(force_refresh=True) assert exc_info.value.code == "spotify_refresh_failed" assert exc_info.value.relogin_required is True persisted = auth_mod.get_provider_auth_state("spotify") assert persisted is not None # Dead OAuth fields must be cleared. assert "access_token" not in persisted assert "refresh_token" not in persisted assert "expires_at" not in persisted assert "expires_in" not in persisted assert "obtained_at" not in persisted # Non-credential metadata must be preserved. assert persisted["client_id"] == "test-client" assert persisted["api_base_url"] == auth_mod.DEFAULT_SPOTIFY_API_BASE_URL assert persisted["accounts_base_url"] == auth_mod.DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL # Structured diagnostic blob must be written. err = persisted.get("last_auth_error") assert isinstance(err, dict) assert err["provider"] == "spotify" assert err["code"] == "spotify_refresh_failed" assert err["reason"] == "runtime_refresh_failure" assert err["relogin_required"] is True assert "at" in err # Active provider must be unchanged. assert auth_mod.get_active_provider() == "nous" def test_resolve_credentials_does_not_quarantine_on_transient_refresh_failure( tmp_path, monkeypatch: pytest.MonkeyPatch, ) -> None: """Transient refresh failure (relogin_required=False, e.g. 429 / 5xx) must NOT trigger the quarantine path — tokens stay on disk for the next attempt. """ monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _seed_spotify_state(tmp_path, dict(_STALE_SPOTIFY_STATE)) def _transient_refresh(_state, **_kw): raise AuthError( "Spotify token refresh failed: connection error", provider="spotify", code="spotify_refresh_failed", relogin_required=False, ) monkeypatch.setattr(auth_mod, "_refresh_spotify_oauth_state", _transient_refresh) with pytest.raises(AuthError) as exc_info: resolve_spotify_runtime_credentials(force_refresh=True) assert exc_info.value.relogin_required is False # Tokens must be untouched — no quarantine on transient errors. persisted = auth_mod.get_provider_auth_state("spotify") assert persisted is not None assert persisted["refresh_token"] == "dead-refresh-token" assert persisted["access_token"] == "dead-access-token" assert "last_auth_error" not in persisted