"""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