314dc03b0d
Moves the orchestrator into bot_bottle/orchestrator/ so one install gets everything. Entry point is now `python -m bot_bottle.orchestrator run`. - Add bot_bottle/orchestrator/ with all 14 modules (verbatim move; internal imports were already relative, so no changes inside orchestrator modules) - Rewrite bootstrap.py: remove the lazy bot_bottle import guard, use direct relative imports from ..contrib.* - Add bot_bottle/contrib/forge/base.py: ScopedForge (read-anywhere / write-scoped) - Add bot_bottle/contrib/gitea/client.py: GiteaClient + GiteaForge (urllib.request only) - Add bot_bottle/contrib/gitea/forge_state.py: ForgeState + SqliteForgeStateStore - Add tests/unit/orchestrator/ (82 tests: 63 migrated + 19 new for contrib modules) Closes #321
156 lines
5.8 KiB
Python
156 lines
5.8 KiB
Python
"""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)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|