Files
2026-07-13 11:59:58 +08:00

882 lines
31 KiB
Python

"""Tests for the GitHub webhook receiver.
Covers HMAC signature verification (positive + negative paths), event
recognition, JSON parsing failures, and the unset-secret dev-mode escape
hatch. Also exercises the CSRF middleware exemption so the route stays
reachable without an X-CSRF-Token header.
"""
from __future__ import annotations
import hashlib
import hmac
import json
from unittest.mock import AsyncMock
import pytest
from fastapi import FastAPI
from starlette.testclient import TestClient
from app.channels.message_bus import MessageBus
from app.gateway.csrf_middleware import CSRFMiddleware
from app.gateway.routers import github_webhooks
SECRET = "test-secret-do-not-use-in-production"
DELIVERY_ID = "12345678-1234-1234-1234-123456789abc"
def _signature(body: bytes, secret: str = SECRET) -> str:
return "sha256=" + hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
def _make_app() -> FastAPI:
app = FastAPI()
# Include CSRF middleware so we also prove /api/webhooks/ is exempt.
app.add_middleware(CSRFMiddleware)
app.include_router(github_webhooks.router)
return app
@pytest.fixture
def client() -> TestClient:
return TestClient(_make_app())
@pytest.fixture(autouse=True)
def _set_secret(monkeypatch: pytest.MonkeyPatch) -> None:
"""Default every test to: secret configured, dev opt-in cleared.
Tests that exercise the unset-secret / opt-in paths override these
explicitly with their own ``monkeypatch.delenv`` /
``monkeypatch.setenv``.
"""
monkeypatch.setenv("GITHUB_WEBHOOK_SECRET", SECRET)
monkeypatch.delenv("DEER_FLOW_ALLOW_UNVERIFIED_GITHUB_WEBHOOKS", raising=False)
@pytest.fixture(autouse=True)
def _stub_channel_service(monkeypatch: pytest.MonkeyPatch):
"""Provide a stub channel service so the route can publish to a bus.
The real ChannelService is started by the gateway lifespan; here we
only need something with a `.bus` attribute the route can use. Tests
that want to check what was published can read from the bus.
Defaults ``is_channel_enabled("github")`` to True so the route's
R7 kill-switch doesn't skip dispatch. The test that pins the
disabled-channel branch overrides this via a different stub.
``get_channel_config("github")`` returns ``None`` so the operator
default mention threading is exercised in the no-config branch by
default; the test that pins the live-config path stubs this in.
"""
bus = MessageBus()
class _StubService:
def __init__(self) -> None:
self.bus = bus
def is_channel_enabled(self, name: str) -> bool:
return True
def get_channel_config(self, name: str) -> dict | None:
return None
stub = _StubService()
# Patch in the channel-service module so the import inside the route
# picks up the stub.
import app.channels.service as service_module
monkeypatch.setattr(service_module, "get_channel_service", lambda: stub)
return stub
# ---------------------------------------------------------------------------
# Happy paths
# ---------------------------------------------------------------------------
def test_ping_event_returns_200(client: TestClient) -> None:
body = json.dumps({"zen": "Practicality beats purity.", "hook": {"id": 42}}).encode()
response = client.post(
"/api/webhooks/github",
content=body,
headers={
"X-GitHub-Event": "ping",
"X-GitHub-Delivery": DELIVERY_ID,
"X-Hub-Signature-256": _signature(body),
"Content-Type": "application/json",
},
)
assert response.status_code == 200
payload = response.json()
# The dispatch key is populated even when no agents match — its value
# is a small summary dict, here empty because no agents are registered.
assert payload["ok"] is True
assert payload["event"] == "ping"
assert payload["delivery"] == DELIVERY_ID
assert payload["handled"] is True
assert "dispatch" in payload
def test_pull_request_opened_returns_200(client: TestClient) -> None:
body = json.dumps(
{
"action": "opened",
"number": 7,
"pull_request": {
"number": 7,
"title": "Add webhook receiver",
"html_url": "https://github.com/org/repo/pull/7",
},
"repository": {"full_name": "org/repo"},
}
).encode()
response = client.post(
"/api/webhooks/github",
content=body,
headers={
"X-GitHub-Event": "pull_request",
"X-GitHub-Delivery": DELIVERY_ID,
"X-Hub-Signature-256": _signature(body),
"Content-Type": "application/json",
},
)
assert response.status_code == 200
assert response.json()["handled"] is True
def test_issue_comment_returns_200(client: TestClient) -> None:
body = json.dumps(
{
"action": "created",
"issue": {"number": 3, "pull_request": {"url": "..."}},
"comment": {"user": {"login": "octocat"}},
"repository": {"full_name": "org/repo"},
}
).encode()
response = client.post(
"/api/webhooks/github",
content=body,
headers={
"X-GitHub-Event": "issue_comment",
"X-GitHub-Delivery": DELIVERY_ID,
"X-Hub-Signature-256": _signature(body),
},
)
assert response.status_code == 200
assert response.json()["handled"] is True
def test_issues_event_returns_200(client: TestClient) -> None:
body = json.dumps(
{
"action": "opened",
"issue": {
"number": 12,
"title": "Bug: things are broken",
"html_url": "https://github.com/org/repo/issues/12",
},
"repository": {"full_name": "org/repo"},
}
).encode()
response = client.post(
"/api/webhooks/github",
content=body,
headers={
"X-GitHub-Event": "issues",
"X-GitHub-Delivery": DELIVERY_ID,
"X-Hub-Signature-256": _signature(body),
},
)
assert response.status_code == 200
assert response.json()["handled"] is True
def test_pull_request_review_returns_200(client: TestClient) -> None:
body = json.dumps(
{
"action": "submitted",
"pull_request": {"number": 5},
"review": {"state": "approved", "user": {"login": "reviewer"}},
"repository": {"full_name": "org/repo"},
}
).encode()
response = client.post(
"/api/webhooks/github",
content=body,
headers={
"X-GitHub-Event": "pull_request_review",
"X-GitHub-Delivery": DELIVERY_ID,
"X-Hub-Signature-256": _signature(body),
},
)
assert response.status_code == 200
assert response.json()["handled"] is True
def test_plain_issue_comment_is_not_pr(client: TestClient, caplog: pytest.LogCaptureFixture) -> None:
"""An issue_comment on a plain issue (not a PR) should log is_pr=False."""
body = json.dumps(
{
"action": "created",
"issue": {"number": 7}, # No "pull_request" key
"comment": {"user": {"login": "octocat"}},
"repository": {"full_name": "org/repo"},
}
).encode()
with caplog.at_level("INFO", logger="app.gateway.routers.github_webhooks"):
response = client.post(
"/api/webhooks/github",
content=body,
headers={
"X-GitHub-Event": "issue_comment",
"X-GitHub-Delivery": DELIVERY_ID,
"X-Hub-Signature-256": _signature(body),
},
)
assert response.status_code == 200
assert any("is_pr=False" in rec.message for rec in caplog.records)
def test_unknown_event_returns_200_but_unhandled(client: TestClient) -> None:
body = json.dumps({"action": "started"}).encode()
response = client.post(
"/api/webhooks/github",
content=body,
headers={
"X-GitHub-Event": "workflow_run",
"X-GitHub-Delivery": DELIVERY_ID,
"X-Hub-Signature-256": _signature(body),
},
)
assert response.status_code == 200
body_json = response.json()
assert body_json["ok"] is True
assert body_json["handled"] is False
assert body_json["event"] == "workflow_run"
# ---------------------------------------------------------------------------
# Signature verification
# ---------------------------------------------------------------------------
def test_missing_signature_returns_401(client: TestClient) -> None:
body = b'{"zen": "x"}'
response = client.post(
"/api/webhooks/github",
content=body,
headers={
"X-GitHub-Event": "ping",
"X-GitHub-Delivery": DELIVERY_ID,
},
)
assert response.status_code == 401
assert "X-Hub-Signature-256" in response.json()["detail"]
def test_malformed_signature_returns_401(client: TestClient) -> None:
body = b'{"zen": "x"}'
response = client.post(
"/api/webhooks/github",
content=body,
headers={
"X-GitHub-Event": "ping",
"X-GitHub-Delivery": DELIVERY_ID,
"X-Hub-Signature-256": "not-a-valid-format",
},
)
assert response.status_code == 401
def test_signature_mismatch_returns_401(client: TestClient) -> None:
body = b'{"zen": "x"}'
# Sign with a different secret.
bad_sig = _signature(body, secret="wrong-secret")
response = client.post(
"/api/webhooks/github",
content=body,
headers={
"X-GitHub-Event": "ping",
"X-GitHub-Delivery": DELIVERY_ID,
"X-Hub-Signature-256": bad_sig,
},
)
assert response.status_code == 401
def test_signature_verified_against_exact_bytes(client: TestClient) -> None:
"""Signature must be computed over the request body bytes, not
re-serialised JSON. Whitespace and key ordering matter."""
body = b'{"zen":"x","other":1}' # no spaces
response = client.post(
"/api/webhooks/github",
content=body,
headers={
"X-GitHub-Event": "ping",
"X-GitHub-Delivery": DELIVERY_ID,
"X-Hub-Signature-256": _signature(body),
},
)
assert response.status_code == 200
# ---------------------------------------------------------------------------
# Unset-secret dev mode
# ---------------------------------------------------------------------------
def test_unset_secret_rejects_with_503_by_default(client: TestClient, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture) -> None:
"""Fail-closed contract: unset secret + no dev opt-in => the runtime
handler rejects the delivery with 503 even though the route is
mounted in this test app. Production fail-closed depends on
``is_route_enabled`` gating the include in :mod:`app.gateway.app`;
this is the defense-in-depth fallback path inside the handler itself
(e.g. for a runtime env-var rotation that cleared the secret without
a restart).
"""
monkeypatch.delenv("GITHUB_WEBHOOK_SECRET", raising=False)
monkeypatch.delenv("DEER_FLOW_ALLOW_UNVERIFIED_GITHUB_WEBHOOKS", raising=False)
body = json.dumps({"zen": "ok"}).encode()
with caplog.at_level("ERROR", logger="app.gateway.routers.github_webhooks"):
response = client.post(
"/api/webhooks/github",
content=body,
headers={"X-GitHub-Event": "ping", "X-GitHub-Delivery": DELIVERY_ID},
)
assert response.status_code == 503
detail = response.json()["detail"]
assert "GITHUB_WEBHOOK_SECRET" in detail
assert "DEER_FLOW_ALLOW_UNVERIFIED_GITHUB_WEBHOOKS" in detail
assert any("rejecting delivery" in rec.message for rec in caplog.records)
def test_unset_secret_with_dev_optin_accepts_unverified(client: TestClient, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture) -> None:
"""Explicit dev/loopback opt-in: ``DEER_FLOW_ALLOW_UNVERIFIED_GITHUB_WEBHOOKS=1``
causes the handler to accept unverified deliveries with a loud WARNING.
"""
monkeypatch.delenv("GITHUB_WEBHOOK_SECRET", raising=False)
monkeypatch.setenv("DEER_FLOW_ALLOW_UNVERIFIED_GITHUB_WEBHOOKS", "1")
body = json.dumps({"zen": "ok"}).encode()
with caplog.at_level("WARNING", logger="app.gateway.routers.github_webhooks"):
response = client.post(
"/api/webhooks/github",
content=body,
headers={"X-GitHub-Event": "ping", "X-GitHub-Delivery": DELIVERY_ID},
)
assert response.status_code == 200
assert any("UNVERIFIED delivery" in rec.message and "dev/loopback mode ONLY" in rec.message for rec in caplog.records)
def test_empty_string_secret_rejects_without_optin(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""An empty/whitespace-only secret is treated as unset by
:func:`_get_webhook_secret`, so the fail-closed path applies (503) unless
the explicit unverified opt-in is set.
"""
monkeypatch.setenv("GITHUB_WEBHOOK_SECRET", " ")
monkeypatch.delenv("DEER_FLOW_ALLOW_UNVERIFIED_GITHUB_WEBHOOKS", raising=False)
body = json.dumps({"zen": "ok"}).encode()
response = client.post(
"/api/webhooks/github",
content=body,
headers={"X-GitHub-Event": "ping", "X-GitHub-Delivery": DELIVERY_ID},
)
assert response.status_code == 503
@pytest.mark.parametrize("value", ["0", "false", "no", "off", "", " ", "anything-else"])
def test_unverified_optin_falsy_values_reject(client: TestClient, monkeypatch: pytest.MonkeyPatch, value: str) -> None:
"""Only the documented truthy strings flip the unverified opt-in. Anything
else — including 0/false/empty — keeps the fail-closed posture.
"""
monkeypatch.delenv("GITHUB_WEBHOOK_SECRET", raising=False)
monkeypatch.setenv("DEER_FLOW_ALLOW_UNVERIFIED_GITHUB_WEBHOOKS", value)
body = json.dumps({"zen": "ok"}).encode()
response = client.post(
"/api/webhooks/github",
content=body,
headers={"X-GitHub-Event": "ping", "X-GitHub-Delivery": DELIVERY_ID},
)
assert response.status_code == 503
@pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes", "ON"])
def test_unverified_optin_truthy_values_accept(client: TestClient, monkeypatch: pytest.MonkeyPatch, value: str) -> None:
"""Case-insensitive accepted truthy values for the dev opt-in."""
monkeypatch.delenv("GITHUB_WEBHOOK_SECRET", raising=False)
monkeypatch.setenv("DEER_FLOW_ALLOW_UNVERIFIED_GITHUB_WEBHOOKS", value)
body = json.dumps({"zen": "ok"}).encode()
response = client.post(
"/api/webhooks/github",
content=body,
headers={"X-GitHub-Event": "ping", "X-GitHub-Delivery": DELIVERY_ID},
)
assert response.status_code == 200
def test_is_route_enabled_requires_secret_or_optin(monkeypatch: pytest.MonkeyPatch) -> None:
"""Startup-time gate that :mod:`app.gateway.app` consults before
mounting the router. Fail-closed: neither var set => route NOT mounted.
"""
monkeypatch.delenv("GITHUB_WEBHOOK_SECRET", raising=False)
monkeypatch.delenv("DEER_FLOW_ALLOW_UNVERIFIED_GITHUB_WEBHOOKS", raising=False)
assert github_webhooks.is_route_enabled() is False
monkeypatch.setenv("GITHUB_WEBHOOK_SECRET", "anything")
assert github_webhooks.is_route_enabled() is True
monkeypatch.delenv("GITHUB_WEBHOOK_SECRET", raising=False)
monkeypatch.setenv("DEER_FLOW_ALLOW_UNVERIFIED_GITHUB_WEBHOOKS", "1")
assert github_webhooks.is_route_enabled() is True
# Empty / whitespace-only secret is treated as unset, so the opt-in
# alone decides.
monkeypatch.setenv("GITHUB_WEBHOOK_SECRET", " ")
monkeypatch.delenv("DEER_FLOW_ALLOW_UNVERIFIED_GITHUB_WEBHOOKS", raising=False)
assert github_webhooks.is_route_enabled() is False
# ---------------------------------------------------------------------------
# Header / body edge cases
# ---------------------------------------------------------------------------
def test_missing_event_header_returns_400(client: TestClient) -> None:
body = b'{"zen": "x"}'
response = client.post(
"/api/webhooks/github",
content=body,
headers={
"X-GitHub-Delivery": DELIVERY_ID,
"X-Hub-Signature-256": _signature(body),
},
)
assert response.status_code == 400
assert "X-GitHub-Event" in response.json()["detail"]
def test_invalid_json_body_returns_400(client: TestClient) -> None:
body = b"this-is-not-json"
response = client.post(
"/api/webhooks/github",
content=body,
headers={
"X-GitHub-Event": "ping",
"X-GitHub-Delivery": DELIVERY_ID,
"X-Hub-Signature-256": _signature(body),
},
)
assert response.status_code == 400
assert "Invalid JSON" in response.json()["detail"]
# ---------------------------------------------------------------------------
# CSRF middleware exemption
# ---------------------------------------------------------------------------
def test_csrf_middleware_does_not_block_webhook(client: TestClient) -> None:
"""The route is mounted behind CSRFMiddleware in _make_app(). GitHub
sends neither csrf_token cookie nor X-CSRF-Token header, so the
middleware must allow this path through without those credentials.
"""
body = json.dumps({"zen": "ok"}).encode()
response = client.post(
"/api/webhooks/github",
content=body,
headers={
"X-GitHub-Event": "ping",
"X-GitHub-Delivery": DELIVERY_ID,
"X-Hub-Signature-256": _signature(body),
},
)
# If CSRF middleware blocked the route, we'd see 403 with a
# "CSRF token missing" detail. We must get 200 instead.
assert response.status_code == 200, response.text
# ---------------------------------------------------------------------------
# Dispatcher integration
# ---------------------------------------------------------------------------
def test_dispatch_result_included_in_response(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""The fan-out helper's summary dict should appear in the response payload."""
fake = AsyncMock(return_value={"matched_agents": ["x"], "fired_agents": ["x"], "skipped": []})
monkeypatch.setattr(github_webhooks, "fanout_event", fake)
body = json.dumps({"zen": "ok"}).encode()
response = client.post(
"/api/webhooks/github",
content=body,
headers={
"X-GitHub-Event": "ping",
"X-GitHub-Delivery": DELIVERY_ID,
"X-Hub-Signature-256": _signature(body),
},
)
assert response.status_code == 200
assert response.json()["dispatch"] == {"matched_agents": ["x"], "fired_agents": ["x"], "skipped": []}
assert fake.await_count == 1
def test_dispatch_failure_returns_503_so_github_retries(client: TestClient, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture) -> None:
"""A crashing fan-out helper must return 503 so GitHub retries.
The earlier behaviour swallowed every fan-out exception into a 200 OK
response (``dispatch={"error": "fanout failed"}``). GitHub only retries
5xx — a 200 ack permanently drops the delivery. The route now lets
runtime failures propagate as 503 so a transient registry/bus error
triggers GitHub's redelivery path. The startup-time
``is_route_enabled`` check still handles *configuration* failures
fail-closed (route absent → 404); 503 is reserved for runtime
failures GitHub can retry past.
"""
async def fake_fanout(*args, **kwargs) -> dict:
raise RuntimeError("transient registry hiccup")
monkeypatch.setattr(github_webhooks, "fanout_event", fake_fanout)
body = json.dumps({"zen": "ok"}).encode()
with caplog.at_level("ERROR", logger="app.gateway.routers.github_webhooks"):
response = client.post(
"/api/webhooks/github",
content=body,
headers={
"X-GitHub-Event": "ping",
"X-GitHub-Delivery": DELIVERY_ID,
"X-Hub-Signature-256": _signature(body),
},
)
assert response.status_code == 503
detail = response.json()["detail"]
assert "fan-out failed" in detail
assert DELIVERY_ID in detail
assert "transient registry hiccup" in detail
# Operator-visible log: stack trace + delivery id so the redelivery
# page entry can be correlated.
assert any("fanout failed" in rec.message for rec in caplog.records)
def test_dispatch_failure_503_lets_github_redeliver_successfully(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""A retried delivery (after the transient error resolves) lands on 200.
Regression: confirm the 503 response is a real signal — once the
underlying failure is gone, the same delivery (re-sent by GitHub)
succeeds normally. If we ever cache "failed delivery" state on the
route, this test would catch it.
"""
calls: list[int] = []
async def flaky_fanout(*args, **kwargs) -> dict:
calls.append(1)
if len(calls) == 1:
raise RuntimeError("transient")
return {"matched_agents": [], "fired_agents": [], "skipped": []}
monkeypatch.setattr(github_webhooks, "fanout_event", flaky_fanout)
body = json.dumps({"zen": "ok"}).encode()
first = client.post(
"/api/webhooks/github",
content=body,
headers={
"X-GitHub-Event": "ping",
"X-GitHub-Delivery": DELIVERY_ID,
"X-Hub-Signature-256": _signature(body),
},
)
assert first.status_code == 503
# GitHub redelivers — same payload, same signature.
second = client.post(
"/api/webhooks/github",
content=body,
headers={
"X-GitHub-Event": "ping",
"X-GitHub-Delivery": DELIVERY_ID,
"X-Hub-Signature-256": _signature(body),
},
)
assert second.status_code == 200
assert second.json()["dispatch"] == {"matched_agents": [], "fired_agents": [], "skipped": []}
def test_unknown_event_skips_dispatcher(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""The fan-out helper is not invoked for events not in _KNOWN_EVENTS."""
fake = AsyncMock(return_value={})
monkeypatch.setattr(github_webhooks, "fanout_event", fake)
body = json.dumps({"action": "x"}).encode()
response = client.post(
"/api/webhooks/github",
content=body,
headers={
"X-GitHub-Event": "workflow_run",
"X-GitHub-Delivery": DELIVERY_ID,
"X-Hub-Signature-256": _signature(body),
},
)
assert response.status_code == 200
assert response.json()["handled"] is False
assert response.json()["dispatch"] is None
assert fake.await_count == 0
def test_missing_channel_service_does_not_500(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""If the channel service is not running, the route must still 200."""
import app.channels.service as service_module
monkeypatch.setattr(service_module, "get_channel_service", lambda: None)
body = json.dumps({"zen": "ok"}).encode()
response = client.post(
"/api/webhooks/github",
content=body,
headers={
"X-GitHub-Event": "ping",
"X-GitHub-Delivery": DELIVERY_ID,
"X-Hub-Signature-256": _signature(body),
},
)
assert response.status_code == 200
assert response.json()["dispatch"]["error"] == "channel_service_not_available"
def test_channel_disabled_skips_fanout(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""``channels.github.enabled: false`` is the documented operator kill-switch.
With the channel disabled, the route must NOT call ``fanout_event`` —
publishing inbound onto the bus would let the ChannelManager consumer
pick it up and run agents that then post back to GitHub via ``gh``,
contradicting the documented off-switch. Returns 200 (permanent
state, not transient) so GitHub doesn't retry; ``dispatch.skipped``
surfaces the reason in the Recent Deliveries panel.
"""
bus = MessageBus()
class _DisabledService:
def __init__(self) -> None:
self.bus = bus
def is_channel_enabled(self, name: str) -> bool:
return False # the kill-switch
def get_channel_config(self, name: str) -> dict | None:
return None
import app.channels.service as service_module
monkeypatch.setattr(service_module, "get_channel_service", lambda: _DisabledService())
# Belt-and-braces: also pin that fanout_event is never invoked even if
# is_channel_enabled is bypassed by a future regression.
fake_fanout = AsyncMock(return_value={"matched": ["should-not-run"]})
import app.gateway.routers.github_webhooks as router_module
monkeypatch.setattr(router_module, "fanout_event", fake_fanout)
body = json.dumps(
{
"action": "opened",
"number": 7,
"pull_request": {"number": 7, "title": "PR", "html_url": "https://github.com/o/r/pull/7"},
"repository": {"full_name": "o/r"},
}
).encode()
response = client.post(
"/api/webhooks/github",
content=body,
headers={
"X-GitHub-Event": "pull_request",
"X-GitHub-Delivery": DELIVERY_ID,
"X-Hub-Signature-256": _signature(body),
},
)
assert response.status_code == 200
payload = response.json()
assert payload["handled"] is True
assert payload["dispatch"] == {"skipped": "channel_disabled"}
assert fake_fanout.await_count == 0
def test_channel_enabled_dispatches_normally(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""Sanity counterpart to the kill-switch test: enabled → fan-out runs.
Pins the positive branch so a future regression that inverts
``is_channel_enabled`` semantics fails loudly here too.
"""
fake_fanout = AsyncMock(return_value={"matched": ["agent-a"]})
import app.gateway.routers.github_webhooks as router_module
monkeypatch.setattr(router_module, "fanout_event", fake_fanout)
body = json.dumps(
{
"action": "opened",
"number": 7,
"pull_request": {"number": 7, "title": "PR", "html_url": "https://github.com/o/r/pull/7"},
"repository": {"full_name": "o/r"},
}
).encode()
response = client.post(
"/api/webhooks/github",
content=body,
headers={
"X-GitHub-Event": "pull_request",
"X-GitHub-Delivery": DELIVERY_ID,
"X-Hub-Signature-256": _signature(body),
},
)
assert response.status_code == 200
assert response.json()["dispatch"] == {"matched": ["agent-a"]}
assert fake_fanout.await_count == 1
def test_operator_default_mention_login_is_threaded_to_fanout(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""Regression pin for willem-bd's R8 on PR #3754.
The webhook route must read ``channels.github.default_mention_login``
from the live channel-service config and pass it through as
``operator_default_mention_login`` to ``fanout_event``. Without this,
the documented operator default is never honoured: an agent named
``coder`` with ``require_mention: true`` silently requires ``@coder``
mentions instead of the configured ``@deerflow-bot``.
"""
bus = MessageBus()
class _ConfiguredService:
def __init__(self) -> None:
self.bus = bus
def is_channel_enabled(self, name: str) -> bool:
return True
def get_channel_config(self, name: str) -> dict | None:
if name == "github":
return {"enabled": True, "default_mention_login": "deerflow-bot"}
return None
import app.channels.service as service_module
monkeypatch.setattr(service_module, "get_channel_service", lambda: _ConfiguredService())
fake_fanout = AsyncMock(return_value={"matched_agents": [], "fired_agents": [], "skipped": []})
import app.gateway.routers.github_webhooks as router_module
monkeypatch.setattr(router_module, "fanout_event", fake_fanout)
body = json.dumps(
{
"action": "opened",
"number": 7,
"pull_request": {"number": 7, "title": "PR", "html_url": "https://github.com/o/r/pull/7"},
"repository": {"full_name": "o/r"},
}
).encode()
response = client.post(
"/api/webhooks/github",
content=body,
headers={
"X-GitHub-Event": "pull_request",
"X-GitHub-Delivery": DELIVERY_ID,
"X-Hub-Signature-256": _signature(body),
},
)
assert response.status_code == 200
assert fake_fanout.await_count == 1
# The kwarg must have been passed through with the configured value.
_, kwargs = fake_fanout.await_args
assert kwargs["operator_default_mention_login"] == "deerflow-bot"
def test_operator_default_mention_login_absent_passes_none(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""When ``channels.github.default_mention_login`` is unset, the kwarg is None.
A deployment that never opted into the operator default must NOT have
a phantom value silently substituted. The dispatcher's existing
``bot_login → agent.name`` chain remains the source of truth.
"""
fake_fanout = AsyncMock(return_value={"matched_agents": [], "fired_agents": [], "skipped": []})
import app.gateway.routers.github_webhooks as router_module
monkeypatch.setattr(router_module, "fanout_event", fake_fanout)
body = json.dumps(
{
"action": "opened",
"number": 7,
"pull_request": {"number": 7, "title": "PR", "html_url": "https://github.com/o/r/pull/7"},
"repository": {"full_name": "o/r"},
}
).encode()
response = client.post(
"/api/webhooks/github",
content=body,
headers={
"X-GitHub-Event": "pull_request",
"X-GitHub-Delivery": DELIVERY_ID,
"X-Hub-Signature-256": _signature(body),
},
)
assert response.status_code == 200
_, kwargs = fake_fanout.await_args
assert kwargs["operator_default_mention_login"] is None
# ---------------------------------------------------------------------------
# Helper unit tests
# ---------------------------------------------------------------------------
def test_verify_signature_helper_constant_time_equal() -> None:
body = b'{"x": 1}'
sig = _signature(body)
assert github_webhooks._verify_signature(SECRET, body, sig) is True
def test_verify_signature_rejects_none() -> None:
assert github_webhooks._verify_signature(SECRET, b"x", None) is False
def test_verify_signature_rejects_missing_prefix() -> None:
assert github_webhooks._verify_signature(SECRET, b"x", "abcdef0123") is False
def test_summarise_event_handles_missing_fields() -> None:
# No KeyError even on a near-empty payload.
result = github_webhooks._summarise_event("pull_request", {})
assert "pull_request" in result
def test_summarise_event_unknown_event_falls_back() -> None:
result = github_webhooks._summarise_event("deployment_status", {"action": "success", "repository": {"full_name": "a/b"}})
assert "deployment_status" in result
assert "success" in result