"""Tests for the pure ``operations.executions`` layer (Executions card). The module under test talks to the Gitea Actions API over ``urllib.request.urlopen``, which is monkeypatched throughout: no test ever touches the network. Zero ``click``/``print`` in the module itself — every call returns an :class:`ExecutionsView`, and *no* failure mode raises. """ from __future__ import annotations import json import urllib.error from datetime import UTC, datetime from io import BytesIO from pathlib import Path from typing import Any import pytest import ci_orchestrator.operations.executions as ex from ci_orchestrator.config import ( BackendConfig, CargoCacheConfig, Config, Paths, WebConfig, WebOidcConfig, ) from ci_orchestrator.operations.executions import Execution, ExecutionsView, list_executions from ci_orchestrator.operations.vms import LiveVm _NOW = datetime(2026, 7, 25, 12, 0, 0, tzinfo=UTC) def _make_config( tmp_path: Path, *, gitea_url: str | None = "https://gitea.example", limit: int = 15, ) -> Config: return Config( paths=Paths( root=tmp_path, templates=tmp_path / "templates", build_vms=tmp_path / "build-vms", artifacts=tmp_path / "artifacts", keys=tmp_path / "keys", ), backend=BackendConfig(), cargo_cache=CargoCacheConfig( enabled=True, root=tmp_path / "cache", retention_days=30 ), web=WebConfig(gitea_url=gitea_url, executions_limit=limit), ) def _run( *, run_id: int = 412, number: int = 12, attempt: int = 1, status: str = "success", conclusion: str = "success", started: str | None = "2026-07-25T11:50:00Z", completed: str | None = "2026-07-25T11:57:20Z", branch: str = "main", repo: str = "simone/local-ci-cd-system", path: str = ".gitea/workflows/build-ns7zip.yml", ) -> dict[str, Any]: """One ``ActionWorkflowRun`` as Gitea 1.26 serialises it.""" return { "id": run_id, "run_number": number, "run_attempt": attempt, "status": status, "conclusion": conclusion, "started_at": started, "completed_at": completed, "head_branch": branch, "head_sha": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "event": "push", "path": path, "display_title": "feat: something", "html_url": f"https://gitea.example/{repo}/actions/runs/{number}", "repository": {"full_name": repo}, "trigger_actor": {"login": "Simone"}, } class _FakeResponse: """Minimal ``urlopen`` context manager returning a JSON body.""" def __init__(self, payload: object) -> None: self._body = BytesIO(json.dumps(payload).encode("utf-8")) def __enter__(self) -> BytesIO: return self._body def __exit__(self, *_exc: object) -> None: return None def _http_error(code: int) -> urllib.error.HTTPError: return urllib.error.HTTPError( url="https://gitea.example", code=code, msg="nope", hdrs=None, fp=None ) def _install_urlopen( monkeypatch: pytest.MonkeyPatch, handler: Any ) -> list[tuple[str, str | None]]: """Patch ``urlopen``; return the (url, authorization) log it appends to.""" calls: list[tuple[str, str | None]] = [] def _fake_urlopen(request: Any, timeout: float | None = None) -> Any: calls.append((request.full_url, request.get_header("Authorization"))) result = handler(request.full_url) if isinstance(result, Exception): raise result return _FakeResponse(result) monkeypatch.setattr(ex.urllib.request, "urlopen", _fake_urlopen) return calls def _admin_ok(runs: list[dict[str, Any]]) -> Any: def handler(url: str) -> Any: if "/admin/actions/runs" in url: return {"total_count": len(runs), "workflow_runs": runs} raise AssertionError(f"unexpected url: {url}") return handler def _vm(name: str, *, ip: str = "192.168.79.201", uptime: int = 120) -> LiveVm: return LiveVm( name=name, vmx=f"/var/lib/ci/build-vms/{name}/{name}.vmx", ip=ip, guest_os="ubuntu-64", uptime_sec=uptime, is_orphan=False, ) @pytest.fixture(autouse=True) def _clear_cache() -> Any: """Every test starts with an empty TTL cache (it is module-level state).""" ex.clear_cache() yield ex.clear_cache() # ── happy path ────────────────────────────────────────────────────────────── def test_admin_payload_parsed_into_executions( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: calls = _install_urlopen(monkeypatch, _admin_ok([_run()])) view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW) assert view.error is None assert view.source == "admin" assert view.auth_failed is False assert view.runs == [ Execution( 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="success", started=datetime(2026, 7, 25, 11, 50, tzinfo=UTC), completed=datetime(2026, 7, 25, 11, 57, 20, tzinfo=UTC), duration_sec=440, html_url="https://gitea.example/simone/local-ci-cd-system/actions/runs/12", vms=[], ) ] # Bearer auth against the admin endpoint, with the configured limit. assert calls == [ ( "https://gitea.example/api/v1/admin/actions/runs?limit=15", "Bearer tok", ) ] def test_limit_argument_overrides_config( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: calls = _install_urlopen(monkeypatch, _admin_ok([])) list_executions(_make_config(tmp_path), [], token="tok", limit=3, now=_NOW) assert calls[0][0].endswith("limit=3") def test_running_run_duration_counts_from_now( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: running = _run(status="in_progress", conclusion="", completed=None) _install_urlopen(monkeypatch, _admin_ok([running])) view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW) assert view.runs[0].status == "running" assert view.runs[0].completed is None # 11:50:00 -> 12:00:00 assert view.runs[0].duration_sec == 600 def test_run_without_start_has_no_duration( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: queued = _run(status="waiting", conclusion="", started=None, completed=None) _install_urlopen(monkeypatch, _admin_ok([queued])) view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW) assert view.runs[0].status == "queued" assert view.runs[0].started is None assert view.runs[0].duration_sec is None @pytest.mark.parametrize( ("status", "conclusion", "expected"), [ ("in_progress", "", "running"), ("running", "", "running"), ("waiting", "", "queued"), ("queued", "", "queued"), ("blocked", "", "queued"), ("completed", "success", "success"), ("completed", "failure", "failure"), ("completed", "cancelled", "cancelled"), ("completed", "skipped", "skipped"), ("success", "success", "success"), ("failure", "failure", "failure"), ("completed", "neutral", "unknown"), ("", "", "unknown"), ], ) def test_status_mapping( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, status: str, conclusion: str, expected: str, ) -> None: _install_urlopen( monkeypatch, _admin_ok([_run(status=status, conclusion=conclusion)]) ) view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW) assert view.runs[0].status == expected def test_missing_fields_degrade_per_field( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """A sparse run object must still produce a row, never an exception.""" _install_urlopen(monkeypatch, _admin_ok([{"id": 9}])) view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW) row = view.runs[0] assert (row.run_id, row.number, row.attempt) == (9, 0, 1) assert (row.workflow, row.repo, row.branch, row.commit) == ("", "", "", "") assert (row.event, row.actor, row.html_url) == ("", "", "") assert row.status == "unknown" assert row.duration_sec is None assert view.error is None def test_actor_falls_back_to_actor_field( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: payload = _run() del payload["trigger_actor"] payload["actor"] = {"login": "someone-else"} _install_urlopen(monkeypatch, _admin_ok([payload])) view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW) assert view.runs[0].actor == "someone-else" def test_non_list_runs_payload_is_an_error( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: _install_urlopen(monkeypatch, lambda _url: {"workflow_runs": "nope"}) view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW) assert view.runs == [] assert view.error is not None assert view.source == "none" # ── endpoint fallback ─────────────────────────────────────────────────────── @pytest.mark.parametrize("code", [403, 404]) def test_admin_denied_falls_back_to_user_endpoint( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, code: int ) -> None: def handler(url: str) -> Any: if "/admin/" in url: return _http_error(code) return {"workflow_runs": [_run()]} calls = _install_urlopen(monkeypatch, handler) view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW) assert view.source == "user" assert view.error is None assert len(view.runs) == 1 assert [url for url, _auth in calls] == [ "https://gitea.example/api/v1/admin/actions/runs?limit=15", "https://gitea.example/api/v1/user/actions/runs?limit=15", ] def test_both_endpoints_denied_names_the_scope_problem( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """403 on both listings means "valid token, no API scope" -- say that.""" _install_urlopen(monkeypatch, lambda _url: _http_error(403)) view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW) assert view.runs == [] assert view.source == "none" assert view.error is not None assert "403" in view.error assert "read:repository" in view.error assert "gitea_token_cred" in view.error assert view.auth_failed is False # a refresh would not fix a scope problem def test_user_endpoint_404_keeps_the_plain_http_error( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Only a 403 on the *user* listing implies a scope problem, not a 404.""" def handler(url: str) -> Any: return _http_error(403 if "/admin/" in url else 404) _install_urlopen(monkeypatch, handler) view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW) assert view.error == "Gitea API returned HTTP 404." # ── degraded paths ────────────────────────────────────────────────────────── def test_missing_token_lists_vms_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: calls = _install_urlopen(monkeypatch, _admin_ok([_run()])) vm = _vm("Clone_412-1_20260725_115000_linux") view = list_executions(_make_config(tmp_path), [vm], token=None, now=_NOW) assert view == ExecutionsView( runs=[], unmatched_vms=[vm], source="none", error="Gitea token not configured.", auth_failed=False, ) assert calls == [] # no HTTP attempted without a token def test_missing_gitea_url_lists_vms_only( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: calls = _install_urlopen(monkeypatch, _admin_ok([_run()])) config = _make_config(tmp_path, gitea_url=None) view = list_executions(config, [_vm("Clone_1-1_20260725_115000_x")], token="tok", now=_NOW) assert view.runs == [] assert view.error is not None assert len(view.unmatched_vms) == 1 assert calls == [] def test_gitea_url_derived_from_oidc_issuer( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """No explicit ``gitea_url``: the OIDC issuer is the fallback base URL.""" calls = _install_urlopen(monkeypatch, _admin_ok([])) config = _make_config(tmp_path, gitea_url=None) config = Config( paths=config.paths, backend=config.backend, cargo_cache=config.cargo_cache, web=WebConfig( oidc=WebOidcConfig( issuer="https://gitea.example", client_id="cid", redirect_url="http://host/auth/callback", ) ), ) view = list_executions(config, [], token="tok", now=_NOW) assert view.error is None assert calls[0][0].startswith("https://gitea.example/api/v1/admin/") def test_401_sets_auth_failed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: _install_urlopen(monkeypatch, lambda _url: _http_error(401)) view = list_executions(_make_config(tmp_path), [], token="stale", now=_NOW) assert view.auth_failed is True assert view.error is not None assert view.runs == [] def test_network_error_degrades(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: _install_urlopen(monkeypatch, lambda _url: urllib.error.URLError("no route to host")) vm = _vm("Clone_412-1_20260725_115000_linux") view = list_executions(_make_config(tmp_path), [vm], token="tok", now=_NOW) assert view.runs == [] assert view.unmatched_vms == [vm] assert view.error is not None assert view.auth_failed is False def test_timeout_degrades(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: _install_urlopen(monkeypatch, lambda _url: TimeoutError("timed out")) view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW) assert view.runs == [] assert view.error is not None def test_malformed_json_degrades(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: class _BadBody: def __enter__(self) -> Any: return self def __exit__(self, *_exc: object) -> None: return None def read(self) -> bytes: return b"{not json" monkeypatch.setattr( ex.urllib.request, "urlopen", lambda _req, timeout=None: _BadBody() ) view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW) assert view.runs == [] assert view.error is not None # ── VM correlation ────────────────────────────────────────────────────────── def test_vm_attached_to_its_run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: _install_urlopen(monkeypatch, _admin_ok([_run(run_id=412, attempt=1)])) vm = _vm("Clone_412-1_20260725_115000_linux") view = list_executions(_make_config(tmp_path), [vm], token="tok", now=_NOW) assert view.runs[0].vms == [vm] assert view.unmatched_vms == [] def test_matrix_run_collects_several_vms( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: _install_urlopen(monkeypatch, _admin_ok([_run(run_id=412)])) win = _vm("Clone_412-1-win_20260725_115000_a") linux = _vm("Clone_412-1-linux_20260725_115005_b") view = list_executions(_make_config(tmp_path), [win, linux], token="tok", now=_NOW) assert view.runs[0].vms == [win, linux] assert view.unmatched_vms == [] def test_attempt_is_part_of_the_match( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """A clone from attempt 1 must not attach to attempt 2 of the same run.""" _install_urlopen(monkeypatch, _admin_ok([_run(run_id=412, attempt=2)])) vm = _vm("Clone_412-1_20260725_115000_linux") view = list_executions(_make_config(tmp_path), [vm], token="tok", now=_NOW) assert view.runs[0].vms == [] assert view.unmatched_vms == [vm] def test_unparseable_and_stale_vms_are_unmatched( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: _install_urlopen(monkeypatch, _admin_ok([_run(run_id=412)])) stale = _vm("Clone_370-1_20260722_101500_linux") weird = _vm("some-manually-made-vm") view = list_executions(_make_config(tmp_path), [stale, weird], token="tok", now=_NOW) assert view.runs[0].vms == [] assert view.unmatched_vms == [stale, weird] # ── TTL cache ─────────────────────────────────────────────────────────────── def test_second_call_within_ttl_reuses_the_fetch( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: calls = _install_urlopen(monkeypatch, _admin_ok([_run(run_id=412)])) clock = [1000.0] monkeypatch.setattr(ex, "_monotonic", lambda: clock[0]) config = _make_config(tmp_path) vm = _vm("Clone_412-1_20260725_115000_linux") first = list_executions(config, [], token="tok", now=_NOW) clock[0] += ex._TTL_SEC / 2 second = list_executions(config, [vm], token="tok", now=_NOW) assert len(calls) == 1 assert first.runs[0].vms == [] # Cached *runs*, freshly correlated: the VM shows up without a new fetch. assert second.runs[0].vms == [vm] def test_expired_ttl_refetches(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: calls = _install_urlopen(monkeypatch, _admin_ok([_run()])) clock = [1000.0] monkeypatch.setattr(ex, "_monotonic", lambda: clock[0]) config = _make_config(tmp_path) list_executions(config, [], token="tok", now=_NOW) clock[0] += ex._TTL_SEC + 0.1 list_executions(config, [], token="tok", now=_NOW) assert len(calls) == 2 def test_cache_is_keyed_by_token_and_limit( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: calls = _install_urlopen(monkeypatch, _admin_ok([_run()])) monkeypatch.setattr(ex, "_monotonic", lambda: 1000.0) config = _make_config(tmp_path) list_executions(config, [], token="tok", now=_NOW) list_executions(config, [], token="other", now=_NOW) list_executions(config, [], token="other", limit=5, now=_NOW) assert len(calls) == 3 def test_errors_are_cached_too(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A Gitea outage must not turn every 5s poll into an HTTP attempt.""" calls = _install_urlopen(monkeypatch, lambda _url: urllib.error.URLError("down")) monkeypatch.setattr(ex, "_monotonic", lambda: 1000.0) config = _make_config(tmp_path) first = list_executions(config, [], token="tok", now=_NOW) second = list_executions(config, [], token="tok", now=_NOW) assert first.error == second.error assert len(calls) == 1