number is a per-repo/workflow counter with no meaning once the admin endpoint mixes every repo on the instance into one feed -- a rarely-run workflow's low #53 that just fired outranked a busier workflow's #142 from hours earlier, but sorting by number buried it instead. Sort by started (globally comparable) with run_id/attempt as tiebreaks; a re-run bumping started_at back to the top is correct, not a bug, under "most recent globally".
772 lines
26 KiB
Python
772 lines
26 KiB
Python
"""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 = 10,
|
|
) -> 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, body: str = "") -> urllib.error.HTTPError:
|
|
return urllib.error.HTTPError(
|
|
url="https://gitea.example",
|
|
code=code,
|
|
msg="nope",
|
|
hdrs=None, # type: ignore[arg-type]
|
|
fp=BytesIO(body.encode("utf-8")) if body else 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=10",
|
|
"Bearer tok",
|
|
)
|
|
]
|
|
|
|
|
|
def test_request_sends_an_explicit_user_agent(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Cloudflare in front of Gitea 403s urllib's default agent (error 1010)."""
|
|
agents: list[str | None] = []
|
|
|
|
def _fake_urlopen(request: Any, timeout: float | None = None) -> Any:
|
|
agents.append(request.get_header("User-agent"))
|
|
return _FakeResponse({"workflow_runs": []})
|
|
|
|
monkeypatch.setattr(ex.urllib.request, "urlopen", _fake_urlopen)
|
|
|
|
list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
|
|
|
|
assert agents and agents[0]
|
|
assert "python-urllib" not in str(agents[0]).lower()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("path", "expected"),
|
|
[
|
|
(".gitea/workflows/build-ns7zip.yml", "build-ns7zip.yml"),
|
|
# Gitea's <file>@<ref> form: splitting on "/" alone yields "main".
|
|
("lint.yml@refs/heads/main", "lint.yml"),
|
|
(".gitea/workflows/ci.yml@refs/heads/feat/x", "ci.yml"),
|
|
("", ""),
|
|
],
|
|
)
|
|
def test_workflow_name_drops_the_ref(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, path: str, expected: str
|
|
) -> None:
|
|
_install_urlopen(monkeypatch, _admin_ok([_run(path=path)]))
|
|
|
|
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
|
|
|
|
assert view.runs[0].workflow == expected
|
|
|
|
|
|
def test_zero_attempt_reads_as_one(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Gitea sends ``run_attempt: 0`` for a first run; job ids start at 1."""
|
|
_install_urlopen(monkeypatch, _admin_ok([_run(run_id=384, attempt=0)]))
|
|
vm = _vm("Clone_384-1_20260725_115000_linux")
|
|
|
|
view = list_executions(_make_config(tmp_path), [vm], token="tok", now=_NOW)
|
|
|
|
assert view.runs[0].attempt == 1
|
|
assert view.runs[0].vms == [vm]
|
|
|
|
|
|
def test_http_error_carries_the_server_message(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Gitea names the missing scope in its body -- surface it, don't guess."""
|
|
body = json.dumps(
|
|
{"message": "token does not have at least one of required scope(s)"}
|
|
)
|
|
_install_urlopen(monkeypatch, lambda _url: _http_error(500, body))
|
|
|
|
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
|
|
|
|
assert view.error is not None
|
|
assert "HTTP 500" in view.error
|
|
assert "required scope(s)" in view.error
|
|
|
|
|
|
def test_non_json_error_body_is_included_verbatim(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A Cloudflare interception is HTML, not Gitea JSON -- still show it."""
|
|
_install_urlopen(monkeypatch, lambda _url: _http_error(500, "<html>error code: 1010</html>"))
|
|
|
|
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
|
|
|
|
assert view.error is not None
|
|
assert "1010" in view.error
|
|
|
|
|
|
def test_empty_error_body_leaves_a_bare_status(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_install_urlopen(monkeypatch, lambda _url: _http_error(500))
|
|
|
|
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
|
|
|
|
assert view.error == "Gitea API returned HTTP 500."
|
|
|
|
|
|
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"
|
|
|
|
|
|
# ── ordering ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_runs_are_sorted_newest_first(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Gitea's order is not contracted -- the card sorts by recency itself."""
|
|
payload = [
|
|
_run(run_id=1, number=1, started="2026-07-25T09:00:00Z"),
|
|
_run(run_id=3, number=3, started="2026-07-25T11:00:00Z"),
|
|
_run(run_id=2, number=2, started="2026-07-25T10:00:00Z"),
|
|
]
|
|
_install_urlopen(monkeypatch, _admin_ok(payload))
|
|
|
|
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
|
|
|
|
assert [r.number for r in view.runs] == [3, 2, 1]
|
|
|
|
|
|
def test_queued_run_sorts_above_started_runs(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A run with no start time was just created: it is the newest of all."""
|
|
payload = [
|
|
_run(run_id=1, number=1, started="2026-07-25T11:00:00Z"),
|
|
_run(run_id=2, number=2, status="waiting", conclusion="", started=None, completed=None),
|
|
]
|
|
_install_urlopen(monkeypatch, _admin_ok(payload))
|
|
|
|
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
|
|
|
|
assert [r.status for r in view.runs] == ["queued", "success"]
|
|
|
|
|
|
def test_a_rerun_sorts_above_a_run_that_started_earlier(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Re-running an old workflow really is fresh activity -- it belongs at
|
|
the top of a "most recent globally" feed, same as any other run that just
|
|
started."""
|
|
payload = [
|
|
_run(run_id=12, number=12, attempt=1, started="2026-07-25T10:00:00Z"),
|
|
_run(run_id=13, number=13, attempt=1, started="2026-07-25T10:05:00Z"),
|
|
# #12 gets manually re-run after #13 already started.
|
|
_run(run_id=12, number=12, attempt=2, started="2026-07-25T10:10:00Z"),
|
|
]
|
|
_install_urlopen(monkeypatch, _admin_ok(payload))
|
|
|
|
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
|
|
|
|
assert [(r.number, r.attempt) for r in view.runs] == [(12, 2), (13, 1), (12, 1)]
|
|
|
|
|
|
def test_a_more_recent_run_from_a_quiet_workflow_sorts_above_an_older_higher_numbered_run(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""``number`` is a per-repo/workflow counter -- it cannot answer "most
|
|
recent across the whole instance" at all once more than one repo is in
|
|
the mix. A rarely-triggered workflow's #53 that just ran outranks a
|
|
#141/#142 from a busier workflow that ran hours earlier."""
|
|
payload = [
|
|
_run(run_id=8000, number=141, started="2026-07-25T09:00:00Z"),
|
|
_run(run_id=9001, number=142, started="2026-07-25T10:00:00Z"),
|
|
# A different, rarely-run workflow -- but it just fired, after both.
|
|
_run(run_id=1, number=53, path=".gitea/workflows/rare.yml", started="2026-07-25T11:00:00Z"),
|
|
]
|
|
_install_urlopen(monkeypatch, _admin_ok(payload))
|
|
|
|
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
|
|
|
|
assert [r.number for r in view.runs] == [53, 142, 141]
|
|
|
|
|
|
def test_same_number_and_attempt_falls_back_to_run_id(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Two different workflows can land on the same run number independently
|
|
-- a real tie, not a bug. ``run_id`` just keeps the order stable/repeatable
|
|
rather than left to chance."""
|
|
stamp = "2026-07-25T11:00:00Z"
|
|
payload = [
|
|
_run(run_id=10, number=12, started=stamp),
|
|
_run(run_id=12, number=12, path=".gitea/workflows/other.yml", started=stamp),
|
|
_run(run_id=11, number=12, path=".gitea/workflows/third.yml", started=stamp),
|
|
]
|
|
_install_urlopen(monkeypatch, _admin_ok(payload))
|
|
|
|
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
|
|
|
|
assert [r.run_id for r in view.runs] == [12, 11, 10]
|
|
|
|
|
|
# ── 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=10",
|
|
"https://gitea.example/api/v1/user/actions/runs?limit=10",
|
|
]
|
|
|
|
|
|
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
|