"""Unit tests for agent_team.dispatcher — the trusted apply-path transport (§4.3). Fully hermetic: the branch-push and workflow-dispatch side effects are injected fakes, so no git, no ``gh``, and no network are exercised. The tests pin the pure input-assembly + validation contract and the push-before-dispatch order. """ from __future__ import annotations import base64 import pytest from agent_team.dispatcher import ( MAX_DIFF_BYTES, DispatcherError, DispatchInputs, DispatchResult, app_branch_pusher, app_run_locator, app_workflow_dispatcher, build_dispatch_inputs, dispatch_apply_verify, head_branch_for, run_name_for, select_run_id, ) from agent_team.state_store import compute_content_hash TASK = "0a1b2c3d4e5f6071" DIFF = "diff --git a/README.md b/README.md\n--- a/README.md\n+++ b/README.md\n@@ -1 +1,2 @@\n title\n+added line\n" SCOPE = "README.md\ndocs/**" # --------------------------------------------------------------------------- # # build_dispatch_inputs (pure) # --------------------------------------------------------------------------- # def test_build_dispatch_inputs_binds_hash_and_b64() -> None: di = build_dispatch_inputs(task_id=TASK, diff_text=DIFF, declared_scope=SCOPE) # expected_diff_hash is the plain sha256 the workflow's sha256sum reproduces. assert di.expected_diff_hash == compute_content_hash(DIFF.encode("utf-8")) # diff_b64 round-trips back to the exact diff bytes. assert base64.b64decode(di.diff_b64).decode("utf-8") == DIFF assert di.head_branch == f"agent-team/apply/{TASK}" assert di.diff_artifact_name == f"agent-team-diff-{TASK}" assert di.declared_scope == SCOPE def test_as_inputs_keys_match_the_six_workflow_inputs() -> None: di = build_dispatch_inputs(task_id=TASK, diff_text=DIFF, declared_scope=SCOPE) assert set(di.as_inputs()) == { "task_id", "diff_artifact_name", "expected_diff_hash", "declared_scope", "diff_b64", "head_branch", } @pytest.mark.parametrize("bad_diff", ["", " ", "\n\n"]) def test_empty_diff_rejected(bad_diff: str) -> None: with pytest.raises(DispatcherError): build_dispatch_inputs(task_id=TASK, diff_text=bad_diff, declared_scope=SCOPE) def test_oversized_diff_rejected() -> None: # The diff rides a base64 workflow_dispatch input (GitHub ~64 KB cap); a diff # over MAX_DIFF_BYTES must fail closed in the dispatcher, not be dispatched. big = "diff --git a/x b/x\n" + "+" + ("x" * (MAX_DIFF_BYTES + 1)) + "\n" with pytest.raises(DispatcherError): build_dispatch_inputs(task_id=TASK, diff_text=big, declared_scope=SCOPE) @pytest.mark.parametrize("bad_scope", ["", " "]) def test_empty_scope_rejected(bad_scope: str) -> None: # An empty declared scope would let a diff touch ANY path — fail closed. with pytest.raises(DispatcherError): build_dispatch_inputs(task_id=TASK, diff_text=DIFF, declared_scope=bad_scope) @pytest.mark.parametrize( "bad_task", ["", "has space", "semi;colon", "../escape", "a/b", "x" * 201] ) def test_unsafe_task_id_rejected(bad_task: str) -> None: with pytest.raises(DispatcherError): head_branch_for(bad_task) with pytest.raises(DispatcherError): build_dispatch_inputs(task_id=bad_task, diff_text=DIFF, declared_scope=SCOPE) # --------------------------------------------------------------------------- # # dispatch_apply_verify (injected seams) # --------------------------------------------------------------------------- # class _Recorder: def __init__(self) -> None: self.calls: list[dict] = [] def test_dispatch_pushes_then_fires_with_correct_inputs() -> None: order: list[str] = [] pushed = _Recorder() fired = _Recorder() def pusher(*, owner, repo, base, head_branch, diff_text): order.append("push") pushed.calls.append( { "owner": owner, "repo": repo, "base": base, "head": head_branch, "diff": diff_text, } ) def dispatcher(*, owner, repo, inputs, ref): order.append("dispatch") fired.calls.append({"owner": owner, "repo": repo, "inputs": inputs, "ref": ref}) located = _Recorder() def locator(*, owner, repo, task_id, since_iso): order.append("locate") located.calls.append( {"owner": owner, "repo": repo, "task_id": task_id, "since_iso": since_iso} ) return "27990718108" result = dispatch_apply_verify( owner="Sea-Haven-Industries", repo="orchestrator", task_id=TASK, diff_text=DIFF, declared_scope=SCOPE, pusher=pusher, dispatcher=dispatcher, locator=locator, ) assert isinstance(result, DispatchResult) assert isinstance(result.inputs, DispatchInputs) # run_id is captured from the locator and surfaced for the verifier. assert result.run_id == "27990718108" assert result.correlation_tag == TASK assert result.dispatched_at # stamped, non-empty # Push BEFORE dispatch BEFORE locate (the run can only be located after it is # triggered, and the branch must exist before the run reaches the PR step). assert order == ["push", "dispatch", "locate"] assert pushed.calls[0]["head"] == f"agent-team/apply/{TASK}" assert pushed.calls[0]["diff"] == DIFF # The dispatch carries all six inputs, including the b64 diff + head branch. inputs = fired.calls[0]["inputs"] assert inputs["head_branch"] == f"agent-team/apply/{TASK}" assert base64.b64decode(inputs["diff_b64"]).decode("utf-8") == DIFF assert inputs["expected_diff_hash"] == compute_content_hash(DIFF.encode("utf-8")) assert fired.calls[0]["ref"] == "main" # The locator is keyed by THIS task and the dispatched-at watermark. assert located.calls[0]["task_id"] == TASK assert located.calls[0]["since_iso"] == result.dispatched_at def test_dispatch_returns_none_run_id_when_locator_cannot_resolve() -> None: # A fired-but-unlocatable run fails closed (None run_id); never raises here. result = dispatch_apply_verify( owner="o", repo="r", task_id=TASK, diff_text=DIFF, declared_scope=SCOPE, pusher=lambda **_k: None, dispatcher=lambda **_k: None, locator=lambda **_k: None, ) assert isinstance(result, DispatchResult) assert result.run_id is None assert result.dispatched_at # still stamped for the CI-watch timeout def test_run_name_for_matches_workflow_run_name_convention() -> None: # Mirrors run-name: "agent-team-apply ${{ inputs.task_id }}" in the workflow. assert run_name_for(TASK) == f"agent-team-apply {TASK}" def test_dispatch_does_not_fire_if_push_fails() -> None: fired = _Recorder() def failing_pusher(**_kw): raise RuntimeError("push failed") def dispatcher(**kw): fired.calls.append(kw) with pytest.raises(RuntimeError): dispatch_apply_verify( owner="o", repo="r", task_id=TASK, diff_text=DIFF, declared_scope=SCOPE, pusher=failing_pusher, dispatcher=dispatcher, ) # A failed push must NOT dispatch a run (no orphan run against a missing head). assert fired.calls == [] @pytest.mark.parametrize( "owner,repo", [("", "r"), ("o", ""), ("bad owner", "r"), ("o", "r/x")] ) def test_unsafe_owner_repo_rejected(owner: str, repo: str) -> None: with pytest.raises(DispatcherError): dispatch_apply_verify( owner=owner, repo=repo, task_id=TASK, diff_text=DIFF, declared_scope=SCOPE, pusher=lambda **_k: None, dispatcher=lambda **_k: None, ) # --------------------------------------------------------------------------- # # select_run_id (pure; anti-stale on rapid re-dispatch of the SAME task_id) # --------------------------------------------------------------------------- # def _row(db_id, *, created, status="completed", conclusion=None): """A minimal ``gh run list`` row for the apply/verify run-name of TASK.""" return { "databaseId": db_id, "name": run_name_for(TASK), "createdAt": created, "status": status, "conclusion": conclusion, } def test_select_run_id_skips_cancelled_prior_run_on_re_dispatch() -> None: # Rapid re-dispatch of the SAME task_id: the concurrency group cancelled the # OLDER run, and a NEWER run is now in progress. We must bind to the newer, # active run — never the older cancelled one (it carries the prior verdict). older_cancelled = _row( 100, created="2026-06-23T10:00:00Z", status="completed", conclusion="cancelled" ) newer_active = _row( 200, created="2026-06-23T10:05:00Z", status="in_progress", conclusion=None ) runs = [newer_active, older_cancelled] chosen = select_run_id(runs, task_id=TASK, floor_iso="2026-06-23T09:58:00Z") assert chosen == "200" def test_select_run_id_skips_cancelled_even_when_it_is_newest() -> None: # Defensive: a cancelled run is NEVER selected even if its createdAt is the # greatest — it is the superseded run, not ours. active = _row(300, created="2026-06-23T10:00:00Z", status="queued", conclusion=None) newest_cancelled = _row( 400, created="2026-06-23T10:10:00Z", status="completed", conclusion="cancelled" ) runs = [active, newest_cancelled] chosen = select_run_id(runs, task_id=TASK, floor_iso="2026-06-23T09:58:00Z") assert chosen == "300" def test_select_run_id_prefers_newest_active_over_older_completed() -> None: # An older legitimately-completed run plus a newer active run -> the active, # newest run wins (the freshly-triggered one with no conclusion yet). older_done = _row( 500, created="2026-06-23T10:00:00Z", status="completed", conclusion="success" ) newer_active = _row( 600, created="2026-06-23T10:05:00Z", status="in_progress", conclusion=None ) chosen = select_run_id( [older_done, newer_active], task_id=TASK, floor_iso="2026-06-23T09:58:00Z" ) assert chosen == "600" def test_select_run_id_falls_back_to_newest_non_cancelled_when_none_active() -> None: # No active runs (e.g. a fast run already concluded by the time we poll): # fall back to the newest NON-cancelled run overall. older = _row( 700, created="2026-06-23T10:00:00Z", status="completed", conclusion="success" ) newer = _row( 800, created="2026-06-23T10:05:00Z", status="completed", conclusion="failure" ) cancelled = _row( 900, created="2026-06-23T10:09:00Z", status="completed", conclusion="cancelled" ) chosen = select_run_id( [older, newer, cancelled], task_id=TASK, floor_iso="2026-06-23T09:58:00Z" ) assert chosen == "800" def test_select_run_id_respects_created_floor_and_run_name() -> None: # Below-floor runs and other-task runs are not matched. below_floor = _row( 1000, created="2026-06-23T09:00:00Z", status="in_progress", conclusion=None ) other_task = { "databaseId": 1100, "name": "agent-team-apply other-task", "createdAt": "2026-06-23T10:00:00Z", "status": "in_progress", "conclusion": None, } assert ( select_run_id( [below_floor, other_task], task_id=TASK, floor_iso="2026-06-23T09:58:00Z" ) is None ) def test_select_run_id_returns_none_when_only_cancelled_matches() -> None: # If the only matching run is cancelled, there is nothing to bind to -> None # (the caller fails closed: no run_id -> verify BLOCKs/parks). only_cancelled = _row( 1200, created="2026-06-23T10:00:00Z", status="completed", conclusion="cancelled" ) assert ( select_run_id([only_cancelled], task_id=TASK, floor_iso="2026-06-23T09:58:00Z") is None ) # --------------------------------------------------------------------------- # # app_branch_pusher (App-token git seam; secret hygiene) # --------------------------------------------------------------------------- # class _StubTokenProvider: """Minimal token provider: returns a fixed installation token.""" def __init__(self, token: str = "ghs_TESTTOKEN") -> None: self._token = token def token(self) -> str: return self._token def test_app_branch_pusher_keeps_token_out_of_argv_and_uses_plain_remote() -> None: # The clone must use the PLAIN HTTPS remote (no token in the URL/argv), and # the installation token must be injected via http.extraHeader through git's # GIT_CONFIG_* env vars on the network steps (clone + push) — never in argv. recorded: list[dict] = [] def fake_run(cmd, *, cwd=None, env=None): recorded.append({"cmd": list(cmd), "env": env}) push = app_branch_pusher(_StubTokenProvider(), _run=fake_run) push( owner="owner", repo="repo", base="main", head_branch="agent-team/apply/abc", diff_text=DIFF, ) clone_cmd = recorded[0]["cmd"] assert "clone" in clone_cmd assert "https://github.com/owner/repo.git" in clone_cmd # The token NEVER appears in any git argv element. for call in recorded: for arg in call["cmd"]: assert "ghs_TESTTOKEN" not in arg # Auth rides in GIT_CONFIG_* env on the network steps only (clone + push), as # BASIC auth (username x-access-token) — git smart-HTTP rejects Bearer. expected_basic = "Authorization: Basic " + base64.b64encode( b"x-access-token:ghs_TESTTOKEN" ).decode("ascii") clone_env = recorded[0]["env"] assert clone_env["GIT_CONFIG_KEY_0"] == "http.https://github.com/.extraHeader" assert clone_env["GIT_CONFIG_VALUE_0"] == expected_basic push_env = recorded[-1]["env"] assert push_env["GIT_CONFIG_VALUE_0"] == expected_basic # The raw token never appears literally in the auth header (it is base64'd). assert "ghs_TESTTOKEN" not in clone_env["GIT_CONFIG_VALUE_0"] # The non-network steps (checkout/apply/commit) carry no auth env. for call in recorded[1:4]: assert call["env"] is None # The five expected git steps fired in order. Skip the global ``git`` # options (``-C ``, ``-c ``) to find each subcommand. def _git_subcommand(cmd: list[str]) -> str: i = 1 # cmd[0] == "git" while i < len(cmd) and cmd[i] in ("-C", "-c"): i += 2 # each takes one argument return cmd[i] steps = [_git_subcommand(c["cmd"]) for c in recorded] assert steps == ["clone", "checkout", "apply", "commit", "push"] def test_app_branch_pusher_scrubs_token_from_errors() -> None: # The token rides in the http.extraHeader config value (passed via env, not # argv). If a failing git step's exception string ever surfaces that header, # it must NEVER leak the token — it is re-raised as a DispatcherError with the # token scrubbed to ***. def failing_run(cmd, *, cwd=None, env=None): # Simulate an error whose string carries the Authorization header value. raise RuntimeError( "fatal: remote rejected (Authorization: Bearer ghs_TESTTOKEN)" ) push = app_branch_pusher(_StubTokenProvider(), _run=failing_run) with pytest.raises(DispatcherError) as excinfo: push( owner="owner", repo="repo", base="main", head_branch="agent-team/apply/abc", diff_text=DIFF, ) assert "ghs_TESTTOKEN" not in str(excinfo.value) assert "***" in str(excinfo.value) # ``from None``: the token-bearing original is not chained onto the raised error. assert excinfo.value.__cause__ is None # --------------------------------------------------------------------------- # # app_workflow_dispatcher (App-token REST seam) # --------------------------------------------------------------------------- # class _FakeHttp: """Records POST/GET calls; returns a canned response object.""" def __init__(self, *, post_status=204, get_body=None, get_status=200) -> None: self.post_calls: list[dict] = [] self.get_calls: list[dict] = [] self._post_status = post_status self._get_body = get_body or {} self._get_status = get_status def post(self, url, *, json=None, headers=None, timeout=None): self.post_calls.append({"url": url, "json": json, "headers": headers}) return _FakeResp(status_code=self._post_status) def get(self, url, *, params=None, headers=None, timeout=None): self.get_calls.append({"url": url, "params": params, "headers": headers}) return _FakeResp(status_code=self._get_status, body=self._get_body) class _FakeResp: def __init__(self, *, status_code=200, body=None) -> None: self.status_code = status_code self._body = body or {} def json(self): return self._body def test_app_workflow_dispatcher_posts_correct_url_and_body() -> None: http = _FakeHttp(post_status=204) fire = app_workflow_dispatcher(_StubTokenProvider(), _http=http) inputs = {"task_id": TASK, "diff_b64": "x"} fire(owner="owner", repo="repo", inputs=inputs, ref="main") call = http.post_calls[0] assert call["url"].endswith( "/actions/workflows/agent-team-apply-verify.yml/dispatches" ) assert call["json"] == {"ref": "main", "inputs": inputs} # The installation token is carried as a Bearer header (and nowhere else). assert call["headers"]["Authorization"] == "Bearer ghs_TESTTOKEN" def test_app_workflow_dispatcher_fails_closed_without_token_in_message() -> None: http = _FakeHttp(post_status=422) fire = app_workflow_dispatcher(_StubTokenProvider(), _http=http) with pytest.raises(DispatcherError) as excinfo: fire(owner="owner", repo="repo", inputs={"task_id": TASK}, ref="main") assert "422" in str(excinfo.value) assert "ghs_TESTTOKEN" not in str(excinfo.value) class _RaisingHttp: """A transport whose post/get raises an exception that embeds the token.""" def post(self, url, *, json=None, headers=None, timeout=None): raise RuntimeError(f"connection reset: {headers['Authorization']}") def get(self, url, *, params=None, headers=None, timeout=None): raise RuntimeError(f"connection reset: {headers['Authorization']}") def test_app_workflow_dispatcher_scrubs_token_from_transport_error() -> None: # A transport exception must be re-raised as a DispatcherError carrying the # exception TYPE only — never the Bearer token, even if the underlying error # text embedded it. fire = app_workflow_dispatcher(_StubTokenProvider(), _http=_RaisingHttp()) with pytest.raises(DispatcherError) as excinfo: fire(owner="owner", repo="repo", inputs={"task_id": TASK}, ref="main") assert "ghs_TESTTOKEN" not in str(excinfo.value) assert excinfo.value.__cause__ is None # `from None` breaks the chain # --------------------------------------------------------------------------- # # app_run_locator (App-token REST seam; field mapping + run-name correlation) # --------------------------------------------------------------------------- # def test_app_run_locator_maps_rest_fields_and_correlates_run_name() -> None: # The REST list endpoint uses id/created_at; the locator must map them to the # databaseId/createdAt names select_run_id reads, and correlate on run-name. body = { "workflow_runs": [ { "id": 12345, "name": run_name_for(TASK), "created_at": "2026-06-23T10:00:00Z", "status": "in_progress", "conclusion": None, } ] } http = _FakeHttp(get_body=body) locate = app_run_locator(_StubTokenProvider(), _http=http, _sleep=lambda *_a: None) run_id = locate( owner="owner", repo="repo", task_id=TASK, since_iso="2026-06-23T09:58:00Z", ) assert run_id == "12345" # The list GET is authenticated + filtered to workflow_dispatch events. call = http.get_calls[0] assert call["url"].endswith("/actions/runs") assert call["params"]["event"] == "workflow_dispatch" assert call["headers"]["Authorization"] == "Bearer ghs_TESTTOKEN" def test_app_run_locator_returns_none_for_non_matching_task() -> None: body = { "workflow_runs": [ { "id": 999, "name": "agent-team-apply someone-else", "created_at": "2026-06-23T10:00:00Z", "status": "in_progress", "conclusion": None, } ] } http = _FakeHttp(get_body=body) locate = app_run_locator(_StubTokenProvider(), _http=http, _sleep=lambda *_a: None) assert ( locate( owner="owner", repo="repo", task_id=TASK, since_iso="2026-06-23T09:58:00Z", ) is None ) def test_app_run_locator_raises_on_auth_error_without_token_in_message() -> None: # A 401/403/404 list response must park immediately with the STATUS only # (never the token), not silently poll ~60s and return None as "no runs". http = _FakeHttp(get_status=403, get_body={"message": "Bad credentials"}) locate = app_run_locator(_StubTokenProvider(), _http=http, _sleep=lambda *_a: None) with pytest.raises(DispatcherError) as excinfo: locate( owner="owner", repo="repo", task_id=TASK, since_iso="2026-06-23T09:58:00Z", ) assert "403" in str(excinfo.value) assert "ghs_TESTTOKEN" not in str(excinfo.value) def test_app_run_locator_scrubs_token_from_transport_error() -> None: locate = app_run_locator( _StubTokenProvider(), _http=_RaisingHttp(), _sleep=lambda *_a: None ) with pytest.raises(DispatcherError) as excinfo: locate( owner="owner", repo="repo", task_id=TASK, since_iso="2026-06-23T09:58:00Z", ) assert "ghs_TESTTOKEN" not in str(excinfo.value) assert excinfo.value.__cause__ is None