204 lines
6.1 KiB
Python
204 lines
6.1 KiB
Python
import hashlib
|
|
import hmac
|
|
import json
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from httpx import ASGITransport, AsyncClient, Response
|
|
|
|
from agentci.api.routes.webhook import router
|
|
from agentci.engine.model import IncomingCommand
|
|
from agentci.integrations.gitea.webhooks import (
|
|
incoming_command_from_payload,
|
|
valid_signature,
|
|
)
|
|
|
|
|
|
class FakeRepository:
|
|
def __init__(self, *, duplicate: bool = False) -> None:
|
|
self.accepted: list[IncomingCommand] = []
|
|
self.duplicate = duplicate
|
|
|
|
async def accept(self, event: IncomingCommand) -> SimpleNamespace:
|
|
self.accepted.append(event)
|
|
job = SimpleNamespace(id="job", receive_sequence=1)
|
|
return SimpleNamespace(job=job, duplicate=self.duplicate)
|
|
|
|
|
|
def payload(body: str, *, is_pull: bool = False, requester: str = "alice") -> dict:
|
|
value = {
|
|
"action": "created",
|
|
"comment": {"id": 8, "body": body, "user": {"login": requester}},
|
|
"repository": {"name": "repo", "owner": {"login": "org"}},
|
|
"issue": {"number": 4},
|
|
"is_pull": is_pull,
|
|
}
|
|
if is_pull:
|
|
value["pull_request"] = {"number": 4}
|
|
return value
|
|
|
|
|
|
def encoded(value: object) -> bytes:
|
|
return json.dumps(value).encode()
|
|
|
|
|
|
def sign(body: bytes, secret: bytes = b"secret") -> str:
|
|
return hmac.new(secret, body, hashlib.sha256).hexdigest()
|
|
|
|
|
|
async def post_webhook(
|
|
repository: FakeRepository,
|
|
body: bytes,
|
|
*,
|
|
event: str = "issue_comment",
|
|
delivery: str | None = "delivery",
|
|
signature: str | None = None,
|
|
bot_username: str = "agentci",
|
|
) -> Response:
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
app.state.runtime = SimpleNamespace(
|
|
repository=repository,
|
|
settings=SimpleNamespace(webhook_secret=b"secret", bot_username=bot_username),
|
|
)
|
|
headers = {
|
|
"X-Gitea-Event-Type": event,
|
|
"X-Gitea-Signature": sign(body) if signature is None else signature,
|
|
}
|
|
if delivery is not None:
|
|
headers["X-Gitea-Delivery"] = delivery
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
return await client.post("/webhooks/gitea", content=body, headers=headers)
|
|
|
|
|
|
async def test_signed_supported_command_is_accepted_once_with_raw_body() -> None:
|
|
repository = FakeRepository()
|
|
body = encoded(payload("/agent iterate\n\nkeep raw body", is_pull=True))
|
|
|
|
response = await post_webhook(repository, body)
|
|
|
|
assert response.status_code == 202
|
|
assert len(repository.accepted) == 1
|
|
event = repository.accepted[0]
|
|
assert event.delivery_id == "delivery"
|
|
assert event.target_key == "org/repo:pr:4"
|
|
assert event.requester == "alice"
|
|
assert event.body == "/agent iterate\n\nkeep raw body"
|
|
|
|
|
|
@pytest.mark.parametrize("event", ["issue_comment", "push"])
|
|
async def test_invalid_signature_precedes_parsing_and_event_filtering(event: str) -> None:
|
|
repository = FakeRepository()
|
|
|
|
response = await post_webhook(repository, b"not-json", event=event, signature="invalid")
|
|
|
|
assert response.status_code == 401
|
|
assert response.json() == {"detail": "Invalid webhook signature"}
|
|
assert repository.accepted == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("body", "event"),
|
|
[
|
|
pytest.param(
|
|
b"not-json",
|
|
"push",
|
|
id="unsupported-event-before-parsing",
|
|
),
|
|
pytest.param(
|
|
encoded(payload("ordinary discussion")),
|
|
"issue_comment",
|
|
id="non-command",
|
|
),
|
|
pytest.param(
|
|
encoded(payload("/agent plan", requester="AgentCI")),
|
|
"issue_comment",
|
|
id="bot-author",
|
|
),
|
|
pytest.param(
|
|
encoded({"action": "edited"}),
|
|
"issue_comment",
|
|
id="non-created-before-payload",
|
|
),
|
|
],
|
|
)
|
|
async def test_authenticated_irrelevant_webhooks_are_ignored(
|
|
body: bytes,
|
|
event: str,
|
|
) -> None:
|
|
repository = FakeRepository()
|
|
|
|
response = await post_webhook(repository, body, event=event)
|
|
|
|
assert response.status_code == 204
|
|
assert repository.accepted == []
|
|
|
|
|
|
async def test_duplicate_delivery_returns_ok() -> None:
|
|
repository = FakeRepository(duplicate=True)
|
|
|
|
response = await post_webhook(repository, encoded(payload("/agent plan")))
|
|
|
|
assert response.status_code == 200
|
|
assert len(repository.accepted) == 1
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("body", "delivery", "detail"),
|
|
[
|
|
pytest.param(b"{", "delivery", "Invalid webhook payload", id="invalid-json"),
|
|
pytest.param(encoded([]), "delivery", "Invalid webhook payload", id="non-object"),
|
|
pytest.param(
|
|
encoded({"action": "created"}),
|
|
"delivery",
|
|
"Invalid webhook payload",
|
|
id="missing-comment",
|
|
),
|
|
pytest.param(
|
|
encoded({"action": "created", "comment": {}}),
|
|
"delivery",
|
|
"Invalid webhook payload",
|
|
id="incomplete-comment",
|
|
),
|
|
pytest.param(
|
|
encoded(payload("/agent plan")),
|
|
None,
|
|
"Missing X-Gitea-Delivery",
|
|
id="missing-delivery",
|
|
),
|
|
],
|
|
)
|
|
async def test_authenticated_bad_requests_are_rejected_without_persistence(
|
|
body: bytes,
|
|
delivery: str | None,
|
|
detail: str,
|
|
) -> None:
|
|
repository = FakeRepository()
|
|
|
|
response = await post_webhook(repository, body, delivery=delivery)
|
|
|
|
assert response.status_code == 400
|
|
assert response.json() == {"detail": detail}
|
|
assert repository.accepted == []
|
|
|
|
|
|
def test_payload_parser_supports_owner_username_and_pull_request() -> None:
|
|
value = payload("/agent plan", is_pull=True)
|
|
value["repository"]["owner"] = {"username": "fallback-owner"}
|
|
|
|
event = incoming_command_from_payload("delivery", value)
|
|
|
|
assert event is not None
|
|
assert event.repo_owner == "fallback-owner"
|
|
assert event.issue_number == 4
|
|
assert event.pr_number == 4
|
|
|
|
|
|
def test_signature_validation_requires_exact_nonempty_digest() -> None:
|
|
signature = sign(b"{}")
|
|
|
|
assert valid_signature(b"secret", b"{}", signature)
|
|
assert not valid_signature(b"secret", b"{}", "")
|
|
assert not valid_signature(b"secret", b"{}", "bad")
|