From 3de6f6e84b4b41aa4bfed1820cb13035ad4bf821 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Fri, 21 Aug 2026 11:40:52 -0700 Subject: [PATCH] fix(cli): honor global --base-url and --api-key in auth commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every command routes its client through get_client(ctx), which reads the global options the root callback stashes on ctx.obj — except auth login and auth status, which took no ctx and built a client directly. The visible damage was auth status reporting {"valid": true} against a host it never contacted: $ discolike --base-url http://127.0.0.1:9 auth status {"source": "env", "api_key": "…isco", "valid": true} while the same override on any other command correctly refused to connect. --api-key was ignored too, so there was no way to verify a key before auth login wrote it to disk. auth status gains a third source value, "option", distinguishing a key passed on the command line from one inherited via DISCOLIKE_API_KEY. That distinction has to come from click's parameter source, since the global --api-key is env-bound and the value alone cannot tell them apart. The same distinction keeps auth login's prompt intact: an ambient DISCOLIKE_API_KEY must not silently become the saved credential, or anyone holding a production key in their environment would persist the wrong one while trying to store another. Only an explicit flag skips the prompt. --- CHANGELOG.md | 1 + .../discolike-cli/src/discolike_cli/auth.py | 46 +++++++--- packages/discolike-cli/tests/conftest.py | 14 ++- packages/discolike-cli/tests/test_auth.py | 90 +++++++++++++++++++ 4 files changed, 136 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a2754b..34c35a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- CLI (fix): `auth login` and `auth status` now honor the global `--base-url` and `--api-key`. Every other command routed through `get_client(ctx)`; these two built their own client, so `--base-url` was ignored and `auth status` reported `"valid": true` for a host it never contacted, while `--api-key` was ignored in favour of the environment or config key. `auth status` gains a third `source` value, `option`, for a key passed explicitly on the command line. An ambient `DISCOLIKE_API_KEY` still does not skip the `auth login` prompt — only an explicit flag does. - CLI: new `discolike email` command group wrapping the SDK email resource — `find FIRST LAST DOMAIN [--known-pattern X]`, `find-batch` (CSV file and/or repeatable `--contact "first,last,domain"`, max 500 per batch), `results BATCH_ID [--kind find|verify]`, and `job JOB_ID`, each with `--wait/--no-wait` polling. - SDK: `email.find` accepts `known_pattern` (sync + async), matching the platform's `POST /email/find` body. Omitted from the request when unset. - SDK: email routes are no longer `openapi=False` — the platform now exposes `/email/find`, `/email/find/batch`, and the poll routes in its OpenAPI spec, so `check_contract.py` validates them like every other route. diff --git a/packages/discolike-cli/src/discolike_cli/auth.py b/packages/discolike-cli/src/discolike_cli/auth.py index 5c8d56c..e1a0f6a 100644 --- a/packages/discolike-cli/src/discolike_cli/auth.py +++ b/packages/discolike-cli/src/discolike_cli/auth.py @@ -1,12 +1,11 @@ from __future__ import annotations import json -import os import sys +from typing import Any import typer -from discolike._config import ENV_API_KEY from discolike._config import KEYS_URL from discolike._config import delete_config from discolike._config import load_config @@ -19,39 +18,58 @@ MASKED_VISIBLE_CHARS = 4 +SOURCE_OPTION = "option" +SOURCE_ENV = "env" +SOURCE_CONFIG = "config" + def _mask(key: str) -> str: return "…" + key[-MASKED_VISIBLE_CHARS:] +def _global_key_source(ctx: typer.Context) -> str: + # typer vendors click without re-exporting ParameterSource, so match on the enum member name. + source = ctx.find_root().get_parameter_source("api_key") + return SOURCE_ENV if source is not None and source.name == "ENVIRONMENT" else SOURCE_OPTION + + +def _verify(ctx: typer.Context, *, api_key: str) -> None: + from discolike_cli.main import build_client + + kwargs: dict[str, Any] = {"api_key": api_key} + base_url = ctx.obj.get("base_url") + if base_url is not None: + kwargs["base_url"] = base_url + build_client(**kwargs).account.usage() + + @app.command() @handle_errors def login( + ctx: typer.Context, api_key: str | None = typer.Option(None, help=f"API key. Create one at {KEYS_URL}. Prompted for if omitted."), ) -> None: """Verify an API key and save it to the local config file.""" - from discolike_cli.main import build_client - - key = api_key or typer.prompt("API key", hide_input=True) - build_client(api_key=key).account.usage() + # An ambient DISCOLIKE_API_KEY must not silently become the saved key; only an explicit flag may. + passed_globally = ctx.obj.get("api_key") if _global_key_source(ctx) == SOURCE_OPTION else None + key = api_key or passed_globally or typer.prompt("API key", hide_input=True) + _verify(ctx, api_key=key) save_config({"auth_method": "api_key", "api_key": key}) print(json.dumps({"logged_in": True, "source": "api_key"}), file=sys.stderr) @app.command() @handle_errors -def status() -> None: - """Show which API key is in use (env or config) and verify it against the API.""" - from discolike_cli.main import build_client - - key = os.environ.get(ENV_API_KEY) - source = "env" +def status(ctx: typer.Context) -> None: + """Show which API key is in use (option, env, or config) and verify it against the API.""" + key = ctx.obj.get("api_key") + source = _global_key_source(ctx) if not key: key = load_config().get("api_key") - source = "config" + source = SOURCE_CONFIG if not key: resolve_api_key(None) - build_client(api_key=key).account.usage() + _verify(ctx, api_key=str(key)) emit({"source": source, "api_key": _mask(str(key)), "valid": True}) diff --git a/packages/discolike-cli/tests/conftest.py b/packages/discolike-cli/tests/conftest.py index 1fa1190..c0450db 100644 --- a/packages/discolike-cli/tests/conftest.py +++ b/packages/discolike-cli/tests/conftest.py @@ -6,6 +6,7 @@ """ from collections.abc import Callable +from typing import Any import httpx import pytest @@ -16,14 +17,25 @@ Handler = Callable[[httpx.Request], httpx.Response] +@pytest.fixture +def build_client_calls() -> list[dict[str, Any]]: + """Records the keyword arguments the CLI passes to its client factory.""" + return [] + + @pytest.fixture def install_build_client( monkeypatch: pytest.MonkeyPatch, make_client: Callable[[Handler], Discolike], + build_client_calls: list[dict[str, Any]], ) -> Callable[[Handler], None]: """Point the CLI's client factory at a mock transport driven by ``handler``.""" def _install(handler: Handler) -> None: - monkeypatch.setattr(cli_main, "build_client", lambda **kwargs: make_client(handler)) + def _factory(**kwargs: Any) -> Discolike: + build_client_calls.append(kwargs) + return make_client(handler) + + monkeypatch.setattr(cli_main, "build_client", _factory) return _install diff --git a/packages/discolike-cli/tests/test_auth.py b/packages/discolike-cli/tests/test_auth.py index 89c346f..3a5acc8 100644 --- a/packages/discolike-cli/tests/test_auth.py +++ b/packages/discolike-cli/tests/test_auth.py @@ -3,11 +3,13 @@ import json import stat from collections.abc import Callable +from typing import Any import httpx import pytest from typer.testing import CliRunner +from discolike._config import ENV_API_KEY from discolike._config import config_path from discolike._config import save_config from discolike_cli.main import app @@ -118,3 +120,91 @@ def test_cli_version_flag() -> None: assert result.exit_code == 0 assert f"discolike-cli {version('discolike-cli')}" in result.output assert f"(discolike {__version__})" in result.output + + +def test_status_honors_global_base_url( + monkeypatch: pytest.MonkeyPatch, + install_build_client: Callable[[Handler], None], + build_client_calls: list[dict[str, Any]], +) -> None: + install_build_client(_usage_ok) + monkeypatch.setenv("DISCOLIKE_API_KEY", "dk-abcdefgh1234") + result = runner.invoke(app, ["--base-url", "https://other.test/v1", "auth", "status"]) + assert result.exit_code == 0, result.output + assert build_client_calls == [{"api_key": "dk-abcdefgh1234", "base_url": "https://other.test/v1"}] + + +def test_login_honors_global_base_url( + install_build_client: Callable[[Handler], None], + build_client_calls: list[dict[str, Any]], +) -> None: + install_build_client(_usage_ok) + result = runner.invoke(app, ["--base-url", "https://other.test/v1", "auth", "login", "--api-key", "dk-1"]) + assert result.exit_code == 0, result.output + assert build_client_calls == [{"api_key": "dk-1", "base_url": "https://other.test/v1"}] + + +def test_status_verifies_explicitly_passed_api_key( + monkeypatch: pytest.MonkeyPatch, + install_build_client: Callable[[Handler], None], + build_client_calls: list[dict[str, Any]], +) -> None: + install_build_client(_usage_ok) + monkeypatch.setenv("DISCOLIKE_API_KEY", "dk-fromenv0000") + save_config({"auth_method": "api_key", "api_key": "dk-fromconfig11"}) + result = runner.invoke(app, ["--api-key", "dk-fromoption22", "auth", "status"]) + assert result.exit_code == 0, result.output + assert build_client_calls == [{"api_key": "dk-fromoption22"}] + payload = json.loads(result.stdout) + assert payload["source"] == "option" + assert payload["api_key"] == "…on22" + assert payload["valid"] is True + + +def test_status_source_is_env_when_key_comes_from_environment( + monkeypatch: pytest.MonkeyPatch, install_build_client: Callable[[Handler], None] +) -> None: + install_build_client(_usage_ok) + monkeypatch.setenv("DISCOLIKE_API_KEY", "dk-fromenv0000") + result = runner.invoke(app, ["auth", "status"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["source"] == "env" + + +def test_status_source_is_config_when_only_config_has_key(install_build_client: Callable[[Handler], None]) -> None: + install_build_client(_usage_ok) + save_config({"auth_method": "api_key", "api_key": "dk-fromconfig11"}) + result = runner.invoke(app, ["auth", "status"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["source"] == "config" + + +def test_login_api_key_option_beats_global_option( + install_build_client: Callable[[Handler], None], + build_client_calls: list[dict[str, Any]], +) -> None: + install_build_client(_usage_ok) + result = runner.invoke(app, ["--api-key", "dk-global", "auth", "login", "--api-key", "dk-local"]) + assert result.exit_code == 0, result.output + assert build_client_calls == [{"api_key": "dk-local"}] + assert json.loads(config_path().read_text())["api_key"] == "dk-local" + + +def test_login_ignores_ambient_env_key_and_still_prompts( + install_build_client: Callable[[Handler], None], + monkeypatch: pytest.MonkeyPatch, +) -> None: + install_build_client(_usage_ok) + monkeypatch.setenv(ENV_API_KEY, "dk-from-env") + result = runner.invoke(app, ["auth", "login"], input="dk-typed\n") + assert result.exit_code == 0, result.output + assert json.loads(config_path().read_text())["api_key"] == "dk-typed" + + +def test_login_accepts_explicitly_passed_global_key_without_prompting( + install_build_client: Callable[[Handler], None], +) -> None: + install_build_client(_usage_ok) + result = runner.invoke(app, ["--api-key", "dk-global", "auth", "login"]) + assert result.exit_code == 0, result.output + assert json.loads(config_path().read_text())["api_key"] == "dk-global"