The box-side dispatch smoke test failed at `git clone` (exit 128, "could not
read Username"): GitHub's git smart-HTTP transport authenticates an installation
token via BASIC auth (username x-access-token), not Bearer. Bearer is the REST
API form (minting + workflow_dispatch + run-list all use it correctly) but git
rejects it.
app_branch_pusher now sets the http.extraHeader to
`Authorization: Basic <base64("x-access-token:" + token)>`. Verified on the box:
Bearer -> exit 128, Basic -> clone OK. _scrub now also redacts the base64
credential blob (it decodes to the token). Test updated to assert the Basic form
and that the raw token never appears literally in the header.
597 lines
22 KiB
Python
597 lines
22 KiB
Python
"""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 <dir>``, ``-c <key=val>``) 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
|