Files
2026-07-13 13:12:33 +08:00

274 lines
8.8 KiB
Python

"""Webhook delivery mode.
``DeliveryMode.WEBHOOK`` POSTs the finished-run event payload to
``DeliveryConfig.webhook_url``, optionally with a bearer token. URL is
validated up front and rejected at add time when malformed.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from opensquilla.scheduler.delivery import DeliveryChain, validate_webhook_url
from opensquilla.scheduler.ops import SchedulerOps
from opensquilla.scheduler.payloads import make_agent_turn_payload
from opensquilla.scheduler.persistence import JobStore
from opensquilla.scheduler.types import (
CronJob,
DeliveryConfig,
DeliveryMode,
ScheduleKind,
SessionTarget,
)
# --- URL validation --------------------------------------------------------
def test_validate_webhook_url_accepts_http_and_https() -> None:
validate_webhook_url("http://example.com/hook")
validate_webhook_url("https://example.com/hook?x=1")
def test_validate_webhook_url_rejects_other_schemes() -> None:
with pytest.raises(ValueError, match="http or https"):
validate_webhook_url("ftp://example.com/x")
with pytest.raises(ValueError, match="http or https"):
validate_webhook_url("file:///tmp/x")
def test_validate_webhook_url_requires_hostname() -> None:
with pytest.raises(ValueError, match="hostname"):
validate_webhook_url("https:///nohost")
def test_validate_webhook_url_rejects_empty() -> None:
with pytest.raises(ValueError, match="required"):
validate_webhook_url("")
# --- ops.add validates webhook config -------------------------------------
async def test_ops_add_with_webhook_delivery_persists(tmp_path: Path) -> None:
db = tmp_path / "cron.db"
store = JobStore(str(db))
await store.open()
try:
ops = SchedulerOps(store)
delivery = DeliveryConfig(
mode=DeliveryMode.WEBHOOK,
webhook_url="https://hooks.example/cron",
webhook_token="secret-bearer",
best_effort=True,
)
job = await ops.add(
name="hook",
schedule_kind=ScheduleKind.CRON,
schedule_value="*/5 * * * *",
handler_key="agent_run",
payload=make_agent_turn_payload("brief"),
session_target=SessionTarget.ISOLATED,
delivery=delivery,
)
assert job.delivery.mode == DeliveryMode.WEBHOOK
assert job.delivery.webhook_url == "https://hooks.example/cron"
assert job.delivery.webhook_token == "secret-bearer"
assert job.delivery.best_effort is True
reloaded = await store.get(job.id)
assert reloaded is not None
assert reloaded.delivery.mode == DeliveryMode.WEBHOOK
assert reloaded.delivery.webhook_url == "https://hooks.example/cron"
assert reloaded.delivery.webhook_token == "secret-bearer"
assert reloaded.delivery.best_effort is True
finally:
await store.close()
async def test_ops_add_rejects_webhook_without_url(tmp_path: Path) -> None:
db = tmp_path / "cron.db"
store = JobStore(str(db))
await store.open()
try:
ops = SchedulerOps(store)
with pytest.raises(ValueError, match="webhook URL is required"):
await ops.add(
name="bad",
schedule_kind=ScheduleKind.CRON,
schedule_value="*/5 * * * *",
handler_key="agent_run",
payload=make_agent_turn_payload("x"),
session_target=SessionTarget.ISOLATED,
delivery=DeliveryConfig(mode=DeliveryMode.WEBHOOK, webhook_url=""),
)
finally:
await store.close()
async def test_ops_add_rejects_webhook_with_bad_scheme(tmp_path: Path) -> None:
db = tmp_path / "cron.db"
store = JobStore(str(db))
await store.open()
try:
ops = SchedulerOps(store)
with pytest.raises(ValueError, match="http or https"):
await ops.add(
name="bad",
schedule_kind=ScheduleKind.CRON,
schedule_value="*/5 * * * *",
handler_key="agent_run",
payload=make_agent_turn_payload("x"),
session_target=SessionTarget.ISOLATED,
delivery=DeliveryConfig(
mode=DeliveryMode.WEBHOOK, webhook_url="ftp://example.com/x"
),
)
finally:
await store.close()
async def test_ops_add_allows_webhook_on_main_target(tmp_path: Path) -> None:
"""Webhook delivery is permitted for any sessionTarget, including main."""
db = tmp_path / "cron.db"
store = JobStore(str(db))
await store.open()
try:
from opensquilla.scheduler.payloads import make_system_event_payload
ops = SchedulerOps(store)
job = await ops.add(
name="main-hook",
schedule_kind=ScheduleKind.CRON,
schedule_value="*/5 * * * *",
handler_key="system_event",
payload=make_system_event_payload("reminder"),
session_target=SessionTarget.MAIN,
delivery=DeliveryConfig(
mode=DeliveryMode.WEBHOOK,
webhook_url="https://hooks.example/main",
),
)
assert job.delivery.mode == DeliveryMode.WEBHOOK
reloaded = await store.get(job.id)
assert reloaded is not None
assert reloaded.delivery.mode == DeliveryMode.WEBHOOK
assert reloaded.delivery.webhook_url == "https://hooks.example/main"
finally:
await store.close()
# --- DeliveryChain webhook dispatch ---------------------------------------
def _webhook_job(url: str, token: str = "") -> CronJob:
return CronJob(
id="job-1",
name="hook",
cron_expr="*/5 * * * *",
handler_key="agent_run",
payload={"kind": "agent_turn", "task": "x", "agent_id": "main"},
session_target=SessionTarget.ISOLATED,
delivery=DeliveryConfig(
mode=DeliveryMode.WEBHOOK,
webhook_url=url,
webhook_token=token,
),
)
class _RecordingAsyncClient:
"""Capture httpx.AsyncClient.post calls for assertion."""
instances: list[_RecordingAsyncClient] = []
def __init__(self, *, timeout=None, **_kw) -> None:
self.timeout = timeout
self.posts: list[dict] = []
_RecordingAsyncClient.instances.append(self)
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
async def post(self, url, json=None, headers=None):
self.posts.append({"url": url, "json": json, "headers": headers or {}})
class _Resp:
status_code = 200
def raise_for_status(self):
return None
return _Resp()
async def test_deliver_webhook_posts_json_with_bearer(monkeypatch) -> None:
_RecordingAsyncClient.instances.clear()
class _FakeHttpx:
AsyncClient = _RecordingAsyncClient
monkeypatch.setitem(__import__("sys").modules, "httpx", _FakeHttpx)
chain = DeliveryChain()
status = await chain._deliver_webhook(
_webhook_job("https://hooks.example/cron", token="abc"),
text="summary text",
)
assert status == "delivered"
assert _RecordingAsyncClient.instances, "AsyncClient was not constructed"
inst = _RecordingAsyncClient.instances[-1]
assert inst.posts, "no POST issued"
post = inst.posts[-1]
assert post["url"] == "https://hooks.example/cron"
assert post["json"]["jobId"] == "job-1"
assert post["json"]["summary"] == "summary text"
assert post["headers"]["Content-Type"] == "application/json"
assert post["headers"]["Authorization"] == "Bearer abc"
async def test_deliver_webhook_omits_authorization_when_no_token(monkeypatch) -> None:
_RecordingAsyncClient.instances.clear()
class _FakeHttpx:
AsyncClient = _RecordingAsyncClient
monkeypatch.setitem(__import__("sys").modules, "httpx", _FakeHttpx)
chain = DeliveryChain()
status = await chain._deliver_webhook(
_webhook_job("https://hooks.example/cron"),
text="x",
)
assert status == "delivered"
inst = _RecordingAsyncClient.instances[-1]
assert "Authorization" not in inst.posts[-1]["headers"]
async def test_deliver_webhook_returns_failed_on_http_error(monkeypatch) -> None:
class _ErrorClient(_RecordingAsyncClient):
async def post(self, url, json=None, headers=None):
class _Resp:
status_code = 500
def raise_for_status(self):
raise RuntimeError("HTTP 500")
return _Resp()
class _FakeHttpx:
AsyncClient = _ErrorClient
monkeypatch.setitem(__import__("sys").modules, "httpx", _FakeHttpx)
chain = DeliveryChain()
status = await chain._deliver_webhook(
_webhook_job("https://hooks.example/cron"),
text="x",
)
assert status == "delivery_failed"