"""Tests for the xceadmin CLI (design.md, ``xceadmin.cli``; task 1.1's package-skeleton coverage plus task 4.1's ``config build``/``config push`` wiring; Requirements 1.2, 1.3, 2.2, 2.4, 5.4, 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 8.1, 8.2). """ from __future__ import annotations import hashlib import ssl import socket import threading from pathlib import Path import pytest import yaml from click.testing import CliRunner from xcecommon.schema import load_schema from xceadmin.cli import cli def test_cli_help_exits_zero() -> None: """`xceadmin --help` must run cleanly and print usage text.""" runner = CliRunner() result = runner.invoke(cli, ["--help"]) assert result.exit_code == 0 assert "Usage:" in result.output def test_cli_help_shows_config_command() -> None: """`config build`/`config push` are reachable from the top-level group (4.1's core acceptance bullet).""" runner = CliRunner() result = runner.invoke(cli, ["--help"]) assert result.exit_code == 0 assert "Commands:" in result.output assert "config" in result.output def test_cli_config_help_shows_build_and_push() -> None: runner = CliRunner() result = runner.invoke(cli, ["config", "--help"]) assert result.exit_code == 0 assert "build" in result.output assert "push" in result.output def test_cli_group_invoked_with_no_args_does_not_traceback() -> None: """Invoking the bare group with no subcommand exits cleanly via click's own SystemExit (no subcommand given), not an unhandled traceback from application code.""" runner = CliRunner() result = runner.invoke(cli, []) assert isinstance(result.exception, SystemExit) assert "Usage:" in result.output def test_xcecommon_is_importable_independent_of_xceadmin() -> None: """xcecommon must be importable on its own, with no xceadmin import.""" import xcecommon # noqa: F401 - import success is the assertion # --------------------------------------------------------------------------- # config build # --------------------------------------------------------------------------- def test_config_build_writes_file_and_prints_sha256_to_stderr(tmp_path): (tmp_path / "xce.yaml").write_text( yaml.safe_dump( {"cluster_name": "eda1", "heartbeat_interval": 30, "inactive_heartbeat_count": 3} ) ) out_path = tmp_path / "out.bin" runner = CliRunner() result = runner.invoke(cli, ["config", "build", str(tmp_path), "-o", str(out_path)]) assert result.exit_code == 0, result.output assert out_path.is_file() expected_digest = hashlib.sha256(out_path.read_bytes()).hexdigest() assert expected_digest in result.stderr def test_config_build_dash_writes_to_stdout(tmp_path): """Byte-exact stdout content is already covered at the unit level (test_config_build.py::test_write_output_dash_writes_to_stdout_buffer) -- click's CliRunner captures output as decoded text and is lossy for arbitrary binary (confirmed: UTF-8 encode/decode does not round-trip an arbitrary byte sequence), so this test only confirms the CLI wires `-o -` through to write_output without erroring, not exact bytes. """ (tmp_path / "xce.yaml").write_text( yaml.safe_dump( {"cluster_name": "eda1", "heartbeat_interval": 30, "inactive_heartbeat_count": 3} ) ) runner = CliRunner() result = runner.invoke(cli, ["config", "build", str(tmp_path), "-o", "-"], color=False) assert result.exit_code == 0, result.output def test_config_build_schema_violation_exits_nonzero_with_message(tmp_path): (tmp_path / "queues.yaml").write_text(yaml.safe_dump({"queues": [{"priority": 1}]})) # missing name runner = CliRunner() result = runner.invoke(cli, ["config", "build", str(tmp_path)]) assert result.exit_code != 0 assert not isinstance(result.exception, KeyError | AttributeError | TypeError) assert result.output.strip() != "" def test_config_build_missing_configs_dir_does_not_raw_traceback(tmp_path): missing = tmp_path / "does-not-exist" out_path = tmp_path / "out.bin" runner = CliRunner() result = runner.invoke(cli, ["config", "build", str(missing), "-o", str(out_path)]) assert result.exit_code == 0, "a nonexistent dir behaves like one with zero known files present (1.5)" # --------------------------------------------------------------------------- # config push / config push --dry-run (mock controller) # --------------------------------------------------------------------------- def _server_context(cert_file: Path, key_file: Path) -> ssl.SSLContext: context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) context.load_cert_chain(certfile=str(cert_file), keyfile=str(key_file)) return context class _MockController: def __init__(self, handler, cert_file: Path, key_file: Path): self._handler = handler self._cert_file = cert_file self._key_file = key_file self._listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._listener.bind(("127.0.0.1", 0)) self._listener.listen(1) self.port = self._listener.getsockname()[1] self._thread = threading.Thread(target=self._run, daemon=True) self._thread.start() def _run(self) -> None: try: raw_conn, _ = self._listener.accept() except OSError: return try: tls_conn = _server_context(self._cert_file, self._key_file).wrap_socket( raw_conn, server_side=True ) except (ssl.SSLError, OSError): raw_conn.close() return try: self._handler(tls_conn) except OSError: pass finally: tls_conn.close() def close(self) -> None: self._listener.close() self._thread.join(timeout=5) @pytest.fixture def mock_controller(tls_certs): servers: list[_MockController] = [] def _make(handler) -> _MockController: srv = _MockController(handler, tls_certs.cert_file, tls_certs.key_file) servers.append(srv) return srv yield _make for srv in servers: srv.close() @pytest.fixture def cluster_bin(tmp_path): path = tmp_path / "cluster.bin" path.write_bytes(b"fake cluster config binary") return str(path) def test_config_push_success_exits_zero(mock_controller, cluster_bin, wrap_reply): from xcecommon import wire mod = load_schema() def _handler(conn): wire.recv_frame(conn) reply = mod.RequestReply.new_message() reply.status = 0 upgrade_reply = reply.data.init("upgradeconfigreplymsg") upgrade_reply.versionTag = 5 upgrade_reply.ackedCount = 3 upgrade_reply.error = "" wire.send_frame(conn, wrap_reply(reply)) srv = mock_controller(_handler) runner = CliRunner() result = runner.invoke( cli, ["config", "push", cluster_bin, "--controller", f"127.0.0.1:{srv.port}"], ) assert result.exit_code == 0, result.output assert "versionTag=5" in result.output assert "ackedCount=3" in result.output def test_config_push_zero_acked_count_exits_nonzero(mock_controller, cluster_bin, wrap_reply): from xcecommon import wire mod = load_schema() def _handler(conn): wire.recv_frame(conn) reply = mod.RequestReply.new_message() reply.status = 0 upgrade_reply = reply.data.init("upgradeconfigreplymsg") upgrade_reply.versionTag = 0 upgrade_reply.ackedCount = 0 upgrade_reply.error = "no quorum" wire.send_frame(conn, wrap_reply(reply)) srv = mock_controller(_handler) runner = CliRunner() result = runner.invoke( cli, ["config", "push", cluster_bin, "--controller", f"127.0.0.1:{srv.port}"], ) assert result.exit_code != 0 assert "no quorum" in result.output def test_config_push_connection_refused_exits_nonzero_not_raw_traceback(cluster_bin): listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listener.bind(("127.0.0.1", 0)) port = listener.getsockname()[1] listener.close() runner = CliRunner() result = runner.invoke( cli, ["config", "push", cluster_bin, "--controller", f"127.0.0.1:{port}"], ) assert result.exit_code != 0 assert result.output.strip() != "" def test_config_push_dry_run_reports_generations(mock_controller, cluster_bin): from xcecommon import wire mod = load_schema() def _handler(conn): wire.recv_frame(conn) reply = mod.ProbeConfigTagReply.new_message() reply.configTag = 4 wire.send_frame(conn, reply.to_bytes()) srv = mock_controller(_handler) runner = CliRunner() result = runner.invoke( cli, [ "config", "push", "--dry-run", cluster_bin, "--controller", f"127.0.0.1:{srv.port}", ], ) assert result.exit_code == 0, result.output assert "generation 4→5" in result.output