"""Unit: webhook HTTP surface (signature + routing over a real server).""" from __future__ import annotations import hashlib import hmac import json import threading import unittest import urllib.request from urllib.error import HTTPError from bot_bottle.orchestrator.model import RunRecord from bot_bottle.orchestrator.store import InMemoryStateStore from bot_bottle.orchestrator.webhook import WebhookServer, verify_signature _ISSUE_ASSIGNED = { "action": "assigned", "repository": {"name": "bot-bottle", "owner": {"login": "didericis"}}, "issue": { "number": 17, "title": "t", "body": "b", "assignees": [{"login": "agent-bot"}], "labels": [{"name": "bot-bottle:impl"}], }, } class _RecordingOrch: def __init__(self) -> None: self.events: list[object] = [] def handle(self, event: object) -> None: self.events.append(event) class SignatureTest(unittest.TestCase): def test_verify(self): secret = b"s3cret" body = b'{"x":1}' sig = hmac.new(secret, body, hashlib.sha256).hexdigest() self.assertTrue(verify_signature(secret, body, sig)) self.assertFalse(verify_signature(secret, body, "deadbeef")) class WebhookServerTest(unittest.TestCase): # _serve is the per-test setup; attributes are assigned there. # pylint: disable=attribute-defined-outside-init def _serve(self, **kwargs: object) -> None: self.orch = _RecordingOrch() kwargs.setdefault("store", InMemoryStateStore()) self.server = WebhookServer( ("127.0.0.1", 0), orchestrator=self.orch, **kwargs, # type: ignore[arg-type] ) self.port = self.server.server_address[1] self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) self.thread.start() self.addCleanup(self._shutdown) def _shutdown(self) -> None: self.server.shutdown() self.server.server_close() self.thread.join(timeout=5) def _post( self, path: str, body: bytes, headers: dict[str, str] | None = None ) -> tuple[int, dict[str, object]]: req = urllib.request.Request( f"http://127.0.0.1:{self.port}{path}", data=body, method="POST", headers=headers or {}, ) with urllib.request.urlopen(req, timeout=5) as resp: return resp.status, json.loads(resp.read()) def _get(self, path: str) -> tuple[int, dict[str, object]]: with urllib.request.urlopen(f"http://127.0.0.1:{self.port}{path}", timeout=5) as r: return r.status, json.loads(r.read()) def test_webhook_dispatches(self): self._serve() body = json.dumps(_ISSUE_ASSIGNED).encode() status, payload = self._post("/webhook", body, {"X-Gitea-Event": "issues"}) self.assertEqual(200, status) self.assertTrue(payload["handled"]) self.assertEqual(1, len(self.orch.events)) def test_unhandled_event_ok_but_not_handled(self): self._serve() body = json.dumps({"action": "push"}).encode() _status, payload = self._post("/webhook", body, {"X-Gitea-Event": "push"}) self.assertFalse(payload["handled"]) self.assertEqual([], self.orch.events) def test_invalid_json_400(self): self._serve() with self.assertRaises(HTTPError) as ctx: self._post("/webhook", b"{not json", {"X-Gitea-Event": "issues"}) self.assertEqual(400, ctx.exception.code) def test_bad_signature_rejected(self): self._serve(secret=b"sekret") body = json.dumps(_ISSUE_ASSIGNED).encode() with self.assertRaises(HTTPError) as ctx: self._post("/webhook", body, {"X-Gitea-Event": "issues", "X-Gitea-Signature": "deadbeef"}) self.assertEqual(401, ctx.exception.code) self.assertEqual([], self.orch.events) def test_good_signature_accepted(self): self._serve(secret=b"sekret") body = json.dumps(_ISSUE_ASSIGNED).encode() sig = hmac.new(b"sekret", body, hashlib.sha256).hexdigest() status, _payload = self._post( "/webhook", body, {"X-Gitea-Event": "issues", "X-Gitea-Signature": sig}) self.assertEqual(200, status) self.assertEqual(1, len(self.orch.events)) def test_healthz(self): self._serve() self.assertEqual(200, self._get("/healthz")[0]) def test_unknown_path_404(self): self._serve() with self.assertRaises(HTTPError) as ctx: self._post("/nope", b"{}", {"X-Gitea-Event": "issues"}) self.assertEqual(404, ctx.exception.code) def test_provenance_returns_record_and_ops(self): store = InMemoryStateStore() store.upsert(RunRecord(owner="didericis", repo="bot-bottle", issue_number=17, slug="impl-17", agent_name="impl", bottle_names=["claude"])) def reader(rec: object) -> list[dict[str, object]]: # pylint: disable=unused-argument return [{"at": "T", "op": "post_comment", "target": 17, "detail": "ok"}] self._serve(store=store, op_log_reader=reader) status, payload = self._get("/provenance?owner=didericis&repo=bot-bottle&issue=17") self.assertEqual(200, status) self.assertEqual("impl-17", payload["slug"]) self.assertEqual(1, len(payload["ops"])) # type: ignore[arg-type] def test_provenance_missing_params_400(self): self._serve() with self.assertRaises(HTTPError) as ctx: self._get("/provenance?owner=didericis") self.assertEqual(400, ctx.exception.code) def test_provenance_unknown_run_404(self): self._serve() with self.assertRaises(HTTPError) as ctx: self._get("/provenance?owner=x&repo=y&issue=1") self.assertEqual(404, ctx.exception.code) def test_unknown_get_path_404(self): self._serve() with self.assertRaises(HTTPError) as ctx: self._get("/nope") self.assertEqual(404, ctx.exception.code) if __name__ == "__main__": unittest.main()