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_github_intake.py
Adam Moussa ebc350aee5 fix(agent-team): harden github intake per /sh-security-review (CWE-918, idempotency)
Fixes from the high-recall detector fan-out on the durable-dedup change:

- INTAKE-LOGIC-01 (idempotency): switch the IngestStore seam from
  check-then-record (seen/mark) to claim-then-do (claim/release). The id is
  now reserved BEFORE the non-idempotent start_task side effect, so a crash in
  that window cannot re-spawn a duplicate task on the next run; a raising
  start_task releases the claim so transient failures stay retryable. Adds
  delete_issue_ingested to the schema layer for the release path.
- INTAKE-SSRF-001 / INTAKE-PATHSPLICE-002 (CWE-918) in build_default_issue_client:
  drop the caller-overridable api_root (hardcode GITHUB_API_ROOT) and validate
  owner/repo against an anchored charset before splicing them into the
  token-bearing API URL — mirrors the sibling ci_fetcher BLOCK-3/FIX-3 fixes.

Tests cover cross-process duplicate prevention, release-on-failure retry, and
the owner/repo + api_root rejection. 982 tests pass, ruff clean.

Follow-up (pre-existing, not introduced here): the label-only intake has no
author allowlist (cf. AGENT_TEAM_SLACK_OWNER_IDS on the Slack listener); the
Slack answer gate bounds the blast radius. Track as separate hardening.
2026-06-22 17:06:56 -04:00

392 lines
14 KiB
Python

"""Unit tests for agent_team.transport.github_intake (§3.3.1, INTAKE poller).
Fully hermetic: both the GitHub issue client and the coordinator are injected
in-memory fakes, so no network call, token, GitHub SDK, or model is exercised.
The tests pin the poller's contract: a labeled issue creates exactly one task,
a re-poll does not double-ingest, unlabeled issues are never seen (the client
filters by label), and the intake text is the issue title + body.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from agent_team.transport.github_intake import (
GITHUB_TRANSPORT_NAME,
GithubIntake,
build_default_issue_client,
build_ledger_ingest_store,
issue_task_text,
)
INTAKE_LABEL = "agent-team"
# --------------------------------------------------------------------------- #
# Fakes
# --------------------------------------------------------------------------- #
class FakeIssueClient:
"""In-memory ``GithubIssueClient`` returning only issues carrying ``label``.
Mirrors the production client's contract: ``list_open_issues(label=...)``
returns the subset of the configured issues whose ``labels`` include the
requested label. Records each requested label so a test can assert the
poller queries with the configured label.
"""
def __init__(self, issues: list[dict[str, Any]]) -> None:
self.issues = issues
self.requested_labels: list[str] = []
def list_open_issues(self, *, label: str) -> list[dict[str, Any]]:
self.requested_labels.append(label)
return [issue for issue in self.issues if label in (issue.get("labels") or [])]
class FakeCoordinator:
"""In-memory coordinator double recording every ``start_task`` call.
Captures the keyword arguments of each call so a test can assert exactly one
task was started, with the expected ``task_text`` / ``transport_name``.
Returns a synthetic ``thread_id`` like the real coordinator.
"""
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
def start_task(self, *, task_text: str, transport_name: str) -> str:
self.calls.append({"task_text": task_text, "transport_name": transport_name})
return f"thread-{len(self.calls)}"
def _issue(
issue_id: int,
*,
title: str = "Do the thing",
body: str = "with details",
labels: list[str] | None = None,
) -> dict[str, Any]:
"""Build a minimal GitHub-issue-shaped mapping for the fakes."""
return {
"id": issue_id,
"number": issue_id,
"title": title,
"body": body,
"labels": [INTAKE_LABEL] if labels is None else labels,
}
# --------------------------------------------------------------------------- #
# issue_task_text
# --------------------------------------------------------------------------- #
def test_issue_task_text_joins_title_and_body() -> None:
text = issue_task_text(_issue(1, title="Add poller", body="for GitHub intake"))
assert text == "Add poller\n\nfor GitHub intake"
def test_issue_task_text_title_only_when_body_empty() -> None:
assert issue_task_text(_issue(1, title="Title only", body="")) == "Title only"
assert issue_task_text(_issue(1, title="Title only", body=" ")) == "Title only"
def test_issue_task_text_falls_back_to_id_when_title_empty() -> None:
text = issue_task_text(_issue(42, title="", body=""))
assert text == "issue #42"
def test_issue_task_text_strips_surrounding_whitespace() -> None:
text = issue_task_text(_issue(1, title=" Trim me ", body="\n body \n"))
assert text == "Trim me\n\nbody"
# --------------------------------------------------------------------------- #
# GithubIntake construction
# --------------------------------------------------------------------------- #
def test_empty_label_is_rejected() -> None:
with pytest.raises(ValueError):
GithubIntake(
client=FakeIssueClient([]), coordinator=FakeCoordinator(), label=""
)
# --------------------------------------------------------------------------- #
# poll_once: the core contract
# --------------------------------------------------------------------------- #
def test_labeled_issue_creates_exactly_one_task() -> None:
client = FakeIssueClient([_issue(1, title="Build it", body="now")])
coordinator = FakeCoordinator()
intake = GithubIntake(client=client, coordinator=coordinator, label=INTAKE_LABEL)
ingested = intake.poll_once()
assert ingested == ["1"]
assert len(coordinator.calls) == 1
call = coordinator.calls[0]
assert call["task_text"] == "Build it\n\nnow"
assert call["transport_name"] == GITHUB_TRANSPORT_NAME
assert client.requested_labels == [INTAKE_LABEL]
def test_repoll_does_not_double_ingest() -> None:
client = FakeIssueClient([_issue(1)])
coordinator = FakeCoordinator()
intake = GithubIntake(client=client, coordinator=coordinator, label=INTAKE_LABEL)
first = intake.poll_once()
second = intake.poll_once()
assert first == ["1"]
assert second == [] # already ingested -> no new task
assert len(coordinator.calls) == 1
assert intake.ingested_ids == frozenset({"1"})
def test_unlabeled_issues_are_ignored() -> None:
client = FakeIssueClient(
[
_issue(1, labels=[INTAKE_LABEL]),
_issue(2, labels=["bug"]),
_issue(3, labels=[]),
]
)
coordinator = FakeCoordinator()
intake = GithubIntake(client=client, coordinator=coordinator, label=INTAKE_LABEL)
ingested = intake.poll_once()
assert ingested == ["1"]
assert len(coordinator.calls) == 1
assert coordinator.calls[0]["task_text"].startswith("Do the thing")
def test_new_issue_on_second_poll_is_ingested() -> None:
issues = [_issue(1)]
client = FakeIssueClient(issues)
coordinator = FakeCoordinator()
intake = GithubIntake(client=client, coordinator=coordinator, label=INTAKE_LABEL)
first = intake.poll_once()
issues.append(_issue(2, title="Second", body="task"))
second = intake.poll_once()
assert first == ["1"]
assert second == ["2"]
assert len(coordinator.calls) == 2
assert coordinator.calls[1]["task_text"] == "Second\n\ntask"
def test_multiple_labeled_issues_each_create_one_task() -> None:
client = FakeIssueClient([_issue(1), _issue(2), _issue(3)])
coordinator = FakeCoordinator()
intake = GithubIntake(client=client, coordinator=coordinator, label=INTAKE_LABEL)
ingested = intake.poll_once()
assert ingested == ["1", "2", "3"]
assert len(coordinator.calls) == 3
def test_id_falls_back_to_number_when_id_absent() -> None:
issue = {"number": 7, "title": "No id", "body": "", "labels": [INTAKE_LABEL]}
client = FakeIssueClient([issue])
coordinator = FakeCoordinator()
intake = GithubIntake(client=client, coordinator=coordinator, label=INTAKE_LABEL)
ingested = intake.poll_once()
assert ingested == ["7"]
assert intake.poll_once() == [] # de-dup on number-derived id
def test_failed_start_task_leaves_issue_eligible_for_retry() -> None:
"""A raising start_task must NOT mark the issue ingested (no silent drop)."""
class FlakyCoordinator:
def __init__(self) -> None:
self.attempts = 0
def start_task(self, *, task_text: str, transport_name: str) -> str:
self.attempts += 1
if self.attempts == 1:
raise RuntimeError("transient intake failure")
return "thread-ok"
client = FakeIssueClient([_issue(1)])
coordinator = FlakyCoordinator()
intake = GithubIntake(client=client, coordinator=coordinator, label=INTAKE_LABEL)
with pytest.raises(RuntimeError):
intake.poll_once()
assert intake.ingested_ids == frozenset() # not recorded -> retryable
# The retry succeeds and ingests the issue exactly once.
ingested = intake.poll_once()
assert ingested == ["1"]
assert coordinator.attempts == 2
# --------------------------------------------------------------------------- #
# Durable de-dup (the ledger-backed IngestStore)
# --------------------------------------------------------------------------- #
def test_durable_store_dedup_survives_a_fresh_intake_process(tmp_path: Path) -> None:
"""A new GithubIntake (= a new cron process) over the same ledger DB does not
re-ingest a still-open labeled issue — the bug a scheduled timer would hit
with the in-memory default."""
db = tmp_path / "ledger.sqlite"
source = "github:o/r"
# Process 1: ingests issue 1.
store1 = build_ledger_ingest_store(db_path=db, source=source)
coord1 = FakeCoordinator()
intake1 = GithubIntake(
client=FakeIssueClient([_issue(1)]),
coordinator=coord1,
label=INTAKE_LABEL,
store=store1,
)
assert intake1.poll_once() == ["1"]
assert len(coord1.calls) == 1
# Process 2 (restart): brand-new intake + store over the SAME DB; the issue
# is still open, but durable de-dup means it is NOT ingested again.
store2 = build_ledger_ingest_store(db_path=db, source=source)
coord2 = FakeCoordinator()
intake2 = GithubIntake(
client=FakeIssueClient([_issue(1)]),
coordinator=coord2,
label=INTAKE_LABEL,
store=store2,
)
assert intake2.poll_once() == []
assert coord2.calls == []
assert intake2.ingested_ids == frozenset({"1"})
def test_durable_store_namespaces_by_source(tmp_path: Path) -> None:
"""The same issue id under a different repo source is ingested independently."""
db = tmp_path / "ledger.sqlite"
store_a = build_ledger_ingest_store(db_path=db, source="github:o/repo-a")
coord_a = FakeCoordinator()
GithubIntake(
client=FakeIssueClient([_issue(1)]),
coordinator=coord_a,
label=INTAKE_LABEL,
store=store_a,
).poll_once()
assert len(coord_a.calls) == 1
# Different repo, same issue number → not de-duped against repo-a.
store_b = build_ledger_ingest_store(db_path=db, source="github:o/repo-b")
coord_b = FakeCoordinator()
ingested = GithubIntake(
client=FakeIssueClient([_issue(1)]),
coordinator=coord_b,
label=INTAKE_LABEL,
store=store_b,
).poll_once()
assert ingested == ["1"]
assert len(coord_b.calls) == 1
def test_build_ledger_ingest_store_rejects_empty_source(tmp_path: Path) -> None:
with pytest.raises(ValueError):
build_ledger_ingest_store(db_path=tmp_path / "x.sqlite", source="")
# --------------------------------------------------------------------------- #
# Claim-then-do durability (INTAKE-LOGIC-01 fix) + client hardening
# --------------------------------------------------------------------------- #
def test_durable_claim_without_release_prevents_duplicate(tmp_path: Path) -> None:
"""Claim-then-do: a claim recorded but never released (a crash around
start_task) must NOT re-ingest on a fresh process — no duplicate task."""
db = tmp_path / "ledger.sqlite"
source = "github:o/r"
# Simulate a poller that won the claim then 'crashed' before releasing.
store1 = build_ledger_ingest_store(db_path=db, source=source)
assert store1.claim("42") is True
assert store1.claim("42") is False # same store: re-claim loses
# Fresh process over the same DB: the claim persists, so poll skips it.
store2 = build_ledger_ingest_store(db_path=db, source=source)
coord = FakeCoordinator()
intake = GithubIntake(
client=FakeIssueClient([_issue(42)]),
coordinator=coord,
label=INTAKE_LABEL,
store=store2,
)
assert intake.poll_once() == []
assert coord.calls == []
def test_failed_start_task_releases_durable_claim_for_retry(tmp_path: Path) -> None:
"""A raising start_task releases the durable claim so a later poll retries."""
db = tmp_path / "ledger.sqlite"
source = "github:o/r"
class FlakyCoordinator:
def __init__(self) -> None:
self.attempts = 0
def start_task(self, *, task_text: str, transport_name: str) -> str:
self.attempts += 1
if self.attempts == 1:
raise RuntimeError("transient intake failure")
return "thread-ok"
coord = FlakyCoordinator()
intake = GithubIntake(
client=FakeIssueClient([_issue(1)]),
coordinator=coord,
label=INTAKE_LABEL,
store=build_ledger_ingest_store(db_path=db, source=source),
)
with pytest.raises(RuntimeError):
intake.poll_once()
# The claim was released, so a fresh process sees the issue as unclaimed.
intake2 = GithubIntake(
client=FakeIssueClient([_issue(1)]),
coordinator=coord,
label=INTAKE_LABEL,
store=build_ledger_ingest_store(db_path=db, source=source),
)
assert intake2.poll_once() == ["1"]
assert coord.attempts == 2
def test_build_default_issue_client_rejects_unsafe_owner_repo() -> None:
"""owner/repo are validated before URL construction (path-splice / CWE-918)."""
with pytest.raises(ValueError):
build_default_issue_client(owner="../../..", repo="r")
with pytest.raises(ValueError):
build_default_issue_client(owner="o", repo="r/issues?labels=admin")
with pytest.raises(ValueError):
build_default_issue_client(owner="", repo="r")
# A normal owner/repo builds fine.
client = build_default_issue_client(
owner="Sea-Haven-Industries", repo="orchestrator"
)
assert hasattr(client, "list_open_issues")
def test_build_default_issue_client_rejects_api_root_kwarg() -> None:
"""No caller-overridable api_root (SSRF/host-redirect, mirrors ci_fetcher FIX-3)."""
with pytest.raises(TypeError):
build_default_issue_client(owner="o", repo="r", api_root="https://evil.example")