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/commands/login.py b/src/runpod_flash/cli/commands/login.py index d293f335..d5d60c19 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 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() @@ -55,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/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 3a655883..e835106b 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 @@ -182,10 +179,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/src/runpod_flash/core/credentials.py b/src/runpod_flash/core/credentials.py index 9d48b871..47350939 100644 --- a/src/runpod_flash/core/credentials.py +++ b/src/runpod_flash/core/credentials.py @@ -1,57 +1,138 @@ +"""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 +import runpod.cli.groups.config.functions as _runpod_config +from runpod.cli.groups.config.functions import ( + 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_PATH = 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(_runpod_config.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 + 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"] 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 migrate if needed. + + 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 + + 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 + + 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(): + return + except (OSError, ValueError): + return + + 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 (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 fa155ae4..8937ac47 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,36 @@ from runpod_flash.core.credentials import get_api_key +_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: Optional[str] = None, + api_key_override: Union[Optional[str], object] = _UNSET, ) -> httpx.AsyncClient: """Create httpx AsyncClient with RunPod authentication and User-Agent. @@ -50,7 +76,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 = _resolve_api_key(api_key_override) if api_key: headers["Authorization"] = f"Bearer {api_key}" @@ -59,7 +85,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 +124,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 = _resolve_api_key(api_key_override) if api_key: session.headers["Authorization"] = f"Bearer {api_key}" @@ -107,7 +133,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 +168,7 @@ def get_authenticated_aiohttp_session( "Content-Type": "application/json", } - api_key = api_key_override or get_api_key() + api_key = _resolve_api_key(api_key_override) if api_key: headers["Authorization"] = f"Bearer {api_key}" 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 diff --git a/tests/unit/cli/commands/test_init.py b/tests/unit/cli/commands/test_init.py index 3f80e0a6..e9d0333a 100644 --- a/tests/unit/cli/commands/test_init.py +++ b/tests/unit/cli/commands/test_init.py @@ -160,16 +160,30 @@ 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. + # 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 = [ + 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" + 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 new file mode 100644 index 00000000..e60a15fc --- /dev/null +++ b/tests/unit/test_credential_migration.py @@ -0,0 +1,96 @@ +"""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_PATH", + 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_PATH", + 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_PATH", + 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_PATH", + 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_PATH", + old_path, + ): + check_and_migrate_legacy_credentials() + + assert not old_path.exists() + assert not old_dir.exists() 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 9c9e6629..339d6c22 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,37 @@ 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 +62,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 +83,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 +106,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,61 +131,77 @@ 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 + + async with get_authenticated_httpx_client() as client: + assert "Authorization" not in client.headers + + def test_requests_session_uses_credentials_file_key(self, isolate_credentials_file): + """get_authenticated_requests_session uses credentials file for auth.""" + _write_config_toml(isolate_credentials_file, "cred-file-key") + + 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() + + def test_requests_session_no_auth_without_key(self): + """get_authenticated_requests_session omits auth when no key available.""" + 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() - with patch.dict( - os.environ, {"RUNPOD_CREDENTIALS_FILE": str(creds)}, clear=True - ): + @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"}): from runpod_flash.core.utils.http import get_authenticated_httpx_client - async with get_authenticated_httpx_client() as client: + async with get_authenticated_httpx_client(api_key_override=None) as client: assert "Authorization" not in client.headers - def test_requests_session_uses_credentials_file_key(self, tmp_path): - """get_authenticated_requests_session uses credentials file for auth.""" - creds = tmp_path / "credentials.toml" - creds.write_text('api_key = "cred-file-key"\n') - - with patch.dict( - os.environ, {"RUNPOD_CREDENTIALS_FILE": str(creds)}, clear=True - ): + 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"}): from runpod_flash.core.utils.http import get_authenticated_requests_session - session = get_authenticated_requests_session() + session = get_authenticated_requests_session(api_key_override=None) try: - assert "Authorization" in session.headers - assert "cred-file-key" in session.headers["Authorization"] + assert "Authorization" not in session.headers finally: session.close() - def test_requests_session_no_auth_without_key(self, tmp_path): - """get_authenticated_requests_session omits auth when no key available.""" - creds = tmp_path / "nonexistent.toml" - - with patch.dict( - os.environ, {"RUNPOD_CREDENTIALS_FILE": str(creds)}, clear=True - ): - from runpod_flash.core.utils.http import get_authenticated_requests_session + @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"}): + from runpod_flash.core.utils.http import get_authenticated_aiohttp_session - session = get_authenticated_requests_session() + session = get_authenticated_aiohttp_session(api_key_override=None) try: assert "Authorization" not in session.headers finally: - session.close() + await session.close() diff --git a/tests/unit/test_login.py b/tests/unit/test_login.py index 532d7f81..9d061a27 100644 --- a/tests/unit/test_login.py +++ b/tests/unit/test_login.py @@ -27,26 +27,21 @@ 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 +54,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 +96,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 bc0de446..ff0edf40 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,37 +311,105 @@ 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.""" + async def test_fresh_user_no_key_anywhere(self): + """Fresh user: no env var, no credentials file -- no auth header. + + The autouse isolate_credentials_file fixture ensures no credentials + file exists and RUNPOD_API_KEY is deleted from the environment. + """ from runpod_flash.core.api.runpod import RunpodGraphQLClient - # 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), - ): + 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_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"}): + from runpod_flash.core.api.runpod import RunpodGraphQLClient + + client = RunpodGraphQLClient() + assert client.api_key == "my-key" + + session = await client._get_session() + try: + assert "Authorization" in session.headers + 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): + """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, + causing the server to see an authenticated user instead of a guest. + """ + with patch.dict(os.environ, {"RUNPOD_API_KEY": "existing-key"}): + 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() @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): + 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 the credentials file. + Running flash login again must still act as a guest -- the stored key must + 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' + ) + + 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, 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. + """ + 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"}): from runpod_flash.core.api.runpod import RunpodGraphQLClient - client = RunpodGraphQLClient() - assert client.api_key == "my-key" + client = RunpodGraphQLClient(require_api_key=False) + assert client.api_key is None session = await client._get_session() try: - assert "Authorization" in session.headers - assert "my-key" in session.headers["Authorization"] + assert "Authorization" not in session.headers finally: await session.close()