"""Tests for ``ci web serve`` (W4 of the web-UI plan). Two concerns: 1. Behaviour: ``ci web serve --help`` works and ``serve`` builds the app and calls ``uvicorn.run`` with the right host/port (``uvicorn.run`` is monkeypatched -- no real server is ever started). ``CI_ROOT``/``CI_CONFIG`` are pointed at an empty tmp dir so the test never depends on this host's real ``/var/lib/ci/config.toml``. 2. Architecture: ``commands/web.py`` must import only ``click``/stdlib at module scope. ``fastapi``/``uvicorn``/``ci_orchestrator.web`` may only be imported lazily, inside the ``serve`` function body -- this keeps ``ci --help`` working when the optional ``[web]`` extra is not installed. """ from __future__ import annotations import ast import inspect import sys from pathlib import Path from typing import Any import pytest from click.testing import CliRunner import ci_orchestrator.commands.web as web_module from ci_orchestrator.__main__ import cli from ci_orchestrator.config import Config @pytest.fixture(autouse=True) def _isolated_config_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Point config resolution at an empty tmp dir (no config.toml present). Every test in this file exercises ``ci web serve``, which calls ``load_config()``; without this, the real ``/var/lib/ci/config.toml`` on this host (see CLAUDE.md) could leak in and make assertions flaky. """ monkeypatch.setenv("CI_ROOT", str(tmp_path)) monkeypatch.setenv("CI_CONFIG", str(tmp_path / "does-not-exist.toml")) def _install_fake_uvicorn(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]: calls: list[dict[str, Any]] = [] class _FakeUvicorn: @staticmethod def run(app: object, **kwargs: Any) -> None: calls.append({"app": app, **kwargs}) monkeypatch.setitem(sys.modules, "uvicorn", _FakeUvicorn()) return calls def test_web_serve_help_works() -> None: runner = CliRunner() result = runner.invoke(cli, ["web", "serve", "--help"]) assert result.exit_code == 0 assert "--host" in result.output assert "--port" in result.output def test_web_serve_calls_uvicorn_run_with_host_and_port(monkeypatch: pytest.MonkeyPatch) -> None: calls = _install_fake_uvicorn(monkeypatch) runner = CliRunner() result = runner.invoke(cli, ["web", "serve", "--host", "127.0.0.1", "--port", "9999"]) assert result.exit_code == 0, result.output assert len(calls) == 1 assert calls[0]["host"] == "127.0.0.1" assert calls[0]["port"] == 9999 assert calls[0]["app"] is not None def test_web_serve_defaults_host_and_port_from_config(monkeypatch: pytest.MonkeyPatch) -> None: calls = _install_fake_uvicorn(monkeypatch) runner = CliRunner() result = runner.invoke(cli, ["web", "serve"]) assert result.exit_code == 0, result.output assert len(calls) == 1 assert calls[0]["host"] == "127.0.0.1" assert calls[0]["port"] == 8800 def test_web_serve_host_flag_overrides_config_default_only( monkeypatch: pytest.MonkeyPatch, ) -> None: calls = _install_fake_uvicorn(monkeypatch) runner = CliRunner() result = runner.invoke(cli, ["web", "serve", "--port", "8123"]) assert result.exit_code == 0, result.output assert calls[0]["host"] == "127.0.0.1" # config default, untouched assert calls[0]["port"] == 8123 # flag wins def test_web_serve_never_binds_0_0_0_0_by_default() -> None: """Sanity: the un-overridden default host must never be 0.0.0.0.""" from ci_orchestrator.config import WebConfig assert WebConfig().bind_host == "127.0.0.1" # ── architecture: commands/web.py stays dep-light ─────────────────────────── def test_commands_web_module_has_no_top_level_heavy_imports() -> None: """Static check: no ``fastapi``/``uvicorn``/``ci_orchestrator.web`` import at module (top) level -- only inside function bodies. This is what keeps ``ci --help`` working when the optional ``[web]`` extra is not installed. """ source = inspect.getsource(web_module) tree = ast.parse(source) forbidden_prefixes = ("fastapi", "uvicorn", "ci_orchestrator.web") for node in tree.body: # module (top) level statements only if isinstance(node, ast.Import): for alias in node.names: assert not alias.name.startswith( forbidden_prefixes ), f"top-level `import {alias.name}` in commands/web.py" elif isinstance(node, ast.ImportFrom): module = node.module or "" assert not module.startswith( forbidden_prefixes ), f"top-level `from {module} import ...` in commands/web.py" def test_commands_web_module_object_carries_no_fastapi_or_uvicorn_attribute() -> None: """Belt-and-braces: the module object itself has no fastapi/uvicorn name bound.""" assert not hasattr(web_module, "FastAPI") assert not hasattr(web_module, "uvicorn") assert not hasattr(web_module, "create_app") def test_web_group_registered_on_root_cli() -> None: assert "web" in cli.commands assert "serve" in cli.commands["web"].commands # type: ignore[attr-defined] def test_config_import_does_not_require_web_extra() -> None: """``Config``/``load_config`` (imported lazily inside serve) is core, not [web].""" assert Config is not None # ── W5 security guard: no unauthenticated bind on a non-loopback address ────── def _cfg(*, oidc: bool, tmp: Path) -> Config: from ci_orchestrator.config import ( BackendConfig, Paths, WebConfig, WebOidcConfig, ) web_oidc = ( WebOidcConfig(issuer="https://gitea.lan", client_id="cid", redirect_url="http://x/cb") if oidc else None ) return Config( paths=Paths( root=tmp, templates=tmp / "templates", build_vms=tmp / "build-vms", artifacts=tmp / "artifacts", keys=tmp / "keys", ), backend=BackendConfig(), web=WebConfig(oidc=web_oidc), ) @pytest.mark.parametrize("host", ["127.0.0.1", "::1", "localhost", ""]) def test_is_loopback_true(host: str) -> None: assert web_module._is_loopback(host) is True @pytest.mark.parametrize("host", ["192.168.1.50", "10.0.0.5", "0.0.0.0", "gitea.lan"]) def test_is_loopback_false(host: str) -> None: assert web_module._is_loopback(host) is False def test_bind_guard_blocks_lan_bind_without_oidc(tmp_path: Path) -> None: err = web_module._bind_guard_error("192.168.1.50", _cfg(oidc=False, tmp=tmp_path)) assert err is not None assert "refusing to bind" in err def test_bind_guard_allows_lan_bind_with_oidc(tmp_path: Path) -> None: assert web_module._bind_guard_error("192.168.1.50", _cfg(oidc=True, tmp=tmp_path)) is None def test_bind_guard_allows_loopback_without_oidc(tmp_path: Path) -> None: assert web_module._bind_guard_error("127.0.0.1", _cfg(oidc=False, tmp=tmp_path)) is None def test_web_serve_refuses_lan_bind_without_auth(monkeypatch: pytest.MonkeyPatch) -> None: """End-to-end: ``serve`` aborts (and never starts uvicorn) on an unsafe bind. The autouse fixture points config at an empty tmp dir → ``web.oidc`` is ``None`` (open mode), so a non-loopback ``--host`` must be refused. """ calls = _install_fake_uvicorn(monkeypatch) result = CliRunner().invoke(cli, ["web", "serve", "--host", "10.0.0.5", "--port", "8800"]) assert result.exit_code != 0 assert "refusing to bind" in result.output assert calls == [], "uvicorn.run must never be called on a refused bind"