This repository has been archived on 2026-08-04. You can view files and clone it, but cannot push or open issues or pull requests.
orchestrator/agent-team/tests/test_ci_fetcher.py
Adam Moussa 41132a4615 feat(agent-team): read-only CI-result fetcher for P3 verify gate (opt-in, inert)
ci_fetcher.py: fail-closed CiResultFetcher reading the GitHub Actions run
conclusion via a read-only PAT (AGENT_TEAM_CI_READ_TOKEN→GITHUB_TOKEN), returns
{run_id,conclusion,diff_hash} or None on any error. Data-fetcher only — ci_gate
owns the verdict; never writes, no OIDC/AWS, never reads patch artifacts.
coordinator gains opt-in gated_build_verify_wiring() composing it via
bind_ci_result_fetcher; NOT wired into the default run-team.py path. 20 tests.
2026-06-18 15:30:07 -04:00

202 lines
7.7 KiB
Python

"""Unit tests for agent_team.ci_fetcher — the read-only, fail-closed CI fetcher.
The fetcher is a pure DATA seam: on a clean authenticated run it returns the
``{run_id, conclusion, diff_hash}`` mapping the pure-code gate consumes; on ANY
error (404 / auth fail / malformed body / timeout / missing run_id / missing
token) it returns ``None`` so the gate BLOCKs (fail-closed). It NEVER derives a
verdict and NEVER writes. Everything is mocked with a fake HTTP client; no
network, no real token.
"""
from __future__ import annotations
import pytest
from agent_team.ci_fetcher import (
CI_READ_TOKEN_ENV,
build_ci_result_fetcher,
fetch_ci_result,
)
class _FakeResponse:
def __init__(self, status: int, body):
self.status_code = status
self._body = body
def json(self):
if isinstance(self._body, Exception):
raise self._body
return self._body
class _FakeClient:
"""A requests-like client recording calls; raises only write verbs if asked."""
def __init__(self, response=None, raise_exc=None):
self._response = response
self._raise = raise_exc
self.calls: list[tuple[str, dict]] = []
def get(self, url, *, timeout=None):
self.calls.append((url, {"timeout": timeout}))
if self._raise is not None:
raise self._raise
return self._response
# If the fetcher ever tried to write, these would record it — they must not
# be called (the fetcher is read-only).
def post(self, *a, **k): # pragma: no cover - must never be called
raise AssertionError("ci_fetcher must never POST")
def patch(self, *a, **k): # pragma: no cover - must never be called
raise AssertionError("ci_fetcher must never PATCH")
def _state(run_id="123", diff_hash="abc123"):
return {"run_id": run_id, "diff_hash": diff_hash}
# --------------------------------------------------------------------------- #
# Success path
# --------------------------------------------------------------------------- #
def test_success_returns_correct_mapping() -> None:
client = _FakeClient(
_FakeResponse(200, {"id": 123, "conclusion": "success", "status": "completed"})
)
out = fetch_ci_result(_state(), owner="o", repo="r", client=client)
assert out == {"run_id": "123", "conclusion": "success", "diff_hash": "abc123"}
# It hit the runs endpoint for the right run, read-only.
assert client.calls[0][0].endswith("/repos/o/r/actions/runs/123")
def test_failure_conclusion_is_echoed_not_judged() -> None:
# The fetcher returns the raw conclusion; it does NOT decide pass/fail.
client = _FakeClient(_FakeResponse(200, {"id": 5, "conclusion": "failure"}))
out = fetch_ci_result(_state(run_id="5"), owner="o", repo="r", client=client)
assert out is not None
assert out["conclusion"] == "failure" # echoed verbatim, no verdict derived
def test_diff_hash_echoed_from_state() -> None:
client = _FakeClient(_FakeResponse(200, {"id": 9, "conclusion": "success"}))
out = fetch_ci_result(
{"run_id": "9", "diff_hash": "DEADBEEF"}, owner="o", repo="r", client=client
)
assert out["diff_hash"] == "DEADBEEF"
def test_run_id_read_from_state_drives_url() -> None:
client = _FakeClient(_FakeResponse(200, {"id": 777, "conclusion": "success"}))
fetch_ci_result(_state(run_id="777"), owner="acme", repo="svc", client=client)
assert client.calls[0][0].endswith("/repos/acme/svc/actions/runs/777")
# --------------------------------------------------------------------------- #
# Fail-closed paths (every error -> None)
# --------------------------------------------------------------------------- #
def test_404_fails_closed() -> None:
client = _FakeClient(_FakeResponse(404, {"message": "Not Found"}))
assert fetch_ci_result(_state(), owner="o", repo="r", client=client) is None
def test_auth_fail_401_fails_closed() -> None:
client = _FakeClient(_FakeResponse(401, {"message": "Bad credentials"}))
assert fetch_ci_result(_state(), owner="o", repo="r", client=client) is None
def test_forbidden_403_fails_closed() -> None:
client = _FakeClient(_FakeResponse(403, {"message": "forbidden"}))
assert fetch_ci_result(_state(), owner="o", repo="r", client=client) is None
def test_malformed_body_fails_closed() -> None:
client = _FakeClient(_FakeResponse(200, ValueError("not json")))
assert fetch_ci_result(_state(), owner="o", repo="r", client=client) is None
def test_non_object_body_fails_closed() -> None:
client = _FakeClient(_FakeResponse(200, ["not", "a", "dict"]))
assert fetch_ci_result(_state(), owner="o", repo="r", client=client) is None
def test_missing_conclusion_fails_closed() -> None:
# In-progress run: conclusion is None -> not an authenticated verdict.
client = _FakeClient(_FakeResponse(200, {"id": 1, "conclusion": None}))
assert (
fetch_ci_result(_state(run_id="1"), owner="o", repo="r", client=client) is None
)
def test_timeout_fails_closed() -> None:
client = _FakeClient(raise_exc=TimeoutError("timed out"))
assert fetch_ci_result(_state(), owner="o", repo="r", client=client) is None
def test_connection_error_fails_closed() -> None:
client = _FakeClient(raise_exc=ConnectionError("refused"))
assert fetch_ci_result(_state(), owner="o", repo="r", client=client) is None
def test_missing_run_id_fails_closed() -> None:
client = _FakeClient(_FakeResponse(200, {"id": 1, "conclusion": "success"}))
assert (
fetch_ci_result({"diff_hash": "x"}, owner="o", repo="r", client=client) is None
)
# And it never even made a request.
assert client.calls == []
def test_empty_run_id_fails_closed() -> None:
client = _FakeClient(_FakeResponse(200, {"id": 1, "conclusion": "success"}))
assert (
fetch_ci_result(
{"run_id": "", "diff_hash": "x"}, owner="o", repo="r", client=client
)
is None
)
assert client.calls == []
# --------------------------------------------------------------------------- #
# Missing token (no injected client) -> fails closed (does NOT raise)
# --------------------------------------------------------------------------- #
def test_missing_token_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv(CI_READ_TOKEN_ENV, raising=False)
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
# No client injected -> it must resolve a token; none set -> None (no raise).
assert fetch_ci_result(_state(), owner="o", repo="r") is None
# --------------------------------------------------------------------------- #
# Read-only contract: never derives a verdict, never writes
# --------------------------------------------------------------------------- #
def test_fetcher_never_returns_a_verdict_field() -> None:
client = _FakeClient(_FakeResponse(200, {"id": 2, "conclusion": "failure"}))
out = fetch_ci_result(_state(run_id="2"), owner="o", repo="r", client=client)
assert out is not None
# The mapping is DATA only — there is no gate_decision / passed / verdict.
assert set(out.keys()) == {"run_id", "conclusion", "diff_hash"}
assert "passed" not in out
assert "gate_decision" not in out
def test_build_ci_result_fetcher_returns_state_callable() -> None:
client = _FakeClient(_FakeResponse(200, {"id": 3, "conclusion": "success"}))
fetcher = build_ci_result_fetcher(owner="o", repo="r", client=client)
out = fetcher({"run_id": "3", "diff_hash": "h"})
assert out == {"run_id": "3", "conclusion": "success", "diff_hash": "h"}
def test_build_ci_result_fetcher_fails_closed_on_error() -> None:
client = _FakeClient(_FakeResponse(500, {"message": "boom"}))
fetcher = build_ci_result_fetcher(owner="o", repo="r", client=client)
assert fetcher({"run_id": "3", "diff_hash": "h"}) is None