"""Unit tests for the CLI ↔ API client plumbing. Coverage: - ``k7.cli._config`` — TOML round-trip (``set`` / ``get`` / ``show``) through the simple hand-rolled parser, including a malformed-file fallback that returns ``{}``. - ``k7.cli._client`` — the four-stage resolution chain for both ``--api-url`` and ``--api-key`` (flag → env → config file → ``/etc/k7/api_*``), plus the "exit 1 with a helpful message" branch. - CLI routing — the global ``--core`` flag drops back to ``K7Core``; the default behaviour routes through ``k7_sdk.Client``. """ from __future__ import annotations import json from pathlib import Path from unittest.mock import MagicMock, patch import pytest import typer from typer.testing import CliRunner from k7.cli import _client as cli_client from k7.cli import _config as cli_config from k7.cli.k7 import app runner = CliRunner() # --------------------------------------------------------------------------- # Config file CRUD. # --------------------------------------------------------------------------- @pytest.fixture() def isolated_config_dir(tmp_path: Path, monkeypatch): """Point ``K7_CONFIG_DIR`` at ``tmp_path`` so tests don't touch ``~``.""" monkeypatch.setenv(cli_config.CONFIG_DIR_ENV, str(tmp_path)) yield tmp_path def test_config_read_returns_empty_dict_when_file_missing(isolated_config_dir): assert cli_config.read_config() == {} def test_config_set_writes_toml_then_get_reads_it_back(isolated_config_dir): cli_config.set_config_value("api.url", "https://10.0.0.1:31000") cli_config.set_config_value("api.key", "abc123") assert cli_config.get_config_value("api.url") == "https://10.0.0.1:31000" assert cli_config.get_config_value("api.key") == "abc123" # File is mode 0600. assert (isolated_config_dir / "config.toml").stat().st_mode & 0o777 == 0o600 def test_config_set_rejects_unknown_key(isolated_config_dir): with pytest.raises(ValueError, match="Unknown config key"): cli_config.set_config_value("api.token", "x") def test_config_read_tolerates_malformed_file(isolated_config_dir): p = isolated_config_dir / "config.toml" p.write_text("this is not [valid toml\n") # Should not raise; just return {}. assert cli_config.read_config() == {} def test_config_cli_set_get_show_round_trip(isolated_config_dir): set_res = runner.invoke(app, ["config", "set", "api.url", "https://10.0.0.1:31000"]) assert set_res.exit_code == 0, set_res.output get_res = runner.invoke(app, ["config", "get", "api.url"]) assert get_res.exit_code == 0 assert get_res.output.strip() == "https://10.0.0.1:31000" # Unknown key → exit 1 (Typer prints help on stderr in some versions). bad = runner.invoke(app, ["config", "set", "api.bogus", "x"]) assert bad.exit_code != 0 # ``config show`` redacts the key. runner.invoke(app, ["config", "set", "api.key", "supersecret"]) show = runner.invoke(app, ["config", "show"]) assert show.exit_code == 0 assert "supersecret" not in show.output assert "" in show.output # --------------------------------------------------------------------------- # URL / key resolver — flag > env > config > /etc/k7/*. # --------------------------------------------------------------------------- def test_resolve_api_url_prefers_flag_over_env(isolated_config_dir, monkeypatch): monkeypatch.setenv("K7_API_URL", "https://env.local:31000") assert cli_client._resolve_api_url("https://flag.local:31000") == "https://flag.local:31000" def test_resolve_api_url_prefers_env_over_config_file(isolated_config_dir, monkeypatch): cli_config.set_config_value("api.url", "https://config.local:31000") monkeypatch.setenv("K7_API_URL", "https://env.local:31000") assert cli_client._resolve_api_url(None) == "https://env.local:31000" def test_resolve_api_url_prefers_config_over_etc_fallback(isolated_config_dir, monkeypatch, tmp_path): cli_config.set_config_value("api.url", "https://config.local:31000") fake_etc = tmp_path / "fake_etc_api_endpoint" fake_etc.write_text("https://etc.local:31000") monkeypatch.delenv("K7_API_URL", raising=False) monkeypatch.setattr(cli_client, "_ETC_API_ENDPOINT", fake_etc) assert cli_client._resolve_api_url(None) == "https://config.local:31000" def test_resolve_api_url_falls_back_to_etc_when_nothing_else_set(isolated_config_dir, monkeypatch, tmp_path): monkeypatch.delenv("K7_API_URL", raising=False) fake_etc = tmp_path / "fake_etc_api_endpoint" fake_etc.write_text("https://etc.local:31000\n") monkeypatch.setattr(cli_client, "_ETC_API_ENDPOINT", fake_etc) assert cli_client._resolve_api_url(None) == "https://etc.local:31000" def test_resolve_api_url_returns_none_when_nothing_set(isolated_config_dir, monkeypatch, tmp_path): monkeypatch.delenv("K7_API_URL", raising=False) monkeypatch.setattr(cli_client, "_ETC_API_ENDPOINT", tmp_path / "does_not_exist") assert cli_client._resolve_api_url(None) is None def test_resolve_api_key_chain(isolated_config_dir, monkeypatch, tmp_path): # 1) Flag wins. assert cli_client._resolve_api_key("flag-key") == "flag-key" # 2) Env wins over config. cli_config.set_config_value("api.key", "config-key") monkeypatch.setenv("K7_API_KEY", "env-key") assert cli_client._resolve_api_key(None) == "env-key" monkeypatch.delenv("K7_API_KEY") # 3) Config wins over /etc fallback. fake_keys = tmp_path / "fake_api_keys.json" fake_keys.write_text(json.dumps({"hash": {"name": "x", "token": "etc-key"}})) monkeypatch.setattr(cli_client, "_ETC_API_KEYS", fake_keys) assert cli_client._resolve_api_key(None) == "config-key" def test_resolve_api_key_etc_fallback_picks_first_token(isolated_config_dir, monkeypatch, tmp_path): monkeypatch.delenv("K7_API_KEY", raising=False) fake_keys = tmp_path / "fake_api_keys.json" fake_keys.write_text( json.dumps( { "h1": {"name": "first", "token": "first-key"}, "h2": {"name": "second", "token": "second-key"}, } ) ) monkeypatch.setattr(cli_client, "_ETC_API_KEYS", fake_keys) assert cli_client._resolve_api_key(None) == "first-key" def test_resolve_client_exits_when_url_missing(isolated_config_dir, monkeypatch): monkeypatch.delenv("K7_API_URL", raising=False) monkeypatch.delenv("K7_API_KEY", raising=False) monkeypatch.setattr(cli_client, "_ETC_API_ENDPOINT", isolated_config_dir / "no_endpoint") with pytest.raises(typer.Exit): cli_client.resolve_client(None, None) def test_resolve_client_exits_when_key_missing(isolated_config_dir, monkeypatch): monkeypatch.delenv("K7_API_KEY", raising=False) monkeypatch.setattr(cli_client, "_ETC_API_KEYS", isolated_config_dir / "no_keys") with pytest.raises(typer.Exit): cli_client.resolve_client("https://flag.local:31000", None) def test_resolve_api_ca_chain(isolated_config_dir, monkeypatch, tmp_path): flag = tmp_path / "flag.pem" envp = tmp_path / "env.pem" cfgp = tmp_path / "cfg.pem" etcp = tmp_path / "etc.pem" for p in (flag, envp, cfgp, etcp): p.write_text("ca") monkeypatch.setattr(cli_client, "_ETC_API_CA", etcp) assert cli_client._resolve_api_ca(str(flag)) == str(flag) monkeypatch.setenv("K7_API_CA", str(envp)) assert cli_client._resolve_api_ca(None) == str(envp) monkeypatch.delenv("K7_API_CA") cli_config.set_config_value("api.ca", str(cfgp)) assert cli_client._resolve_api_ca(None) == str(cfgp) # Config wins over /etc; wipe config by pointing at a fresh dir. monkeypatch.setenv(cli_config.CONFIG_DIR_ENV, str(tmp_path / "empty-cfg")) assert cli_client._resolve_api_ca(None) == str(etcp) def test_verify_ssl_for_url_never_disables_https(): assert cli_client.verify_ssl_for_url("https://10.0.0.1:31007", None) is True assert cli_client.verify_ssl_for_url("https://10.0.0.1:31007", "/etc/k7/tls/ca.crt") == "/etc/k7/tls/ca.crt" assert cli_client.verify_ssl_for_url("http://10.0.0.1:31007", "/etc/k7/tls/ca.crt") is False def test_resolve_client_https_uses_ca_path(isolated_config_dir, monkeypatch, tmp_path): ca = tmp_path / "ca.crt" ca.write_text("dummy") monkeypatch.delenv("K7_API_CA", raising=False) monkeypatch.setattr(cli_client, "_ETC_API_CA", tmp_path / "missing") with patch("k7.cli._client.Client") as client_cls: cli_client.resolve_client("https://10.0.0.1:31007", "k", str(ca)) assert client_cls.call_args.kwargs["verify_ssl"] == str(ca) def test_resolve_client_https_without_ca_uses_system_store(isolated_config_dir, monkeypatch, tmp_path): monkeypatch.delenv("K7_API_CA", raising=False) monkeypatch.setattr(cli_client, "_ETC_API_CA", tmp_path / "missing") with patch("k7.cli._client.Client") as client_cls: cli_client.resolve_client("https://api.example.com:31007", "k") assert client_cls.call_args.kwargs["verify_ssl"] is True def test_resolve_client_http_disables_verify(isolated_config_dir, monkeypatch, tmp_path): monkeypatch.delenv("K7_API_CA", raising=False) monkeypatch.setattr(cli_client, "_ETC_API_CA", tmp_path / "missing") with patch("k7.cli._client.Client") as client_cls: cli_client.resolve_client("http://10.0.0.1:31007", "k") assert client_cls.call_args.kwargs["verify_ssl"] is False def test_config_accepts_api_ca(isolated_config_dir): cli_config.set_config_value("api.ca", "/tmp/ca.crt") assert cli_config.get_config_value("api.ca") == "/tmp/ca.crt" # --------------------------------------------------------------------------- # CLI routing — --core flag vs default API path. # --------------------------------------------------------------------------- class TestCliRoutingList: def test_default_routes_through_api_client(self, isolated_config_dir): fake_client = MagicMock() fake_client.list.return_value = [ { "name": "demo", "namespace": "default", "status": "Running", "ready": "True", "restarts": 0, "age": "1m", "image": "alpine:3.20", "backend": "kata-qemu-longhorn", "error_message": "", } ] with patch("k7.cli._client.resolve_client", return_value=fake_client): result = runner.invoke( app, ["--api-url", "https://x:1", "--api-key", "k", "list"], ) assert result.exit_code == 0, result.output fake_client.list.assert_called_once_with(namespace=None) # Table renders the sandbox name. assert "demo" in result.output def test_core_flag_falls_back_to_k7core(self, isolated_config_dir): from k7.core.models import SandboxInfo fake_core = MagicMock() fake_core.list_sandboxes = MagicMock( return_value=_awaitable( [ SandboxInfo( name="byc", namespace="ns", status="Running", ready="True", restarts=0, age="1m", image="alpine", backend="ql", ) ] ) ) with ( patch("k7.cli.k7.K7Core", return_value=fake_core), patch("k7.cli._client.resolve_client") as rc, ): result = runner.invoke(app, ["--core", "list"]) assert result.exit_code == 0, result.output rc.assert_not_called() # Rich may truncate long cells; assert on the short fixture name. assert "byc" in result.output class TestCliRoutingDelete: def test_default_routes_through_api_client(self, isolated_config_dir): fake_client = MagicMock() fake_client.delete.return_value = {"message": "Sandbox demo deleted"} with patch("k7.cli._client.resolve_client", return_value=fake_client): result = runner.invoke(app, ["--api-url", "https://x:1", "--api-key", "k", "delete", "demo"]) assert result.exit_code == 0, result.output fake_client.delete.assert_called_once_with("demo", namespace="default") assert "deleted" in result.output.lower() class TestCliRoutingExec: def test_default_routes_through_api_client(self, isolated_config_dir): fake_client = MagicMock() fake_client.exec.return_value = {"stdout": "hello\n", "stderr": "", "exit_code": 0} with patch("k7.cli._client.resolve_client", return_value=fake_client): result = runner.invoke( app, [ "--api-url", "https://x:1", "--api-key", "k", "exec", "demo", "echo", "hello", ], ) assert result.exit_code == 0, result.output fake_client.exec.assert_called_once_with("demo", "echo hello", namespace="default") assert "hello" in result.output class TestCliRoutingLogs: def test_default_routes_through_api_client(self, isolated_config_dir): fake_client = MagicMock() fake_client.logs.return_value = "line1\nline2\n" with patch("k7.cli._client.resolve_client", return_value=fake_client): result = runner.invoke( app, ["--api-url", "https://x:1", "--api-key", "k", "logs", "demo", "--tail", "50"], ) assert result.exit_code == 0, result.output fake_client.logs.assert_called_once_with("demo", namespace="default", tail=50) assert "line1" in result.output class TestCliRoutingSnapshotList: def test_default_routes_through_api_client(self, isolated_config_dir): fake_client = MagicMock() fake_client.list_snapshots.return_value = [ { "name": "demo-paused-1", "namespace": "default", "source_sandbox": "demo", "size_bytes": 1024 * 1024 * 1024, "age": "1m", "ready_to_use": True, "kind": "pause", } ] with patch("k7.cli._client.resolve_client", return_value=fake_client): result = runner.invoke( app, ["--api-url", "https://x:1", "--api-key", "k", "snapshot", "list"], ) assert result.exit_code == 0, result.output fake_client.list_snapshots.assert_called_once_with( namespace="default", all_namespaces=False, sandbox=None, kind=None ) assert "demo-paused-1" in result.output # --------------------------------------------------------------------------- # Helpers. # --------------------------------------------------------------------------- def _awaitable(value): """Return a coroutine factory that yields ``value`` once awaited. Lets us hand the mocked ``K7Core.list_sandboxes`` an ``asyncio.run``-able object (the CLI calls ``asyncio.run(core.list_sandboxes(...))``). """ async def _coro(): return value return _coro()