882 lines
31 KiB
Python
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
|