From fcd9b1ab2a5d70816a908b22e1a31e3cdf907cf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Wed, 11 Mar 2026 17:08:23 -0700 Subject: [PATCH 01/11] fix(login): use sentinel default to prevent env var auth leak in guest sessions Session factories conflated "no override provided" with "explicitly no auth" via `api_key_override or get_api_key()`. When RunpodGraphQLClient set api_key_override=None for require_api_key=False, the `or` fallback picked up RUNPOD_API_KEY from the environment, causing flash login to send a Bearer token instead of acting as a guest. The server then never returned an apiKey. Introduces _UNSET sentinel so api_key_override=None means "send no auth" while omitting the parameter preserves existing credential resolution. --- src/runpod_flash/core/utils/http.py | 16 +++++++------ tests/unit/test_credentials_extended.py | 32 +++++++++++++++++++++++++ tests/unit/test_login_extended.py | 20 ++++++++++++++++ 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/runpod_flash/core/utils/http.py b/src/runpod_flash/core/utils/http.py index fa155ae4..8d9ba97d 100644 --- a/src/runpod_flash/core/utils/http.py +++ b/src/runpod_flash/core/utils/http.py @@ -1,6 +1,6 @@ """HTTP utilities for RunPod API communication.""" -from typing import Optional +from typing import Optional, Union import httpx import requests @@ -9,10 +9,12 @@ from runpod_flash.core.credentials import get_api_key +_UNSET = object() + def get_authenticated_httpx_client( timeout: Optional[float] = None, - api_key_override: Optional[str] = None, + api_key_override: Union[Optional[str], object] = _UNSET, ) -> httpx.AsyncClient: """Create httpx AsyncClient with RunPod authentication and User-Agent. @@ -50,7 +52,7 @@ def get_authenticated_httpx_client( "User-Agent": get_user_agent(), "Content-Type": "application/json", } - api_key = api_key_override or get_api_key() + api_key = get_api_key() if api_key_override is _UNSET else api_key_override if api_key: headers["Authorization"] = f"Bearer {api_key}" @@ -59,7 +61,7 @@ def get_authenticated_httpx_client( def get_authenticated_requests_session( - api_key_override: Optional[str] = None, + api_key_override: Union[Optional[str], object] = _UNSET, ) -> requests.Session: """Create requests Session with RunPod authentication and User-Agent. @@ -98,7 +100,7 @@ def get_authenticated_requests_session( session.headers["User-Agent"] = get_user_agent() session.headers["Content-Type"] = "application/json" - api_key = api_key_override or get_api_key() + api_key = get_api_key() if api_key_override is _UNSET else api_key_override if api_key: session.headers["Authorization"] = f"Bearer {api_key}" @@ -107,7 +109,7 @@ def get_authenticated_requests_session( def get_authenticated_aiohttp_session( timeout: float = 300.0, - api_key_override: Optional[str] = None, + api_key_override: Union[Optional[str], object] = _UNSET, use_threaded_resolver: bool = True, ) -> ClientSession: """Create aiohttp ClientSession with RunPod authentication and User-Agent. @@ -142,7 +144,7 @@ def get_authenticated_aiohttp_session( "Content-Type": "application/json", } - api_key = api_key_override or get_api_key() + api_key = get_api_key() if api_key_override is _UNSET else api_key_override if api_key: headers["Authorization"] = f"Bearer {api_key}" diff --git a/tests/unit/test_credentials_extended.py b/tests/unit/test_credentials_extended.py index 9c9e6629..0974e238 100644 --- a/tests/unit/test_credentials_extended.py +++ b/tests/unit/test_credentials_extended.py @@ -229,3 +229,35 @@ def test_requests_session_no_auth_without_key(self, tmp_path): assert "Authorization" not in session.headers finally: session.close() + + @pytest.mark.asyncio + async def test_httpx_explicit_none_override_skips_env_key(self): + """Passing api_key_override=None explicitly must NOT fall back to env var.""" + with patch.dict(os.environ, {"RUNPOD_API_KEY": "env-key"}, clear=True): + from runpod_flash.core.utils.http import get_authenticated_httpx_client + + async with get_authenticated_httpx_client(api_key_override=None) as client: + assert "Authorization" not in client.headers + + def test_requests_explicit_none_override_skips_env_key(self): + """Passing api_key_override=None explicitly must NOT fall back to env var.""" + with patch.dict(os.environ, {"RUNPOD_API_KEY": "env-key"}, clear=True): + from runpod_flash.core.utils.http import get_authenticated_requests_session + + session = get_authenticated_requests_session(api_key_override=None) + try: + assert "Authorization" not in session.headers + finally: + session.close() + + @pytest.mark.asyncio + async def test_aiohttp_explicit_none_override_skips_env_key(self): + """Passing api_key_override=None explicitly must NOT fall back to env var.""" + with patch.dict(os.environ, {"RUNPOD_API_KEY": "env-key"}, clear=True): + from runpod_flash.core.utils.http import get_authenticated_aiohttp_session + + session = get_authenticated_aiohttp_session(api_key_override=None) + try: + assert "Authorization" not in session.headers + finally: + await session.close() diff --git a/tests/unit/test_login_extended.py b/tests/unit/test_login_extended.py index bc0de446..5825c62a 100644 --- a/tests/unit/test_login_extended.py +++ b/tests/unit/test_login_extended.py @@ -355,3 +355,23 @@ async def test_session_includes_auth_header_when_key_set(self): assert "my-key" in session.headers["Authorization"] finally: await session.close() + + @pytest.mark.asyncio + async def test_no_auth_header_when_require_api_key_false_despite_env_var(self): + """require_api_key=False must not send auth even when RUNPOD_API_KEY is set. + + This is the exact bug scenario: flash login sets require_api_key=False + but RUNPOD_API_KEY in the environment was leaking into the session, + causing the server to see an authenticated user instead of a guest. + """ + with patch.dict(os.environ, {"RUNPOD_API_KEY": "existing-key"}, clear=True): + from runpod_flash.core.api.runpod import RunpodGraphQLClient + + client = RunpodGraphQLClient(require_api_key=False) + assert client.api_key is None + + session = await client._get_session() + try: + assert "Authorization" not in session.headers + finally: + await session.close() From 4b65b0b14a8068791c167f6f0711b24afcda5b5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Wed, 11 Mar 2026 18:19:46 -0700 Subject: [PATCH 02/11] test(login): add missing coverage for credentials file and combined auth scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace mocked get_api_key test with real credential resolution (fresh user) - Add test: credentials file has key + require_api_key=False → no auth header - Add test: both env var and credentials file + require_api_key=False → no auth header --- tests/unit/test_login_extended.py | 74 +++++++++++++++++++++++++++---- 1 file changed, 65 insertions(+), 9 deletions(-) diff --git a/tests/unit/test_login_extended.py b/tests/unit/test_login_extended.py index 5825c62a..346d1ed0 100644 --- a/tests/unit/test_login_extended.py +++ b/tests/unit/test_login_extended.py @@ -321,21 +321,24 @@ class TestGraphQLSessionWithoutApiKey: """Test that _get_session omits Authorization when api_key is None.""" @pytest.mark.asyncio - async def test_session_omits_auth_header_when_no_key(self): - """Session created without Authorization header when api_key is None.""" - from runpod_flash.core.api.runpod import RunpodGraphQLClient + async def test_fresh_user_no_key_anywhere(self, tmp_path): + """Fresh user: no env var, no credentials file — no auth header. - # Ensure no API key is discoverable from env or credentials file - with ( - patch.dict(os.environ, {}, clear=True), - patch("runpod_flash.core.utils.http.get_api_key", return_value=None), + Uses real credential resolution (no mocks on get_api_key) to verify + the sentinel default works end-to-end. + """ + creds = tmp_path / "nonexistent.toml" + + with patch.dict( + os.environ, {"RUNPOD_CREDENTIALS_FILE": str(creds)}, clear=True ): + from runpod_flash.core.api.runpod import RunpodGraphQLClient + client = RunpodGraphQLClient(require_api_key=False) assert client.api_key is None session = await client._get_session() try: - # Default headers should NOT have Authorization assert "Authorization" not in session.headers finally: await session.close() @@ -358,7 +361,7 @@ async def test_session_includes_auth_header_when_key_set(self): @pytest.mark.asyncio async def test_no_auth_header_when_require_api_key_false_despite_env_var(self): - """require_api_key=False must not send auth even when RUNPOD_API_KEY is set. + """Re-login with RUNPOD_API_KEY env var set must not send auth. This is the exact bug scenario: flash login sets require_api_key=False but RUNPOD_API_KEY in the environment was leaking into the session, @@ -375,3 +378,56 @@ async def test_no_auth_header_when_require_api_key_false_despite_env_var(self): assert "Authorization" not in session.headers finally: await session.close() + + @pytest.mark.asyncio + async def test_no_auth_header_when_credentials_file_has_key(self, tmp_path): + """Re-login after prior flash login (key in credentials file) must not send auth. + + A previous successful flash login writes the API key to credentials.toml. + Running flash login again must still act as a guest — the stored key must + not leak into the session. + """ + creds = tmp_path / "credentials.toml" + creds.write_text('api_key = "previously-saved-key"\n') + + with patch.dict( + os.environ, {"RUNPOD_CREDENTIALS_FILE": str(creds)}, clear=True + ): + from runpod_flash.core.api.runpod import RunpodGraphQLClient + + client = RunpodGraphQLClient(require_api_key=False) + assert client.api_key is None + + session = await client._get_session() + try: + assert "Authorization" not in session.headers + finally: + await session.close() + + @pytest.mark.asyncio + async def test_no_auth_header_when_both_env_var_and_credentials_file( + self, tmp_path + ): + """Re-login with both env var and credentials file must not send auth. + + Covers the force re-login scenario where both RUNPOD_API_KEY and a + credentials file with a stored key are present simultaneously. + """ + creds = tmp_path / "credentials.toml" + creds.write_text('api_key = "file-key"\n') + + with patch.dict( + os.environ, + {"RUNPOD_API_KEY": "env-key", "RUNPOD_CREDENTIALS_FILE": str(creds)}, + clear=True, + ): + from runpod_flash.core.api.runpod import RunpodGraphQLClient + + client = RunpodGraphQLClient(require_api_key=False) + assert client.api_key is None + + session = await client._get_session() + try: + assert "Authorization" not in session.headers + finally: + await session.close() From 1df18f725e03b8c1898d0aee90d2fc075c4dd0d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Thu, 12 Mar 2026 01:31:00 -0700 Subject: [PATCH 03/11] refactor(credentials): delegate to runpod-python set_credentials/get_credentials --- src/runpod_flash/core/credentials.py | 124 ++++++++++++++++++++------- tests/conftest.py | 11 ++- 2 files changed, 103 insertions(+), 32 deletions(-) diff --git a/src/runpod_flash/core/credentials.py b/src/runpod_flash/core/credentials.py index 9d48b871..5dc952c1 100644 --- a/src/runpod_flash/core/credentials.py +++ b/src/runpod_flash/core/credentials.py @@ -1,57 +1,123 @@ +"""Credential management for runpod_flash. + +Thin wrappers around runpod-python's credential functions. +Resolution priority: RUNPOD_API_KEY env var > .env > ~/.runpod/config.toml +""" + from __future__ import annotations +import logging import os from pathlib import Path from typing import Optional -try: - import tomllib -except ImportError: # python < 3.11 - import tomli as tomllib - +from runpod.cli.groups.config.functions import ( + CREDENTIAL_FILE, + get_credentials, + set_credentials, +) -def get_credentials_path() -> Path: - credentials_file = os.getenv("RUNPOD_CREDENTIALS_FILE") - if credentials_file: - return Path(credentials_file).expanduser() - - config_home = os.getenv("XDG_CONFIG_HOME") - base_dir = ( - Path(config_home).expanduser() if config_home else Path.home() / ".config" - ) - return base_dir / "runpod" / "credentials.toml" +log = logging.getLogger(__name__) +_OLD_XDG_PATHS = ( + Path.home() / ".config" / "runpod" / "credentials.toml", +) -def _read_credentials() -> dict: - path = get_credentials_path() - if not path.exists(): - return {} - try: - with path.open("rb") as handle: - return tomllib.load(handle) - except (OSError, ValueError): - return {} +def get_credentials_path() -> Path: + """Return the path to the runpod credentials file.""" + return Path(CREDENTIAL_FILE) def get_api_key() -> Optional[str]: + """Get API key with priority: env var > credentials file. + + Returns: + API key string, or None if not found. + """ api_key = os.getenv("RUNPOD_API_KEY") if api_key and api_key.strip(): return api_key - stored = _read_credentials().get("api_key") - if isinstance(stored, str) and stored.strip(): - return stored + creds = get_credentials() + if creds and isinstance(creds.get("api_key"), str) and creds["api_key"].strip(): + return creds["api_key"] return None def save_api_key(api_key: str) -> Path: + """Save API key to ~/.runpod/config.toml via runpod-python. + + Args: + api_key: The API key to save. + + Returns: + Path to the credentials file. + """ path = get_credentials_path() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(f'api_key = "{api_key}"\n', encoding="utf-8") + set_credentials(api_key, overwrite=True) try: os.chmod(path, 0o600) except OSError: pass return path + + +def check_and_migrate_legacy_credentials() -> None: + """Check for credentials at old XDG path and offer migration. + + Called during flash login before saving new credentials. + If old file exists and new file has no credentials, prompts user to migrate. + """ + try: + import tomllib + except ImportError: + import tomli as tomllib + + existing_creds = get_credentials() + if existing_creds and existing_creds.get("api_key"): + return + + new_path = get_credentials_path() + + for old_path in _OLD_XDG_PATHS: + if not old_path.exists(): + continue + + try: + with old_path.open("rb") as f: + old_data = tomllib.load(f) + old_key = old_data.get("api_key") + if not isinstance(old_key, str) or not old_key.strip(): + continue + except (OSError, ValueError): + continue + + log.info("Found credentials at legacy path: %s", old_path) + + try: + from rich.console import Console + + console = Console() + console.print( + f"\n[yellow]Found credentials at old location:[/yellow]" + f"\n {old_path}" + f"\n[yellow]Migrating to:[/yellow]" + f"\n {new_path}\n" + ) + save_api_key(old_key) + old_path.unlink() + try: + old_path.parent.rmdir() + except OSError: + pass + console.print("[green]Migrated.[/green] Old file removed.\n") + except Exception: + log.warning( + "Could not migrate credentials from %s to %s. " + "Run 'flash login' to create new credentials.", + old_path, + new_path, + ) + return diff --git a/tests/conftest.py b/tests/conftest.py index 28b29f2b..9ba17851 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -185,9 +185,14 @@ def mock_logger(): @pytest.fixture(autouse=True) def isolate_credentials_file(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: - """force tests to use a temp credentials file.""" - credentials_path = tmp_path / "credentials.toml" - monkeypatch.setenv("RUNPOD_CREDENTIALS_FILE", str(credentials_path)) + """Force tests to use a temp credentials file.""" + credentials_path = tmp_path / "config.toml" + monkeypatch.setattr( + "runpod.cli.groups.config.functions.CREDENTIAL_FILE", + str(credentials_path), + ) + # Keep env var isolation for RUNPOD_API_KEY + monkeypatch.delenv("RUNPOD_API_KEY", raising=False) return credentials_path From cc9ded6f2340ebd8c60fbc3b9834a7b1113fc2b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Thu, 12 Mar 2026 01:31:50 -0700 Subject: [PATCH 04/11] feat(login): migrate legacy XDG credentials on flash login --- src/runpod_flash/cli/commands/login.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/runpod_flash/cli/commands/login.py b/src/runpod_flash/cli/commands/login.py index d293f335..6572345b 100644 --- a/src/runpod_flash/cli/commands/login.py +++ b/src/runpod_flash/cli/commands/login.py @@ -6,7 +6,7 @@ from rich.console import Console from runpod_flash.core.api.runpod import RunpodGraphQLClient -from runpod_flash.core.credentials import save_api_key +from runpod_flash.core.credentials import check_and_migrate_legacy_credentials, save_api_key from runpod_flash.core.resources.constants import CONSOLE_BASE_URL console = Console() @@ -41,6 +41,9 @@ async def _login(open_browser: bool, timeout_seconds: float) -> None: if open_browser: typer.launch(auth_url) + # Migrate legacy credentials if present + check_and_migrate_legacy_credentials() + expires_at = _parse_expires_at(request.get("expiresAt")) deadline = dt.datetime.now(dt.timezone.utc) + dt.timedelta( seconds=timeout_seconds From 09345039eacfc50640d1b1762f77bbd12833a821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Thu, 12 Mar 2026 01:34:20 -0700 Subject: [PATCH 05/11] test(credentials): rewrite credential tests for runpod-python format Remove _read_credentials references (no longer exists), RUNPOD_CREDENTIALS_FILE env var usage, and clear=True from patch.dict calls. All tests now use the autouse isolate_credentials_file fixture and write runpod-python profile format ([default] section). Also fix credentials.py to read CREDENTIAL_FILE dynamically via module reference (enables monkeypatch) and catch TOMLDecodeError from get_credentials() for corrupt file handling. --- src/runpod_flash/core/credentials.py | 11 +- tests/unit/test_credentials.py | 131 ++++++----------- tests/unit/test_credentials_extended.py | 188 ++++++++---------------- 3 files changed, 116 insertions(+), 214 deletions(-) diff --git a/src/runpod_flash/core/credentials.py b/src/runpod_flash/core/credentials.py index 5dc952c1..de91dcb9 100644 --- a/src/runpod_flash/core/credentials.py +++ b/src/runpod_flash/core/credentials.py @@ -11,8 +11,9 @@ from pathlib import Path from typing import Optional +import runpod.cli.groups.config.functions as _runpod_config + from runpod.cli.groups.config.functions import ( - CREDENTIAL_FILE, get_credentials, set_credentials, ) @@ -26,7 +27,7 @@ def get_credentials_path() -> Path: """Return the path to the runpod credentials file.""" - return Path(CREDENTIAL_FILE) + return Path(_runpod_config.CREDENTIAL_FILE) def get_api_key() -> Optional[str]: @@ -39,7 +40,11 @@ def get_api_key() -> Optional[str]: if api_key and api_key.strip(): return api_key - creds = get_credentials() + try: + creds = get_credentials() + except Exception: + log.debug("Failed to read credentials file", exc_info=True) + return None if creds and isinstance(creds.get("api_key"), str) and creds["api_key"].strip(): return creds["api_key"] diff --git a/tests/unit/test_credentials.py b/tests/unit/test_credentials.py index ea4a7d96..6ce4d1c0 100644 --- a/tests/unit/test_credentials.py +++ b/tests/unit/test_credentials.py @@ -11,104 +11,61 @@ ) -class TestGetCredentialsPath: - def test_default_path(self): - with patch.dict(os.environ, {}, clear=True): - path = get_credentials_path() - assert path == Path.home() / ".config" / "runpod" / "credentials.toml" +def _write_config_toml(path: Path, api_key: str, profile: str = "default") -> None: + """Write a runpod-python format config.toml.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f'[{profile}]\napi_key = "{api_key}"\n') - def test_xdg_config_home(self, tmp_path): - with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(tmp_path)}, clear=True): - path = get_credentials_path() - assert path == tmp_path / "runpod" / "credentials.toml" - def test_custom_credentials_file(self, tmp_path): - custom = tmp_path / "custom.toml" - with patch.dict( - os.environ, {"RUNPOD_CREDENTIALS_FILE": str(custom)}, clear=True - ): - path = get_credentials_path() - assert path == custom +class TestGetCredentialsPath: + def test_returns_runpod_config_path(self): + path = get_credentials_path() + assert path.name == "config.toml" class TestGetApiKey: - def test_env_var_takes_precedence(self, tmp_path): - creds = tmp_path / "credentials.toml" - creds.write_text('api_key = "stored-key"\n') - with patch.dict( - os.environ, - {"RUNPOD_API_KEY": "env-key", "RUNPOD_CREDENTIALS_FILE": str(creds)}, - clear=True, - ): + def test_env_var_takes_precedence(self, isolate_credentials_file): + _write_config_toml(isolate_credentials_file, "stored-key") + with patch.dict(os.environ, {"RUNPOD_API_KEY": "env-key"}): assert get_api_key() == "env-key" - def test_falls_back_to_credentials_file(self, tmp_path): - creds = tmp_path / "credentials.toml" - creds.write_text('api_key = "stored-key"\n') - with patch.dict( - os.environ, {"RUNPOD_CREDENTIALS_FILE": str(creds)}, clear=True - ): - assert get_api_key() == "stored-key" + def test_falls_back_to_credentials_file(self, isolate_credentials_file): + _write_config_toml(isolate_credentials_file, "stored-key") + assert get_api_key() == "stored-key" + + def test_returns_none_when_nothing_set(self): + assert get_api_key() is None - def test_returns_none_when_nothing_set(self, tmp_path): - creds = tmp_path / "nonexistent.toml" - with patch.dict( - os.environ, {"RUNPOD_CREDENTIALS_FILE": str(creds)}, clear=True - ): - assert get_api_key() is None - - def test_ignores_blank_env_var(self, tmp_path): - creds = tmp_path / "credentials.toml" - creds.write_text('api_key = "stored-key"\n') - with patch.dict( - os.environ, - {"RUNPOD_API_KEY": " ", "RUNPOD_CREDENTIALS_FILE": str(creds)}, - clear=True, - ): + def test_ignores_blank_env_var(self, isolate_credentials_file): + _write_config_toml(isolate_credentials_file, "stored-key") + with patch.dict(os.environ, {"RUNPOD_API_KEY": " "}): assert get_api_key() == "stored-key" - def test_ignores_blank_stored_key(self, tmp_path): - creds = tmp_path / "credentials.toml" - creds.write_text('api_key = " "\n') - with patch.dict( - os.environ, {"RUNPOD_CREDENTIALS_FILE": str(creds)}, clear=True - ): - assert get_api_key() is None + def test_ignores_blank_stored_key(self, isolate_credentials_file): + _write_config_toml(isolate_credentials_file, " ") + assert get_api_key() is None - def test_handles_corrupt_credentials_file(self, tmp_path): - creds = tmp_path / "credentials.toml" - creds.write_text("not valid toml {{{{") - with patch.dict( - os.environ, {"RUNPOD_CREDENTIALS_FILE": str(creds)}, clear=True - ): - assert get_api_key() is None + def test_handles_corrupt_credentials_file(self, isolate_credentials_file): + isolate_credentials_file.parent.mkdir(parents=True, exist_ok=True) + isolate_credentials_file.write_text("not valid toml {{{{") + assert get_api_key() is None class TestSaveApiKey: - def test_creates_file_and_directories(self, tmp_path): - creds = tmp_path / "deep" / "nested" / "credentials.toml" - with patch.dict( - os.environ, {"RUNPOD_CREDENTIALS_FILE": str(creds)}, clear=True - ): - result = save_api_key("my-new-key") - assert result == creds - assert creds.exists() - assert 'api_key = "my-new-key"' in creds.read_text() - - def test_overwrites_existing_file(self, tmp_path): - creds = tmp_path / "credentials.toml" - creds.write_text('api_key = "old-key"\n') - with patch.dict( - os.environ, {"RUNPOD_CREDENTIALS_FILE": str(creds)}, clear=True - ): - save_api_key("new-key") - assert 'api_key = "new-key"' in creds.read_text() - - def test_sets_restrictive_permissions(self, tmp_path): - creds = tmp_path / "credentials.toml" - with patch.dict( - os.environ, {"RUNPOD_CREDENTIALS_FILE": str(creds)}, clear=True - ): - save_api_key("secret") - mode = oct(creds.stat().st_mode & 0o777) - assert mode == "0o600" + def test_creates_file_and_directories(self, isolate_credentials_file): + result = save_api_key("my-new-key") + assert result == isolate_credentials_file + assert isolate_credentials_file.exists() + content = isolate_credentials_file.read_text() + assert "my-new-key" in content + + def test_overwrites_existing_file(self, isolate_credentials_file): + _write_config_toml(isolate_credentials_file, "old-key") + save_api_key("new-key") + content = isolate_credentials_file.read_text() + assert "new-key" in content + + def test_sets_restrictive_permissions(self, isolate_credentials_file): + save_api_key("secret") + mode = oct(isolate_credentials_file.stat().st_mode & 0o777) + assert mode == "0o600" diff --git a/tests/unit/test_credentials_extended.py b/tests/unit/test_credentials_extended.py index 0974e238..5918e2fe 100644 --- a/tests/unit/test_credentials_extended.py +++ b/tests/unit/test_credentials_extended.py @@ -1,7 +1,6 @@ """Extended credential tests covering edge cases. Gaps from existing test_credentials.py: -- _read_credentials with OSError (permissions) - get_api_key with non-string stored value - RunpodAPIKeyError._default_message content - validate_api_key and validate_api_key_with_context direct tests @@ -14,58 +13,35 @@ import pytest -from runpod_flash.core.credentials import get_api_key, _read_credentials +from runpod_flash.core.credentials import get_api_key from runpod_flash.core.exceptions import RunpodAPIKeyError from runpod_flash.core.validation import validate_api_key, validate_api_key_with_context -# ── _read_credentials edge cases ───────────────────────────────────────── +def _write_config_toml(path: Path, api_key: str, profile: str = "default") -> None: + """Write a runpod-python format config.toml.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f'[{profile}]\napi_key = "{api_key}"\n') -class TestReadCredentialsEdgeCases: - """Edge cases for _read_credentials.""" - - def test_returns_empty_on_os_error(self, tmp_path): - """Returns {} when file exists but can't be opened (OSError).""" - creds = tmp_path / "credentials.toml" - creds.write_text('api_key = "test"\n') - - with ( - patch.dict(os.environ, {"RUNPOD_CREDENTIALS_FILE": str(creds)}, clear=True), - patch.object(Path, "open", side_effect=OSError("Permission denied")), - ): - # path.exists() returns True, but path.open() raises - # _read_credentials should catch OSError and return {} - result = _read_credentials() - - assert result == {} +# ── get_api_key edge cases ─────────────────────────────────────────────── class TestGetApiKeyEdgeCases: """Edge cases for get_api_key.""" - def test_non_string_stored_value(self, tmp_path): + def test_non_string_stored_value(self, isolate_credentials_file): """Returns None when stored api_key is not a string (e.g. integer).""" - creds = tmp_path / "credentials.toml" - creds.write_text("api_key = 12345\n") - - with patch.dict( - os.environ, {"RUNPOD_CREDENTIALS_FILE": str(creds)}, clear=True - ): - result = get_api_key() - + isolate_credentials_file.parent.mkdir(parents=True, exist_ok=True) + isolate_credentials_file.write_text("[default]\napi_key = 12345\n") + result = get_api_key() assert result is None - def test_list_stored_value(self, tmp_path): + def test_list_stored_value(self, isolate_credentials_file): """Returns None when stored api_key is a list.""" - creds = tmp_path / "credentials.toml" - creds.write_text('api_key = ["not", "a", "key"]\n') - - with patch.dict( - os.environ, {"RUNPOD_CREDENTIALS_FILE": str(creds)}, clear=True - ): - result = get_api_key() - + isolate_credentials_file.parent.mkdir(parents=True, exist_ok=True) + isolate_credentials_file.write_text('[default]\napi_key = ["not", "a", "key"]\n') + result = get_api_key() assert result is None @@ -84,8 +60,7 @@ def test_default_message_includes_credentials_path(self): """Error message includes the credentials file path.""" err = RunpodAPIKeyError() message = str(err) - # Should include some reference to credentials.toml - assert "credentials" in message.lower() + assert "config.toml" in message def test_default_message_includes_env_var_instructions(self): """Error message includes RUNPOD_API_KEY instructions.""" @@ -106,31 +81,22 @@ def test_custom_message_overrides_default(self): class TestValidateApiKey: """Direct tests for validate_api_key.""" - def test_returns_key_when_set(self, tmp_path): + def test_returns_key_when_set(self): """Returns the API key when available.""" - with patch.dict(os.environ, {"RUNPOD_API_KEY": "valid-key"}, clear=True): + with patch.dict(os.environ, {"RUNPOD_API_KEY": "valid-key"}): result = validate_api_key() assert result == "valid-key" - def test_raises_when_no_key(self, tmp_path): + def test_raises_when_no_key(self): """Raises RunpodAPIKeyError when no key is available.""" - creds = tmp_path / "nonexistent.toml" - with patch.dict( - os.environ, {"RUNPOD_CREDENTIALS_FILE": str(creds)}, clear=True - ): - with pytest.raises(RunpodAPIKeyError): - validate_api_key() - - def test_reads_from_credentials_file(self, tmp_path): - """Returns key from credentials file when env var is unset.""" - creds = tmp_path / "credentials.toml" - creds.write_text('api_key = "file-key"\n') + with pytest.raises(RunpodAPIKeyError): + validate_api_key() - with patch.dict( - os.environ, {"RUNPOD_CREDENTIALS_FILE": str(creds)}, clear=True - ): - result = validate_api_key() - assert result == "file-key" + def test_reads_from_credentials_file(self, isolate_credentials_file): + """Returns key from credentials file when env var is unset.""" + _write_config_toml(isolate_credentials_file, "file-key") + result = validate_api_key() + assert result == "file-key" class TestValidateApiKeyWithContext: @@ -138,30 +104,22 @@ class TestValidateApiKeyWithContext: def test_returns_key_when_set(self): """Returns the API key when available.""" - with patch.dict(os.environ, {"RUNPOD_API_KEY": "valid-key"}, clear=True): + with patch.dict(os.environ, {"RUNPOD_API_KEY": "valid-key"}): result = validate_api_key_with_context("deploy endpoints") assert result == "valid-key" - def test_raises_with_operation_context(self, tmp_path): + def test_raises_with_operation_context(self): """Raises RunpodAPIKeyError with operation context in message.""" - creds = tmp_path / "nonexistent.toml" - with patch.dict( - os.environ, {"RUNPOD_CREDENTIALS_FILE": str(creds)}, clear=True - ): - with pytest.raises(RunpodAPIKeyError, match="Cannot deploy endpoints"): - validate_api_key_with_context("deploy endpoints") - - def test_error_chains_from_original(self, tmp_path): + with pytest.raises(RunpodAPIKeyError, match="Cannot deploy endpoints"): + validate_api_key_with_context("deploy endpoints") + + def test_error_chains_from_original(self): """Error is chained from the original RunpodAPIKeyError.""" - creds = tmp_path / "nonexistent.toml" - with patch.dict( - os.environ, {"RUNPOD_CREDENTIALS_FILE": str(creds)}, clear=True - ): - with pytest.raises(RunpodAPIKeyError) as exc_info: - validate_api_key_with_context("test operation") + with pytest.raises(RunpodAPIKeyError) as exc_info: + validate_api_key_with_context("test operation") - assert exc_info.value.__cause__ is not None - assert isinstance(exc_info.value.__cause__, RunpodAPIKeyError) + assert exc_info.value.__cause__ is not None + assert isinstance(exc_info.value.__cause__, RunpodAPIKeyError) # ── http.py credentials-file fallback ──────────────────────────────────── @@ -171,69 +129,51 @@ class TestHttpCredentialsFallback: """Test that http.py helpers use credentials file when env var is unset.""" @pytest.mark.asyncio - async def test_httpx_client_uses_credentials_file_key(self, tmp_path): + async def test_httpx_client_uses_credentials_file_key(self, isolate_credentials_file): """get_authenticated_httpx_client uses credentials file for auth.""" - creds = tmp_path / "credentials.toml" - creds.write_text('api_key = "cred-file-key"\n') + _write_config_toml(isolate_credentials_file, "cred-file-key") - with patch.dict( - os.environ, {"RUNPOD_CREDENTIALS_FILE": str(creds)}, clear=True - ): - from runpod_flash.core.utils.http import get_authenticated_httpx_client + from runpod_flash.core.utils.http import get_authenticated_httpx_client - async with get_authenticated_httpx_client() as client: - assert "Authorization" in client.headers - assert "cred-file-key" in client.headers["Authorization"] + async with get_authenticated_httpx_client() as client: + assert "Authorization" in client.headers + assert "cred-file-key" in client.headers["Authorization"] @pytest.mark.asyncio - async def test_httpx_client_no_auth_without_key(self, tmp_path): + async def test_httpx_client_no_auth_without_key(self): """get_authenticated_httpx_client omits auth when no key available.""" - creds = tmp_path / "nonexistent.toml" + from runpod_flash.core.utils.http import get_authenticated_httpx_client - with patch.dict( - os.environ, {"RUNPOD_CREDENTIALS_FILE": str(creds)}, clear=True - ): - from runpod_flash.core.utils.http import get_authenticated_httpx_client - - async with get_authenticated_httpx_client() as client: - assert "Authorization" not in client.headers + async with get_authenticated_httpx_client() as client: + assert "Authorization" not in client.headers - def test_requests_session_uses_credentials_file_key(self, tmp_path): + def test_requests_session_uses_credentials_file_key(self, isolate_credentials_file): """get_authenticated_requests_session uses credentials file for auth.""" - creds = tmp_path / "credentials.toml" - creds.write_text('api_key = "cred-file-key"\n') + _write_config_toml(isolate_credentials_file, "cred-file-key") - with patch.dict( - os.environ, {"RUNPOD_CREDENTIALS_FILE": str(creds)}, clear=True - ): - from runpod_flash.core.utils.http import get_authenticated_requests_session + from runpod_flash.core.utils.http import get_authenticated_requests_session - session = get_authenticated_requests_session() - try: - assert "Authorization" in session.headers - assert "cred-file-key" in session.headers["Authorization"] - finally: - session.close() + session = get_authenticated_requests_session() + try: + assert "Authorization" in session.headers + assert "cred-file-key" in session.headers["Authorization"] + finally: + session.close() - def test_requests_session_no_auth_without_key(self, tmp_path): + def test_requests_session_no_auth_without_key(self): """get_authenticated_requests_session omits auth when no key available.""" - creds = tmp_path / "nonexistent.toml" + from runpod_flash.core.utils.http import get_authenticated_requests_session - with patch.dict( - os.environ, {"RUNPOD_CREDENTIALS_FILE": str(creds)}, clear=True - ): - from runpod_flash.core.utils.http import get_authenticated_requests_session - - session = get_authenticated_requests_session() - try: - assert "Authorization" not in session.headers - finally: - session.close() + session = get_authenticated_requests_session() + try: + assert "Authorization" not in session.headers + finally: + session.close() @pytest.mark.asyncio async def test_httpx_explicit_none_override_skips_env_key(self): """Passing api_key_override=None explicitly must NOT fall back to env var.""" - with patch.dict(os.environ, {"RUNPOD_API_KEY": "env-key"}, clear=True): + with patch.dict(os.environ, {"RUNPOD_API_KEY": "env-key"}): from runpod_flash.core.utils.http import get_authenticated_httpx_client async with get_authenticated_httpx_client(api_key_override=None) as client: @@ -241,7 +181,7 @@ async def test_httpx_explicit_none_override_skips_env_key(self): def test_requests_explicit_none_override_skips_env_key(self): """Passing api_key_override=None explicitly must NOT fall back to env var.""" - with patch.dict(os.environ, {"RUNPOD_API_KEY": "env-key"}, clear=True): + with patch.dict(os.environ, {"RUNPOD_API_KEY": "env-key"}): from runpod_flash.core.utils.http import get_authenticated_requests_session session = get_authenticated_requests_session(api_key_override=None) @@ -253,7 +193,7 @@ def test_requests_explicit_none_override_skips_env_key(self): @pytest.mark.asyncio async def test_aiohttp_explicit_none_override_skips_env_key(self): """Passing api_key_override=None explicitly must NOT fall back to env var.""" - with patch.dict(os.environ, {"RUNPOD_API_KEY": "env-key"}, clear=True): + with patch.dict(os.environ, {"RUNPOD_API_KEY": "env-key"}): from runpod_flash.core.utils.http import get_authenticated_aiohttp_session session = get_authenticated_aiohttp_session(api_key_override=None) From 49f88db9397c516427dde8d7ca5ef59de14ee74a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Thu, 12 Mar 2026 01:36:48 -0700 Subject: [PATCH 06/11] test(login): update login tests for runpod-python credential format --- tests/unit/test_login.py | 53 +++++----------- tests/unit/test_login_extended.py | 102 ++++++++++++------------------ 2 files changed, 58 insertions(+), 97 deletions(-) diff --git a/tests/unit/test_login.py b/tests/unit/test_login.py index 532d7f81..3ccb4cdd 100644 --- a/tests/unit/test_login.py +++ b/tests/unit/test_login.py @@ -27,26 +27,19 @@ def test_invalid_string(self): class TestGraphQLClientNoKeyForLogin: """Login mutations must not send stored credentials.""" - def test_require_api_key_false_does_not_load_stored_key(self, tmp_path): - creds = tmp_path / "credentials.toml" - creds.write_text('api_key = "stale-expired-key"\n') - - with patch.dict( - os.environ, - {"RUNPOD_CREDENTIALS_FILE": str(creds)}, - clear=True, - ): - from runpod_flash.core.api.runpod import RunpodGraphQLClient + def test_require_api_key_false_does_not_load_stored_key( + self, isolate_credentials_file + ): + isolate_credentials_file.parent.mkdir(parents=True, exist_ok=True) + isolate_credentials_file.write_text('[default]\napi_key = "stale-expired-key"\n') - client = RunpodGraphQLClient(require_api_key=False) - assert client.api_key is None + from runpod_flash.core.api.runpod import RunpodGraphQLClient + + client = RunpodGraphQLClient(require_api_key=False) + assert client.api_key is None def test_require_api_key_false_does_not_load_env_var(self): - with patch.dict( - os.environ, - {"RUNPOD_API_KEY": "env-key"}, - clear=True, - ): + with patch.dict(os.environ, {"RUNPOD_API_KEY": "env-key"}): from runpod_flash.core.api.runpod import RunpodGraphQLClient client = RunpodGraphQLClient(require_api_key=False) @@ -59,11 +52,7 @@ def test_require_api_key_false_allows_explicit_key(self): assert client.api_key == "explicit" def test_require_api_key_true_loads_key(self): - with patch.dict( - os.environ, - {"RUNPOD_API_KEY": "loaded-key"}, - clear=True, - ): + with patch.dict(os.environ, {"RUNPOD_API_KEY": "loaded-key"}): from runpod_flash.core.api.runpod import RunpodGraphQLClient client = RunpodGraphQLClient(require_api_key=True) @@ -105,25 +94,17 @@ async def test_login_denied(self): with pytest.raises(RuntimeError, match="login failed: denied"): await _login(open_browser=False, timeout_seconds=5) - async def test_login_approved_saves_key(self, tmp_path): - creds = tmp_path / "credentials.toml" + async def test_login_approved_saves_key(self, isolate_credentials_file): mock_client = _make_mock_client(status="APPROVED", apiKey="fresh-api-key") _login = _get_login_fn() - with ( - patch( - "runpod_flash.cli.commands.login.RunpodGraphQLClient", - return_value=mock_client, - ), - patch.dict( - os.environ, - {"RUNPOD_CREDENTIALS_FILE": str(creds)}, - clear=True, - ), + with patch( + "runpod_flash.cli.commands.login.RunpodGraphQLClient", + return_value=mock_client, ): await _login(open_browser=False, timeout_seconds=5) - assert creds.exists() - assert "fresh-api-key" in creds.read_text() + assert isolate_credentials_file.exists() + assert "fresh-api-key" in isolate_credentials_file.read_text() async def test_login_expired(self): mock_client = _make_mock_client(status="EXPIRED", apiKey=None) diff --git a/tests/unit/test_login_extended.py b/tests/unit/test_login_extended.py index 346d1ed0..e6d50a77 100644 --- a/tests/unit/test_login_extended.py +++ b/tests/unit/test_login_extended.py @@ -45,9 +45,8 @@ class TestLoginOpenBrowser: """Test the open_browser=True path.""" @pytest.mark.asyncio - async def test_open_browser_calls_typer_launch(self, tmp_path): + async def test_open_browser_calls_typer_launch(self, isolate_credentials_file): """When open_browser=True, typer.launch is called with the auth URL.""" - creds = tmp_path / "credentials.toml" mock_client = _make_mock_client(status="APPROVED", apiKey="key-123") with ( @@ -59,10 +58,6 @@ async def test_open_browser_calls_typer_launch(self, tmp_path): patch( "runpod_flash.cli.commands.login.asyncio.sleep", new_callable=AsyncMock ), - patch.dict( - os.environ, - {"RUNPOD_CREDENTIALS_FILE": str(creds)}, - ), patch("runpod_flash.cli.commands.login.console"), ): await _fresh_login_module()._login(open_browser=True, timeout_seconds=5) @@ -77,9 +72,8 @@ class TestLoginConsumedStatus: """Test CONSUMED status handling.""" @pytest.mark.asyncio - async def test_consumed_with_api_key_saves_key(self, tmp_path): + async def test_consumed_with_api_key_saves_key(self, isolate_credentials_file): """CONSUMED with a valid apiKey saves credentials and succeeds.""" - creds = tmp_path / "credentials.toml" mock_client = _make_mock_client(status="CONSUMED", apiKey="consumed-key") with ( @@ -90,16 +84,12 @@ async def test_consumed_with_api_key_saves_key(self, tmp_path): patch( "runpod_flash.cli.commands.login.asyncio.sleep", new_callable=AsyncMock ), - patch.dict( - os.environ, - {"RUNPOD_CREDENTIALS_FILE": str(creds)}, - ), patch("runpod_flash.cli.commands.login.console"), ): await _fresh_login_module()._login(open_browser=False, timeout_seconds=5) - assert creds.exists() - assert "consumed-key" in creds.read_text() + assert isolate_credentials_file.exists() + assert "consumed-key" in isolate_credentials_file.read_text() @pytest.mark.asyncio async def test_consumed_without_api_key_raises(self): @@ -236,7 +226,7 @@ class TestGraphQLAuthMethods: @pytest.mark.asyncio async def test_create_flash_auth_request(self): """create_flash_auth_request sends mutation and returns result.""" - with patch.dict(os.environ, {"RUNPOD_API_KEY": "test-key"}, clear=True): + with patch.dict(os.environ, {"RUNPOD_API_KEY": "test-key"}): from runpod_flash.core.api.runpod import RunpodGraphQLClient client = RunpodGraphQLClient() @@ -263,7 +253,7 @@ async def test_create_flash_auth_request(self): @pytest.mark.asyncio async def test_create_flash_auth_request_empty_response(self): """create_flash_auth_request returns empty dict when key missing.""" - with patch.dict(os.environ, {"RUNPOD_API_KEY": "test-key"}, clear=True): + with patch.dict(os.environ, {"RUNPOD_API_KEY": "test-key"}): from runpod_flash.core.api.runpod import RunpodGraphQLClient client = RunpodGraphQLClient() @@ -275,7 +265,7 @@ async def test_create_flash_auth_request_empty_response(self): @pytest.mark.asyncio async def test_get_flash_auth_request_status(self): """get_flash_auth_request_status sends query with request_id.""" - with patch.dict(os.environ, {"RUNPOD_API_KEY": "test-key"}, clear=True): + with patch.dict(os.environ, {"RUNPOD_API_KEY": "test-key"}): from runpod_flash.core.api.runpod import RunpodGraphQLClient client = RunpodGraphQLClient() @@ -303,7 +293,7 @@ async def test_get_flash_auth_request_status(self): @pytest.mark.asyncio async def test_get_flash_auth_request_status_empty_response(self): """get_flash_auth_request_status returns empty dict when key missing.""" - with patch.dict(os.environ, {"RUNPOD_API_KEY": "test-key"}, clear=True): + with patch.dict(os.environ, {"RUNPOD_API_KEY": "test-key"}): from runpod_flash.core.api.runpod import RunpodGraphQLClient client = RunpodGraphQLClient() @@ -321,32 +311,27 @@ class TestGraphQLSessionWithoutApiKey: """Test that _get_session omits Authorization when api_key is None.""" @pytest.mark.asyncio - async def test_fresh_user_no_key_anywhere(self, tmp_path): - """Fresh user: no env var, no credentials file — no auth header. + async def test_fresh_user_no_key_anywhere(self): + """Fresh user: no env var, no credentials file -- no auth header. - Uses real credential resolution (no mocks on get_api_key) to verify - the sentinel default works end-to-end. + The autouse isolate_credentials_file fixture ensures no credentials + file exists and RUNPOD_API_KEY is deleted from the environment. """ - creds = tmp_path / "nonexistent.toml" - - with patch.dict( - os.environ, {"RUNPOD_CREDENTIALS_FILE": str(creds)}, clear=True - ): - from runpod_flash.core.api.runpod import RunpodGraphQLClient + from runpod_flash.core.api.runpod import RunpodGraphQLClient - client = RunpodGraphQLClient(require_api_key=False) - assert client.api_key is None + client = RunpodGraphQLClient(require_api_key=False) + assert client.api_key is None - session = await client._get_session() - try: - assert "Authorization" not in session.headers - finally: - await session.close() + session = await client._get_session() + try: + assert "Authorization" not in session.headers + finally: + await session.close() @pytest.mark.asyncio async def test_session_includes_auth_header_when_key_set(self): """Session created with Authorization header when api_key is provided.""" - with patch.dict(os.environ, {"RUNPOD_API_KEY": "my-key"}, clear=True): + with patch.dict(os.environ, {"RUNPOD_API_KEY": "my-key"}): from runpod_flash.core.api.runpod import RunpodGraphQLClient client = RunpodGraphQLClient() @@ -367,7 +352,7 @@ async def test_no_auth_header_when_require_api_key_false_despite_env_var(self): but RUNPOD_API_KEY in the environment was leaking into the session, causing the server to see an authenticated user instead of a guest. """ - with patch.dict(os.environ, {"RUNPOD_API_KEY": "existing-key"}, clear=True): + with patch.dict(os.environ, {"RUNPOD_API_KEY": "existing-key"}): from runpod_flash.core.api.runpod import RunpodGraphQLClient client = RunpodGraphQLClient(require_api_key=False) @@ -380,47 +365,42 @@ async def test_no_auth_header_when_require_api_key_false_despite_env_var(self): await session.close() @pytest.mark.asyncio - async def test_no_auth_header_when_credentials_file_has_key(self, tmp_path): + async def test_no_auth_header_when_credentials_file_has_key( + self, isolate_credentials_file + ): """Re-login after prior flash login (key in credentials file) must not send auth. - A previous successful flash login writes the API key to credentials.toml. - Running flash login again must still act as a guest — the stored key must + A previous successful flash login writes the API key to the credentials file. + Running flash login again must still act as a guest -- the stored key must not leak into the session. """ - creds = tmp_path / "credentials.toml" - creds.write_text('api_key = "previously-saved-key"\n') + isolate_credentials_file.parent.mkdir(parents=True, exist_ok=True) + isolate_credentials_file.write_text('[default]\napi_key = "previously-saved-key"\n') - with patch.dict( - os.environ, {"RUNPOD_CREDENTIALS_FILE": str(creds)}, clear=True - ): - from runpod_flash.core.api.runpod import RunpodGraphQLClient + from runpod_flash.core.api.runpod import RunpodGraphQLClient - client = RunpodGraphQLClient(require_api_key=False) - assert client.api_key is None + client = RunpodGraphQLClient(require_api_key=False) + assert client.api_key is None - session = await client._get_session() - try: - assert "Authorization" not in session.headers - finally: - await session.close() + session = await client._get_session() + try: + assert "Authorization" not in session.headers + finally: + await session.close() @pytest.mark.asyncio async def test_no_auth_header_when_both_env_var_and_credentials_file( - self, tmp_path + self, isolate_credentials_file ): """Re-login with both env var and credentials file must not send auth. Covers the force re-login scenario where both RUNPOD_API_KEY and a credentials file with a stored key are present simultaneously. """ - creds = tmp_path / "credentials.toml" - creds.write_text('api_key = "file-key"\n') + isolate_credentials_file.parent.mkdir(parents=True, exist_ok=True) + isolate_credentials_file.write_text('[default]\napi_key = "file-key"\n') - with patch.dict( - os.environ, - {"RUNPOD_API_KEY": "env-key", "RUNPOD_CREDENTIALS_FILE": str(creds)}, - clear=True, - ): + with patch.dict(os.environ, {"RUNPOD_API_KEY": "env-key"}): from runpod_flash.core.api.runpod import RunpodGraphQLClient client = RunpodGraphQLClient(require_api_key=False) From 2ae7426598b6de8e8fd5f47e4c1cde8c4bbe542c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Thu, 12 Mar 2026 01:38:05 -0700 Subject: [PATCH 07/11] test(credentials): add legacy XDG credential migration tests --- tests/unit/test_credential_migration.py | 94 +++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 tests/unit/test_credential_migration.py diff --git a/tests/unit/test_credential_migration.py b/tests/unit/test_credential_migration.py new file mode 100644 index 00000000..9736d199 --- /dev/null +++ b/tests/unit/test_credential_migration.py @@ -0,0 +1,94 @@ +"""Tests for legacy XDG credential migration.""" + +from pathlib import Path +from unittest.mock import patch + +from runpod_flash.core.credentials import ( + check_and_migrate_legacy_credentials, + get_api_key, +) + + +def _write_old_xdg_creds(path: Path, api_key: str) -> None: + """Write credentials in old flash format (flat TOML, no profile).""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f'api_key = "{api_key}"\n') + + +def _write_config_toml(path: Path, api_key: str) -> None: + """Write credentials in runpod-python format.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f'[default]\napi_key = "{api_key}"\n') + + +class TestLegacyMigration: + def test_migrates_old_xdg_credentials(self, tmp_path, isolate_credentials_file): + old_path = tmp_path / ".config" / "runpod" / "credentials.toml" + _write_old_xdg_creds(old_path, "legacy-key") + + with patch( + "runpod_flash.core.credentials._OLD_XDG_PATHS", + (old_path,), + ): + check_and_migrate_legacy_credentials() + + assert not old_path.exists() + assert get_api_key() == "legacy-key" + + def test_skips_migration_when_new_file_has_key( + self, tmp_path, isolate_credentials_file + ): + old_path = tmp_path / ".config" / "runpod" / "credentials.toml" + _write_old_xdg_creds(old_path, "old-key") + _write_config_toml(isolate_credentials_file, "new-key") + + with patch( + "runpod_flash.core.credentials._OLD_XDG_PATHS", + (old_path,), + ): + check_and_migrate_legacy_credentials() + + assert old_path.exists() + assert get_api_key() == "new-key" + + def test_skips_when_no_old_file(self): + check_and_migrate_legacy_credentials() + + def test_skips_when_old_file_has_blank_key(self, tmp_path, isolate_credentials_file): + old_path = tmp_path / ".config" / "runpod" / "credentials.toml" + _write_old_xdg_creds(old_path, " ") + + with patch( + "runpod_flash.core.credentials._OLD_XDG_PATHS", + (old_path,), + ): + check_and_migrate_legacy_credentials() + + assert old_path.exists() + + def test_skips_when_old_file_is_corrupt(self, tmp_path, isolate_credentials_file): + old_path = tmp_path / ".config" / "runpod" / "credentials.toml" + old_path.parent.mkdir(parents=True, exist_ok=True) + old_path.write_text("not valid toml {{{{") + + with patch( + "runpod_flash.core.credentials._OLD_XDG_PATHS", + (old_path,), + ): + check_and_migrate_legacy_credentials() + + assert old_path.exists() + + def test_cleans_up_empty_parent_directory(self, tmp_path, isolate_credentials_file): + old_dir = tmp_path / ".config" / "runpod" + old_path = old_dir / "credentials.toml" + _write_old_xdg_creds(old_path, "legacy-key") + + with patch( + "runpod_flash.core.credentials._OLD_XDG_PATHS", + (old_path,), + ): + check_and_migrate_legacy_credentials() + + assert not old_path.exists() + assert not old_dir.exists() From 77a3776944808949c3a9c7c117360da9f4d01f18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Thu, 12 Mar 2026 01:39:13 -0700 Subject: [PATCH 08/11] chore: format and lint fixes --- src/runpod_flash/cli/commands/login.py | 5 ++++- src/runpod_flash/core/credentials.py | 4 +--- tests/unit/test_credential_migration.py | 4 +++- tests/unit/test_credentials_extended.py | 8 ++++++-- tests/unit/test_login.py | 4 +++- tests/unit/test_login_extended.py | 4 +++- 6 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/runpod_flash/cli/commands/login.py b/src/runpod_flash/cli/commands/login.py index 6572345b..fca2ef99 100644 --- a/src/runpod_flash/cli/commands/login.py +++ b/src/runpod_flash/cli/commands/login.py @@ -6,7 +6,10 @@ from rich.console import Console from runpod_flash.core.api.runpod import RunpodGraphQLClient -from runpod_flash.core.credentials import check_and_migrate_legacy_credentials, save_api_key +from runpod_flash.core.credentials import ( + check_and_migrate_legacy_credentials, + save_api_key, +) from runpod_flash.core.resources.constants import CONSOLE_BASE_URL console = Console() diff --git a/src/runpod_flash/core/credentials.py b/src/runpod_flash/core/credentials.py index de91dcb9..5937e09c 100644 --- a/src/runpod_flash/core/credentials.py +++ b/src/runpod_flash/core/credentials.py @@ -20,9 +20,7 @@ log = logging.getLogger(__name__) -_OLD_XDG_PATHS = ( - Path.home() / ".config" / "runpod" / "credentials.toml", -) +_OLD_XDG_PATHS = (Path.home() / ".config" / "runpod" / "credentials.toml",) def get_credentials_path() -> Path: diff --git a/tests/unit/test_credential_migration.py b/tests/unit/test_credential_migration.py index 9736d199..26ecc832 100644 --- a/tests/unit/test_credential_migration.py +++ b/tests/unit/test_credential_migration.py @@ -54,7 +54,9 @@ def test_skips_migration_when_new_file_has_key( def test_skips_when_no_old_file(self): check_and_migrate_legacy_credentials() - def test_skips_when_old_file_has_blank_key(self, tmp_path, isolate_credentials_file): + def test_skips_when_old_file_has_blank_key( + self, tmp_path, isolate_credentials_file + ): old_path = tmp_path / ".config" / "runpod" / "credentials.toml" _write_old_xdg_creds(old_path, " ") diff --git a/tests/unit/test_credentials_extended.py b/tests/unit/test_credentials_extended.py index 5918e2fe..339d6c22 100644 --- a/tests/unit/test_credentials_extended.py +++ b/tests/unit/test_credentials_extended.py @@ -40,7 +40,9 @@ def test_non_string_stored_value(self, isolate_credentials_file): def test_list_stored_value(self, isolate_credentials_file): """Returns None when stored api_key is a list.""" isolate_credentials_file.parent.mkdir(parents=True, exist_ok=True) - isolate_credentials_file.write_text('[default]\napi_key = ["not", "a", "key"]\n') + isolate_credentials_file.write_text( + '[default]\napi_key = ["not", "a", "key"]\n' + ) result = get_api_key() assert result is None @@ -129,7 +131,9 @@ class TestHttpCredentialsFallback: """Test that http.py helpers use credentials file when env var is unset.""" @pytest.mark.asyncio - async def test_httpx_client_uses_credentials_file_key(self, isolate_credentials_file): + async def test_httpx_client_uses_credentials_file_key( + self, isolate_credentials_file + ): """get_authenticated_httpx_client uses credentials file for auth.""" _write_config_toml(isolate_credentials_file, "cred-file-key") diff --git a/tests/unit/test_login.py b/tests/unit/test_login.py index 3ccb4cdd..9d061a27 100644 --- a/tests/unit/test_login.py +++ b/tests/unit/test_login.py @@ -31,7 +31,9 @@ def test_require_api_key_false_does_not_load_stored_key( self, isolate_credentials_file ): isolate_credentials_file.parent.mkdir(parents=True, exist_ok=True) - isolate_credentials_file.write_text('[default]\napi_key = "stale-expired-key"\n') + isolate_credentials_file.write_text( + '[default]\napi_key = "stale-expired-key"\n' + ) from runpod_flash.core.api.runpod import RunpodGraphQLClient diff --git a/tests/unit/test_login_extended.py b/tests/unit/test_login_extended.py index e6d50a77..ff0edf40 100644 --- a/tests/unit/test_login_extended.py +++ b/tests/unit/test_login_extended.py @@ -375,7 +375,9 @@ async def test_no_auth_header_when_credentials_file_has_key( not leak into the session. """ isolate_credentials_file.parent.mkdir(parents=True, exist_ok=True) - isolate_credentials_file.write_text('[default]\napi_key = "previously-saved-key"\n') + isolate_credentials_file.write_text( + '[default]\napi_key = "previously-saved-key"\n' + ) from runpod_flash.core.api.runpod import RunpodGraphQLClient From afcb395760c4b92b2e57d3b0b56d11bf8eb5928e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Thu, 12 Mar 2026 10:10:31 -0700 Subject: [PATCH 09/11] docs: update all docs and skeleton template to lead with flash login Replace .env-based API key setup with flash login as the primary authentication method across README, skeleton template, init command output, and CLI docs. - Quick Start sections now use flash login instead of cp .env.example - Added Authentication section to skeleton template README - Simplified init command post-creation steps - Added flash-login.md CLI documentation - Updated test_init to verify flash login in steps table --- README.md | 16 ++- src/runpod_flash/cli/commands/init.py | 10 +- src/runpod_flash/cli/docs/README.md | 8 +- src/runpod_flash/cli/docs/flash-login.md | 118 ++++++++++++++++++ .../cli/utils/skeleton_template/README.md | 21 +++- tests/unit/cli/commands/test_init.py | 23 +++- 6 files changed, 172 insertions(+), 24 deletions(-) create mode 100644 src/runpod_flash/cli/docs/flash-login.md diff --git a/README.md b/README.md index 62ad9b96..4fa57f16 100644 --- a/README.md +++ b/README.md @@ -38,13 +38,25 @@ Flash requires [Python 3.10+](https://www.python.org/downloads/), and is current ### Authentication -Before you can use Flash, you need to authenticate with your Runpod account: +Before you can use Flash, you need a Runpod API key. The simplest way: ```bash flash login ``` -This saves your API key securely and allows you to use the Flash CLI and run `@Endpoint` functions. +This opens a browser auth flow and saves your key to `~/.runpod/config.toml`. + +You can also provide a key via environment variable or `.env` file: + +```bash +# Environment variable +export RUNPOD_API_KEY=your_api_key_here + +# Or .env file in project root +echo "RUNPOD_API_KEY=your_api_key_here" > .env +``` + +Flash checks these sources in order: env var, `.env` file, then credentials file. See the [flash login docs](src/runpod_flash/cli/docs/flash-login.md) for details. ### Coding agent integration (optional) diff --git a/src/runpod_flash/cli/commands/init.py b/src/runpod_flash/cli/commands/init.py index a8d58471..43ea590e 100644 --- a/src/runpod_flash/cli/commands/init.py +++ b/src/runpod_flash/cli/commands/init.py @@ -105,19 +105,11 @@ def init_command( steps_table.add_row(f"{step_num}.", "pip install -r requirements.txt") step_num += 1 - steps_table.add_row(f"{step_num}.", "cp .env.example .env") - step_num += 1 - steps_table.add_row( - f"{step_num}.", "Add your RUNPOD_API_KEY to .env (or run flash login)" - ) + steps_table.add_row(f"{step_num}.", "flash login") step_num += 1 steps_table.add_row(f"{step_num}.", "flash run") console.print(steps_table) - console.print("\n[bold]Get your API key:[/bold]") - console.print(" https://docs.runpod.io/get-started/api-keys") - console.print("\n[bold]Or authenticate with flash:[/bold]") - console.print(" flash login") console.print("\nVisit http://localhost:8888/docs after running") console.print("\nCheck out the README.md for more") diff --git a/src/runpod_flash/cli/docs/README.md b/src/runpod_flash/cli/docs/README.md index 1dc6ab3a..ab1548cd 100644 --- a/src/runpod_flash/cli/docs/README.md +++ b/src/runpod_flash/cli/docs/README.md @@ -18,13 +18,17 @@ cd my-project uv sync # or: pip install -r requirements.txt ``` -Authenticate with Runpod: +Authenticate with Runpod (saves API key to `~/.runpod/config.toml`): ```bash flash login ``` -Or add your Runpod API key to `.env`: +Alternatively, set your API key via environment variable or `.env` file: ```bash +# Shell environment variable (highest priority) +export RUNPOD_API_KEY=your_api_key_here + +# Or .env file in project root (second priority) echo "RUNPOD_API_KEY=your_api_key_here" > .env ``` diff --git a/src/runpod_flash/cli/docs/flash-login.md b/src/runpod_flash/cli/docs/flash-login.md new file mode 100644 index 00000000..d63845fc --- /dev/null +++ b/src/runpod_flash/cli/docs/flash-login.md @@ -0,0 +1,118 @@ +# flash login + +Authenticate with Runpod via browser and save your API key locally. + +## Overview + +The `flash login` command opens a browser-based authentication flow with Runpod. On approval, your API key is saved to `~/.runpod/config.toml` -- the same credentials file used by runpod-python. This is the recommended way to authenticate for local development. + +### When to use this command +- First-time setup after installing Flash +- Switching between Runpod accounts +- Re-authenticating after revoking an API key + +## Usage + +```bash +flash login [OPTIONS] +``` + +## Options + +- `--force`: Re-authenticate even if valid credentials already exist +- `--no-open`: Print the auth URL instead of opening the browser +- `--timeout`: Maximum wait time in seconds (default: 600) + +## Examples + +```bash +# Standard login (opens browser) +flash login + +# Re-authenticate with a different account +flash login --force + +# Login on a headless server (copy URL manually) +flash login --no-open +``` + +## How It Works + +1. Flash creates an auth request and prints a URL +2. You approve the request in your browser at runpod.io +3. Flash polls for approval, then saves the API key to `~/.runpod/config.toml` +4. File permissions are set to `0600` (owner read/write only) + +If you already have valid credentials on file, `flash login` detects this and exits early. Use `--force` to bypass this check and re-authenticate. + +## Credential Resolution Order + +Flash checks for an API key in this order, using the first one found: + +| Priority | Source | How to set | +|----------|--------|------------| +| 1 | `RUNPOD_API_KEY` environment variable | `export RUNPOD_API_KEY=your_key` | +| 2 | `RUNPOD_API_KEY` in `.env` file | `echo "RUNPOD_API_KEY=your_key" >> .env` | +| 3 | `~/.runpod/config.toml` credentials file | `flash login` | + +The `.env` file is loaded into the environment automatically via `python-dotenv` at startup, so priorities 1 and 2 both resolve through `os.getenv("RUNPOD_API_KEY")`. An explicitly exported shell variable takes precedence over a `.env` value. + +### Scope of `flash login` + +`flash login` manages **only the credentials file** (priority 3). It does not read or write environment variables or `.env` files. The pre-flight check that detects existing credentials also only checks the file -- if your key comes from an env var or `.env`, `flash login` will not see it and will proceed with the browser flow. + +This separation means you can use `flash login` for persistent, machine-wide credentials while still overriding per-project or per-session with env vars. + +## Credentials File Format + +Flash delegates credential storage to runpod-python. The file uses TOML with a `[default]` profile: + +```toml +[default] +api_key = "your_api_key_here" +``` + +**Location:** `~/.runpod/config.toml` (shared with runpod-python CLI) + +This means `runpod config` and `flash login` write to the same file. A key saved by either tool is visible to both. + +## Troubleshooting + +### "Already logged in" but I want to re-authenticate + +```bash +flash login --force +``` + +### Login works but `flash deploy` says key is missing + +Check which source your key is coming from: + +```bash +# Is the env var set? +echo $RUNPOD_API_KEY + +# Is there a .env file? +cat .env | grep RUNPOD_API_KEY + +# Is the credentials file present? +cat ~/.runpod/config.toml +``` + +If the env var or `.env` has a stale key, it takes precedence over the credentials file. Remove or update it. + +### Headless server / SSH session + +Use `--no-open` to get a URL you can copy to another machine's browser: + +```bash +flash login --no-open +``` + +### Timeout during login + +The default timeout is 10 minutes. Increase it for slow connections: + +```bash +flash login --timeout 1200 +``` diff --git a/src/runpod_flash/cli/utils/skeleton_template/README.md b/src/runpod_flash/cli/utils/skeleton_template/README.md index 4f5d09c6..72369541 100644 --- a/src/runpod_flash/cli/utils/skeleton_template/README.md +++ b/src/runpod_flash/cli/utils/skeleton_template/README.md @@ -15,7 +15,7 @@ Set up the project: ```bash uv venv && source .venv/bin/activate uv sync -cp .env.example .env # Add your RUNPOD_API_KEY +flash login # Authenticate with Runpod flash run ``` @@ -24,7 +24,7 @@ Or with pip: ```bash python -m venv .venv && source .venv/bin/activate pip install -r requirements.txt -cp .env.example .env # Add your RUNPOD_API_KEY +flash login # Authenticate with Runpod flash run ``` @@ -34,9 +34,6 @@ Use `flash run --auto-provision` to pre-deploy all endpoints on startup, elimina When you stop the server with Ctrl+C, all endpoints provisioned during the session are automatically cleaned up. -Get your API key from [Runpod Settings](https://www.runpod.io/console/user/settings). -Learn more about it from our [Documentation](https://docs.runpod.io/get-started/api-keys). - ## Test the API ```bash @@ -178,10 +175,22 @@ Pass a CPU instance type string to `cpu=`: Or use `CpuInstanceType` enum values. +## Authentication + +Run `flash login` to authenticate via browser. This stores your API key in `~/.runpod/config.toml`. + +Alternatively, set the `RUNPOD_API_KEY` environment variable or add it to `.env`: +```bash +cp .env.example .env # Then edit .env with your key +``` + +Get your API key from [Runpod Settings](https://www.runpod.io/console/user/settings). +Learn more from our [Documentation](https://docs.runpod.io/get-started/api-keys). + ## Environment Variables ```bash -# Required +# Authentication (optional if using flash login) RUNPOD_API_KEY=your_api_key # Optional diff --git a/tests/unit/cli/commands/test_init.py b/tests/unit/cli/commands/test_init.py index 3f80e0a6..55bd7c37 100644 --- a/tests/unit/cli/commands/test_init.py +++ b/tests/unit/cli/commands/test_init.py @@ -160,16 +160,29 @@ def test_next_steps_displayed(self, mock_context, tmp_path, monkeypatch): ) @patch("pathlib.Path.cwd") - def test_api_key_docs_link_displayed(self, mock_cwd, mock_context, tmp_path): - """Test API key documentation link is displayed.""" + def test_flash_login_step_displayed(self, mock_cwd, mock_context, tmp_path): + """Test flash login is shown in the next steps table.""" mock_cwd.return_value = tmp_path init_command(".") - # Verify console.print was called with API key link - assert any( - "runpod.io" in str(c) for c in mock_context["console"].print.call_args_list + # The steps table is a Rich Table passed to console.print. + # Check that one of the Table's column cells contains "flash login". + from rich.table import Table + + tables = [ + call.args[0] + for call in mock_context["console"].print.call_args_list + if call.args and isinstance(call.args[0], Table) + ] + assert tables, "No Rich Table was printed" + cells_text = " ".join( + str(cell) + for table in tables + for col in table.columns + for cell in col._cells ) + assert "flash login" in cells_text def test_status_message_for_new_directory( self, mock_context, tmp_path, monkeypatch From f400b7824aa51ab0445f60787ff92dd0d7f3365b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Thu, 12 Mar 2026 10:31:26 -0700 Subject: [PATCH 10/11] fix: address PR review feedback on login auth flow - Add _resolve_api_key() with TypeError guard for invalid override types - Wrap get_credentials() in try/except during migration pre-check - Validate existing api_key with isinstance + .strip() before skipping - Fix docstring to match non-interactive auto-migration behavior - Move migration call to after successful auth (not before polling) - Simplify _OLD_XDG_PATHS tuple to _OLD_XDG_PATH constant - Narrow migration except clause from Exception to (OSError, ValueError) --- src/runpod_flash/cli/commands/login.py | 4 +- src/runpod_flash/core/credentials.py | 96 +++++++++++++++----------- src/runpod_flash/core/utils/http.py | 30 +++++++- 3 files changed, 82 insertions(+), 48 deletions(-) diff --git a/src/runpod_flash/cli/commands/login.py b/src/runpod_flash/cli/commands/login.py index fca2ef99..d5d60c19 100644 --- a/src/runpod_flash/cli/commands/login.py +++ b/src/runpod_flash/cli/commands/login.py @@ -44,9 +44,6 @@ async def _login(open_browser: bool, timeout_seconds: float) -> None: if open_browser: typer.launch(auth_url) - # Migrate legacy credentials if present - check_and_migrate_legacy_credentials() - expires_at = _parse_expires_at(request.get("expiresAt")) deadline = dt.datetime.now(dt.timezone.utc) + dt.timedelta( seconds=timeout_seconds @@ -61,6 +58,7 @@ async def _login(open_browser: bool, timeout_seconds: float) -> None: api_key = status_payload.get("apiKey") if api_key and status in {"APPROVED", "CONSUMED"}: + check_and_migrate_legacy_credentials() path = save_api_key(api_key) console.print( f"[green]Logged in.[/green] Credentials saved to [dim]{path}[/dim]" diff --git a/src/runpod_flash/core/credentials.py b/src/runpod_flash/core/credentials.py index 5937e09c..47350939 100644 --- a/src/runpod_flash/core/credentials.py +++ b/src/runpod_flash/core/credentials.py @@ -20,7 +20,7 @@ log = logging.getLogger(__name__) -_OLD_XDG_PATHS = (Path.home() / ".config" / "runpod" / "credentials.toml",) +_OLD_XDG_PATH = Path.home() / ".config" / "runpod" / "credentials.toml" def get_credentials_path() -> Path: @@ -68,59 +68,71 @@ def save_api_key(api_key: str) -> Path: def check_and_migrate_legacy_credentials() -> None: - """Check for credentials at old XDG path and offer migration. + """Check for credentials at old XDG path and migrate if needed. - Called during flash login before saving new credentials. - If old file exists and new file has no credentials, prompts user to migrate. + Called during flash login on successful auth. If an old credentials file + exists and the new location has no credentials, automatically migrate the + legacy API key to the new credentials file. """ try: import tomllib except ImportError: import tomli as tomllib - existing_creds = get_credentials() - if existing_creds and existing_creds.get("api_key"): + try: + existing_creds = get_credentials() + except Exception: + log.debug( + "Failed to read credentials file while checking for legacy migration", + exc_info=True, + ) + existing_creds = None + + if ( + existing_creds + and isinstance(existing_creds.get("api_key"), str) + and existing_creds["api_key"].strip() + ): return new_path = get_credentials_path() + old_path = _OLD_XDG_PATH - for old_path in _OLD_XDG_PATHS: - if not old_path.exists(): - continue + if not old_path.exists(): + return - try: - with old_path.open("rb") as f: - old_data = tomllib.load(f) - old_key = old_data.get("api_key") - if not isinstance(old_key, str) or not old_key.strip(): - continue - except (OSError, ValueError): - continue + try: + with old_path.open("rb") as f: + old_data = tomllib.load(f) + old_key = old_data.get("api_key") + if not isinstance(old_key, str) or not old_key.strip(): + return + except (OSError, ValueError): + return - log.info("Found credentials at legacy path: %s", old_path) + log.info("Found credentials at legacy path: %s", old_path) + try: + from rich.console import Console + + console = Console() + console.print( + f"\n[yellow]Found credentials at old location:[/yellow]" + f"\n {old_path}" + f"\n[yellow]Migrating to:[/yellow]" + f"\n {new_path}\n" + ) + save_api_key(old_key) + old_path.unlink() try: - from rich.console import Console - - console = Console() - console.print( - f"\n[yellow]Found credentials at old location:[/yellow]" - f"\n {old_path}" - f"\n[yellow]Migrating to:[/yellow]" - f"\n {new_path}\n" - ) - save_api_key(old_key) - old_path.unlink() - try: - old_path.parent.rmdir() - except OSError: - pass - console.print("[green]Migrated.[/green] Old file removed.\n") - except Exception: - log.warning( - "Could not migrate credentials from %s to %s. " - "Run 'flash login' to create new credentials.", - old_path, - new_path, - ) - return + old_path.parent.rmdir() + except OSError: + pass + console.print("[green]Migrated.[/green] Old file removed.\n") + except (OSError, ValueError): + log.warning( + "Could not migrate credentials from %s to %s. " + "Run 'flash login' to create new credentials.", + old_path, + new_path, + ) diff --git a/src/runpod_flash/core/utils/http.py b/src/runpod_flash/core/utils/http.py index 8d9ba97d..8937ac47 100644 --- a/src/runpod_flash/core/utils/http.py +++ b/src/runpod_flash/core/utils/http.py @@ -12,6 +12,30 @@ _UNSET = object() +def _resolve_api_key(api_key_override: object) -> Optional[str]: + """Resolve the API key from override or default credentials. + + Args: + api_key_override: _UNSET (use default credentials), None (no auth), + or a str API key. + + Returns: + API key string or None. + + Raises: + TypeError: If api_key_override is not _UNSET, None, or str. + """ + if api_key_override is _UNSET: + return get_api_key() + if api_key_override is None: + return None + if isinstance(api_key_override, str): + return api_key_override + raise TypeError( + f"api_key_override must be str or None, got {type(api_key_override).__name__}" + ) + + def get_authenticated_httpx_client( timeout: Optional[float] = None, api_key_override: Union[Optional[str], object] = _UNSET, @@ -52,7 +76,7 @@ def get_authenticated_httpx_client( "User-Agent": get_user_agent(), "Content-Type": "application/json", } - api_key = get_api_key() if api_key_override is _UNSET else api_key_override + api_key = _resolve_api_key(api_key_override) if api_key: headers["Authorization"] = f"Bearer {api_key}" @@ -100,7 +124,7 @@ def get_authenticated_requests_session( session.headers["User-Agent"] = get_user_agent() session.headers["Content-Type"] = "application/json" - api_key = get_api_key() if api_key_override is _UNSET else api_key_override + api_key = _resolve_api_key(api_key_override) if api_key: session.headers["Authorization"] = f"Bearer {api_key}" @@ -144,7 +168,7 @@ def get_authenticated_aiohttp_session( "Content-Type": "application/json", } - api_key = get_api_key() if api_key_override is _UNSET else api_key_override + api_key = _resolve_api_key(api_key_override) if api_key: headers["Authorization"] = f"Bearer {api_key}" From ab0608b7d9d12f7fe010973a94ed15e67fc54d7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Thu, 12 Mar 2026 10:31:32 -0700 Subject: [PATCH 11/11] test: fix private API usage and align migration tests with refactor - Replace Rich Table _cells access with console render-to-string - Update migration test patches from _OLD_XDG_PATHS tuple to _OLD_XDG_PATH --- tests/unit/cli/commands/test_init.py | 17 +++++++++-------- tests/unit/test_credential_migration.py | 20 ++++++++++---------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/tests/unit/cli/commands/test_init.py b/tests/unit/cli/commands/test_init.py index 55bd7c37..e9d0333a 100644 --- a/tests/unit/cli/commands/test_init.py +++ b/tests/unit/cli/commands/test_init.py @@ -167,7 +167,10 @@ def test_flash_login_step_displayed(self, mock_cwd, mock_context, tmp_path): init_command(".") # The steps table is a Rich Table passed to console.print. - # Check that one of the Table's column cells contains "flash login". + # Render it to plain text and check for "flash login". + from io import StringIO + + from rich.console import Console as RichConsole from rich.table import Table tables = [ @@ -176,13 +179,11 @@ def test_flash_login_step_displayed(self, mock_cwd, mock_context, tmp_path): if call.args and isinstance(call.args[0], Table) ] assert tables, "No Rich Table was printed" - cells_text = " ".join( - str(cell) - for table in tables - for col in table.columns - for cell in col._cells - ) - assert "flash login" in cells_text + buf = StringIO() + render_console = RichConsole(file=buf, width=120) + for table in tables: + render_console.print(table) + assert "flash login" in buf.getvalue() def test_status_message_for_new_directory( self, mock_context, tmp_path, monkeypatch diff --git a/tests/unit/test_credential_migration.py b/tests/unit/test_credential_migration.py index 26ecc832..e60a15fc 100644 --- a/tests/unit/test_credential_migration.py +++ b/tests/unit/test_credential_migration.py @@ -27,8 +27,8 @@ def test_migrates_old_xdg_credentials(self, tmp_path, isolate_credentials_file): _write_old_xdg_creds(old_path, "legacy-key") with patch( - "runpod_flash.core.credentials._OLD_XDG_PATHS", - (old_path,), + "runpod_flash.core.credentials._OLD_XDG_PATH", + old_path, ): check_and_migrate_legacy_credentials() @@ -43,8 +43,8 @@ def test_skips_migration_when_new_file_has_key( _write_config_toml(isolate_credentials_file, "new-key") with patch( - "runpod_flash.core.credentials._OLD_XDG_PATHS", - (old_path,), + "runpod_flash.core.credentials._OLD_XDG_PATH", + old_path, ): check_and_migrate_legacy_credentials() @@ -61,8 +61,8 @@ def test_skips_when_old_file_has_blank_key( _write_old_xdg_creds(old_path, " ") with patch( - "runpod_flash.core.credentials._OLD_XDG_PATHS", - (old_path,), + "runpod_flash.core.credentials._OLD_XDG_PATH", + old_path, ): check_and_migrate_legacy_credentials() @@ -74,8 +74,8 @@ def test_skips_when_old_file_is_corrupt(self, tmp_path, isolate_credentials_file old_path.write_text("not valid toml {{{{") with patch( - "runpod_flash.core.credentials._OLD_XDG_PATHS", - (old_path,), + "runpod_flash.core.credentials._OLD_XDG_PATH", + old_path, ): check_and_migrate_legacy_credentials() @@ -87,8 +87,8 @@ def test_cleans_up_empty_parent_directory(self, tmp_path, isolate_credentials_fi _write_old_xdg_creds(old_path, "legacy-key") with patch( - "runpod_flash.core.credentials._OLD_XDG_PATHS", - (old_path,), + "runpod_flash.core.credentials._OLD_XDG_PATH", + old_path, ): check_and_migrate_legacy_credentials()