"""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 "#412" 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 "#412.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.
The id must be stable across the card's own 5s poll (run_id/attempt, not
anything generated fresh per render) -- app.js keys its client-side
"which format is showing" memory off it to survive the next poll's
server-rendered reset back to relative.
"""
_stub_view(
monkeypatch,
ExecutionsView(
runs=[_execution(run_id=412, attempt=1, 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 'id="started-412-1"' in resp.text
assert 'class="time-toggle text-dim"' in resp.text
assert 'data-showing="relative"' in resp.text
assert 'data-relative="' in resp.text
assert 'data-absolute="' in resp.text
def test_no_cell_stacks_two_facts_on_separate_lines(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Run/Repo, Branch/Commit, Trigger/Actor, Duration/Started and VM/IP each
get their own column now -- no cell relies on a to show two facts."""
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 " " not in resp.text
for header in ("Repo", "Trigger", "Actor", "Started", "IP / Uptime"):
assert f"
{header}
" in resp.text
assert "Simone" in resp.text # actor, now its own column
assert vm.ip in resp.text
def test_empty_state_when_no_runs_and_no_vms(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_stub_view(
monkeypatch,
ExecutionsView(runs=[], 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 "No recent executions" in resp.text
assert "
" not in resp.text
def test_unmatched_vm_keeps_its_row_and_remove_button(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A powered-on VM with no listed run must not vanish from the dashboard."""
vm = _vm("Clone_370-1_20260722_101500_linux")
_stub_view(
monkeypatch,
ExecutionsView(runs=[], unmatched_vms=[vm], 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 vm.name in resp.text
assert "370-1" in resp.text # job id parsed out of the clone name
assert "Remove" in resp.text
# Same word, same column as a run's "running" status pill -- same icon.
# The card's own header icon is also #i-play, hence count == 2 not 1.
assert resp.text.count('href="#i-play"') == 2
def test_end_to_end_against_a_stubbed_gitea(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""No stubbing of the operations layer: only ``urlopen`` is faked."""
build_vms = tmp_path / "build-vms"
vmx = _make_clone_vmx(build_vms, "Clone_412-1_20260725_115000_abc")
payload = {
"workflow_runs": [
{
"id": 412,
"run_number": 12,
"run_attempt": 1,
"status": "in_progress",
"head_branch": "main",
"path": ".gitea/workflows/build-ns7zip.yml",
"repository": {"full_name": "simone/local-ci-cd-system"},
"html_url": "https://gitea.example.lan/run/12",
}
]
}
class _Resp:
def __enter__(self) -> BytesIO:
return BytesIO(json.dumps(payload).encode("utf-8"))
def __exit__(self, *_exc: object) -> None:
return None
monkeypatch.setattr(ex.urllib.request, "urlopen", lambda _r, timeout=None: _Resp())
_install_fake_keyring(monkeypatch)
app = create_app(_make_config(tmp_path, token_cred=_PAT_CRED_NAME))
app.dependency_overrides[get_backend] = lambda: _FakeBackend(
[VmHandle(identifier=str(vmx))]
)
client = TestClient(app)
resp = client.get("/executions")
assert resp.status_code == 200
assert "#412" in resp.text
assert "build-ns7zip.yml" in resp.text
# The clone is attached to its run, so it is not listed as "no run".
assert "no run" not in resp.text
# ── row-count selector ──────────────────────────────────────────────────────
def _stub_limits(monkeypatch: pytest.MonkeyPatch) -> list[int | None]:
"""Replace the operations call; return the log of limits it was given."""
limits: list[int | None] = []
def fake_list_executions(
_config: Config,
_vms: list[LiveVm],
*,
token: str | None,
limit: int | None = None,
**_kw: Any,
) -> ExecutionsView:
limits.append(limit)
return ExecutionsView(runs=[], unmatched_vms=[], source="admin", error=None)
monkeypatch.setattr(app_module, "list_executions", fake_list_executions)
return limits
def test_default_limit_is_ten(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
limits = _stub_limits(monkeypatch)
_install_fake_keyring(monkeypatch)
app = create_app(_make_config(tmp_path, token_cred=_PAT_CRED_NAME))
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get("/executions")
assert limits == [10]
assert '' in resp.text
# The selector lives inside the card's header bar, not in a strip below it.
head = resp.text.split("", 1)[0]
assert "head-select" in head
assert "