"""Tests for the Executions card: route, template, and token handling. Three separate contracts live here: 1. ``GET /executions`` renders run rows with their VMs, and degrades to a notice + the plain VM rows whenever Gitea cannot be reached. 2. The Gitea access token from an OIDC login is kept **server-side only** -- never in the signed-but-unencrypted session cookie (SECURITY-CRITICAL, see ``web/auth.py``'s module docstring). 3. A ``401`` from Gitea triggers exactly one refresh attempt. No network, no keyring, no real ``vmrun``: the backend is injected, the keyring is monkeypatched, and Gitea is either stubbed at ``operations.executions.list_executions`` (template-level tests) or at ``urlopen`` (one end-to-end pass). """ from __future__ import annotations import json import shutil import urllib.error from collections import namedtuple from datetime import UTC, datetime from io import BytesIO from pathlib import Path from typing import Any import pytest from fastapi.responses import RedirectResponse from fastapi.testclient import TestClient import ci_orchestrator.operations.executions as ex import ci_orchestrator.web.app as app_module from ci_orchestrator.backends.protocol import VmHandle from ci_orchestrator.config import ( BackendConfig, CargoCacheConfig, Config, Paths, WebConfig, WebOidcConfig, ) from ci_orchestrator.credentials import Credential, KeyringCredentialStore from ci_orchestrator.operations.executions import Execution, ExecutionsView from ci_orchestrator.operations.vms import LiveVm from ci_orchestrator.web.app import create_app, get_backend from ci_orchestrator.web.auth import gitea_token_for, store_gitea_token _SESSION_CRED_NAME = "TestWebSessionSecret" _OIDC_CRED_NAME = "TestWebOidcClientSecret" _PAT_CRED_NAME = "TestGiteaApiToken" _FAKE_PAT = "pat-do-not-leak-123" # ── fixtures / fakes ──────────────────────────────────────────────────────── def _install_fake_keyring(monkeypatch: pytest.MonkeyPatch) -> None: secrets = { _SESSION_CRED_NAME: "sess-secret", _OIDC_CRED_NAME: "oidc-client-secret", _PAT_CRED_NAME: _FAKE_PAT, } def fake_get(self: KeyringCredentialStore, target: str) -> Credential: if target not in secrets: raise KeyError(target) return Credential(username=target, password=secrets[target]) monkeypatch.setattr(KeyringCredentialStore, "get", fake_get) def _quiet_host(monkeypatch: pytest.MonkeyPatch) -> None: _DiskUsage = namedtuple("_DiskUsage", ["total", "used", "free"]) gb = 1024**3 monkeypatch.setattr( shutil, "disk_usage", lambda _p: _DiskUsage(total=100 * gb, used=40 * gb, free=60 * gb) ) monkeypatch.setattr(shutil, "which", lambda _name: None) def _make_config( root: Path, *, oidc: bool = False, gitea_url: str | None = "https://gitea.example.lan", token_cred: str | None = None, ) -> Config: oidc_config = None if oidc: oidc_config = WebOidcConfig( issuer="https://gitea.example.lan", client_id="ci-web", redirect_url="http://127.0.0.1:8800/auth/callback", client_secret_cred=_OIDC_CRED_NAME, scopes=["openid", "profile", "read:repository"], ) return Config( paths=Paths( root=root, templates=root / "templates", build_vms=root / "build-vms", artifacts=root / "artifacts", keys=root / "keys", ), backend=BackendConfig(), cargo_cache=CargoCacheConfig( enabled=True, root=root / "cache" / "cargo", retention_days=30 ), web=WebConfig( enabled=True, allowed_users=["Simone"], session_secret_cred=_SESSION_CRED_NAME, oidc=oidc_config, gitea_url=gitea_url, gitea_token_cred=token_cred, ), ) class _FakeBackend: """Stub ``VmBackend`` -- proves no real ``vmrun`` is ever invoked.""" def __init__(self, handles: list[VmHandle]) -> None: self._handles = handles def list_running(self) -> list[VmHandle]: return list(self._handles) def get_ip(self, _handle: VmHandle, timeout: float = 0.0) -> str | None: return "192.168.79.201" def _make_clone_vmx(build_vms: Path, name: str) -> Path: vmx = build_vms / name / f"{name}.vmx" vmx.parent.mkdir(parents=True, exist_ok=True) vmx.write_text('guestOS = "ubuntu-64"\n', encoding="utf-8") return vmx def _vm(name: str) -> LiveVm: return LiveVm( name=name, vmx=f"/var/lib/ci/build-vms/{name}/{name}.vmx", ip="192.168.79.201", guest_os="ubuntu-64", uptime_sec=180, is_orphan=False, ) def _execution(**overrides: Any) -> Execution: base: dict[str, Any] = { "run_id": 412, "number": 12, "attempt": 1, "workflow": "build-ns7zip.yml", "repo": "simone/local-ci-cd-system", "branch": "main", "commit": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "event": "push", "actor": "Simone", "status": "running", "started": datetime(2026, 7, 25, 11, 50, tzinfo=UTC), "completed": None, "duration_sec": 600, "html_url": "https://gitea.example.lan/simone/local-ci-cd-system/actions/runs/12", "vms": [], } base.update(overrides) return Execution(**base) def _stub_view(monkeypatch: pytest.MonkeyPatch, view: ExecutionsView) -> list[str | None]: """Replace the operations call; return the log of tokens it was given.""" tokens: list[str | None] = [] def fake_list_executions( _config: Config, _vms: list[LiveVm], *, token: str | None, **_kw: Any ) -> ExecutionsView: tokens.append(token) return view monkeypatch.setattr(app_module, "list_executions", fake_list_executions) return tokens @pytest.fixture(autouse=True) def _clear_executions_cache() -> Any: ex.clear_cache() yield ex.clear_cache() # ── 1. rendering ──────────────────────────────────────────────────────────── def test_card_renders_run_row_with_its_vm( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: vm = _vm("Clone_412-1_20260725_115000_linux") _stub_view( monkeypatch, ExecutionsView( runs=[_execution(vms=[vm])], unmatched_vms=[], source="admin", error=None ), ) app = create_app(_make_config(tmp_path)) app.dependency_overrides[get_backend] = lambda: _FakeBackend([]) client = TestClient(app) resp = client.get("/executions") assert resp.status_code == 200 assert "#12" in resp.text assert "build-ns7zip.yml" in resp.text assert "simone/local-ci-cd-system" in resp.text assert "a1b2c3d" in resp.text # commit shortened to 7 chars assert "badge-info" in resp.text # running badge assert ">running<" in resp.text # never colour-only assert vm.name in resp.text assert "Remove" in resp.text def test_attempt_shown_only_when_greater_than_one( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: _stub_view( monkeypatch, ExecutionsView( runs=[_execution(attempt=2)], unmatched_vms=[], source="admin", error=None ), ) app = create_app(_make_config(tmp_path)) app.dependency_overrides[get_backend] = lambda: _FakeBackend([]) client = TestClient(app) resp = client.get("/executions") assert "#12.2" in resp.text @pytest.mark.parametrize( ("status", "badge"), [ ("success", "badge-success"), ("failure", "badge-danger"), ("queued", "badge-muted"), ("cancelled", "badge-muted"), ("unknown", "badge-muted"), ], ) def test_status_badges( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, status: str, badge: str ) -> None: _stub_view( monkeypatch, ExecutionsView( runs=[_execution(status=status)], unmatched_vms=[], source="admin", error=None ), ) app = create_app(_make_config(tmp_path)) app.dependency_overrides[get_backend] = lambda: _FakeBackend([]) client = TestClient(app) resp = client.get("/executions") assert badge in resp.text assert f">{status}<" in resp.text @pytest.mark.parametrize( ("status", "icon"), [ ("success", "i-check"), ("failure", "i-x"), ], ) def test_status_badge_carries_a_check_or_x_icon( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, status: str, icon: str ) -> None: """A pass/fail outcome should read without needing the badge colour.""" _stub_view( monkeypatch, ExecutionsView( runs=[_execution(status=status)], unmatched_vms=[], source="admin", error=None ), ) app = create_app(_make_config(tmp_path)) app.dependency_overrides[get_backend] = lambda: _FakeBackend([]) client = TestClient(app) resp = client.get("/executions") assert f'href="#{icon}"' in resp.text def test_status_badge_carries_a_play_icon_for_running( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """In progress is its own state, not a verdict -- but still not bare text.""" _stub_view( monkeypatch, ExecutionsView( runs=[_execution(status="running")], unmatched_vms=[], source="admin", error=None ), ) app = create_app(_make_config(tmp_path)) app.dependency_overrides[get_backend] = lambda: _FakeBackend([]) client = TestClient(app) resp = client.get("/executions") # The card's own header icon is also #i-play -- count, don't just look # for presence, or this assertion is trivially true either way. assert resp.text.count('href="#i-play"') == 2 @pytest.mark.parametrize("status", ["queued", "cancelled", "unknown"]) def test_status_badge_has_no_icon_for_non_terminal_states( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, status: str ) -> None: """queued/cancelled/unknown are neither a verdict nor in progress -- plain text.""" _stub_view( monkeypatch, ExecutionsView( runs=[_execution(status=status)], unmatched_vms=[], source="admin", error=None ), ) app = create_app(_make_config(tmp_path)) app.dependency_overrides[get_backend] = lambda: _FakeBackend([]) client = TestClient(app) resp = client.get("/executions") assert ' None: """Both the relative and absolute renderings are pre-rendered into the button's data attributes -- app.js swaps textContent/title on click, no round trip. Server-side, this test can only pin the markup the toggle depends on, not the click itself.""" _stub_view( monkeypatch, ExecutionsView( runs=[_execution(started="2026-07-25T11:50:00Z")], unmatched_vms=[], source="admin", error=None, ), ) app = create_app(_make_config(tmp_path)) app.dependency_overrides[get_backend] = lambda: _FakeBackend([]) client = TestClient(app) resp = client.get("/executions") assert '