In open mode ([web.oidc] unset) the dashboard has no login. Binding a LAN/any address there would expose host-management unauthenticated, so ci web serve now aborts unless the bind host is loopback. Auth-configured deployments bind LAN as before.
219 lines
7.5 KiB
Python
219 lines
7.5 KiB
Python
"""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"
|