merge: testing → main (reconcile 2-week divergence)
This commit is contained in:
0
tests/webhook/__init__.py
Normal file
0
tests/webhook/__init__.py
Normal file
18
tests/webhook/conftest.py
Normal file
18
tests/webhook/conftest.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Shared fixtures for webhook worker tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import AsyncIterator
|
||||
|
||||
import pytest_asyncio
|
||||
|
||||
from decnet.bus.fake import FakeBus
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def fake_bus() -> AsyncIterator[FakeBus]:
|
||||
bus = FakeBus()
|
||||
await bus.connect()
|
||||
try:
|
||||
yield bus
|
||||
finally:
|
||||
await bus.close()
|
||||
145
tests/webhook/test_client.py
Normal file
145
tests/webhook/test_client.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Unit tests for decnet.webhook.client — HMAC + retry policy."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from decnet.webhook.client import (
|
||||
DeliveryResult,
|
||||
SyntheticEvent,
|
||||
build_payload,
|
||||
deliver,
|
||||
sign,
|
||||
)
|
||||
|
||||
|
||||
_EVENT = SyntheticEvent(
|
||||
topic="attacker.observed",
|
||||
type="first_sighting",
|
||||
ts="2026-04-24T00:00:00+00:00",
|
||||
id="11111111-1111-1111-1111-111111111111",
|
||||
payload={"ip": "1.2.3.4"},
|
||||
)
|
||||
|
||||
|
||||
def _sub(url: str = "https://webhook.example/inbound", secret: str = "s" * 32) -> dict:
|
||||
return {"uuid": "w1", "url": url, "secret": secret}
|
||||
|
||||
|
||||
def test_sign_matches_known_vector():
|
||||
body = b'{"hello":"world"}'
|
||||
secret = "0123456789abcdef"
|
||||
expected = (
|
||||
"sha256="
|
||||
+ hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
)
|
||||
assert sign(secret, body) == expected
|
||||
|
||||
|
||||
def test_build_payload_stable_key_order():
|
||||
# Same input → same bytes → same HMAC, regardless of kwarg order.
|
||||
b1 = build_payload(_EVENT)
|
||||
b2 = build_payload(_EVENT)
|
||||
assert b1 == b2
|
||||
assert b'"topic":"attacker.observed"' in b1
|
||||
assert b'"v":1' in b1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_success_on_2xx():
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.headers.get("X-DECNET-Signature", "").startswith("sha256=")
|
||||
assert request.headers.get("X-DECNET-Event-Id") == _EVENT.id
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(transport=transport) as client:
|
||||
result = await deliver(_sub(), _EVENT, retry_schedule=[], client=client)
|
||||
assert result == DeliveryResult(ok=True, status_code=200, attempts=1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_no_retry_on_4xx():
|
||||
calls = {"n": 0}
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls["n"] += 1
|
||||
return httpx.Response(400, text="bad body")
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(transport=transport) as client:
|
||||
result = await deliver(_sub(), _EVENT, retry_schedule=[1, 1, 1], client=client)
|
||||
assert result.ok is False
|
||||
assert result.status_code == 400
|
||||
assert calls["n"] == 1 # no retry
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_retries_on_429():
|
||||
calls = {"n": 0}
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls["n"] += 1
|
||||
if calls["n"] < 3:
|
||||
return httpx.Response(429)
|
||||
return httpx.Response(200)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(transport=transport) as client:
|
||||
result = await deliver(_sub(), _EVENT, retry_schedule=[0, 0], client=client)
|
||||
assert result.ok is True
|
||||
assert result.attempts == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_retries_on_5xx_then_gives_up():
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(503)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(transport=transport) as client:
|
||||
result = await deliver(_sub(), _EVENT, retry_schedule=[0, 0], client=client)
|
||||
assert result.ok is False
|
||||
assert result.status_code == 503
|
||||
assert result.attempts == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_retries_on_connection_error():
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("boom")
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(transport=transport) as client:
|
||||
result = await deliver(_sub(), _EVENT, retry_schedule=[0], client=client)
|
||||
assert result.ok is False
|
||||
assert result.status_code is None
|
||||
assert "ConnectError" in (result.error or "")
|
||||
assert result.attempts == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_receiver_can_verify_signature():
|
||||
"""End-to-end: receiver recomputes HMAC over the posted body and matches ours."""
|
||||
sub = _sub(secret="deadbeefdeadbeef")
|
||||
captured: dict = {}
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured["body"] = request.content
|
||||
captured["sig"] = request.headers["X-DECNET-Signature"]
|
||||
return httpx.Response(200)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(transport=transport) as client:
|
||||
result = await deliver(sub, _EVENT, retry_schedule=[], client=client)
|
||||
assert result.ok
|
||||
expected = (
|
||||
"sha256="
|
||||
+ hmac.new(
|
||||
sub["secret"].encode(), captured["body"], hashlib.sha256
|
||||
).hexdigest()
|
||||
)
|
||||
assert captured["sig"] == expected
|
||||
49
tests/webhook/test_enums.py
Normal file
49
tests/webhook/test_enums.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Unit tests for decnet.webhook.enums — simple→patterns expansion."""
|
||||
from decnet.webhook.enums import (
|
||||
SIMPLE_EVENT_PATTERNS,
|
||||
expand_simple_events,
|
||||
merge_patterns,
|
||||
)
|
||||
|
||||
|
||||
def test_simple_event_patterns_covers_three_families():
|
||||
assert set(SIMPLE_EVENT_PATTERNS) == {
|
||||
"AttackerDetail",
|
||||
"DeckyStatus",
|
||||
"SystemStatus",
|
||||
}
|
||||
|
||||
|
||||
def test_expand_single_event():
|
||||
assert expand_simple_events(["AttackerDetail"]) == ["attacker.>"]
|
||||
|
||||
|
||||
def test_expand_multiple_events_concatenates():
|
||||
out = expand_simple_events(["AttackerDetail", "DeckyStatus"])
|
||||
assert out == ["attacker.>", "decky.*.state", "decky.*.traffic"]
|
||||
|
||||
|
||||
def test_expand_unknown_event_dropped_silently():
|
||||
# The Literal type on the router rejects unknowns; this guards against
|
||||
# programmer error, not user input.
|
||||
assert expand_simple_events(["NotAThing"]) == []
|
||||
|
||||
|
||||
def test_merge_dedups_overlap():
|
||||
merged = merge_patterns(["AttackerDetail"], ["attacker.>", "custom.>"])
|
||||
assert merged == ["attacker.>", "custom.>"]
|
||||
|
||||
|
||||
def test_merge_preserves_order_simple_first():
|
||||
merged = merge_patterns(["SystemStatus"], ["attacker.>", "decky.*.state"])
|
||||
assert merged == ["system.>", "attacker.>", "decky.*.state"]
|
||||
|
||||
|
||||
def test_merge_empty_lists_returns_empty():
|
||||
assert merge_patterns([], []) == []
|
||||
assert merge_patterns(None, None) == []
|
||||
|
||||
|
||||
def test_merge_drops_empty_strings_and_non_strings():
|
||||
merged = merge_patterns([], ["", "attacker.>", None]) # type: ignore[list-item]
|
||||
assert merged == ["attacker.>"]
|
||||
351
tests/webhook/test_worker.py
Normal file
351
tests/webhook/test_worker.py
Normal file
@@ -0,0 +1,351 @@
|
||||
"""Webhook worker — bus consumer → HTTP egress integration test."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from decnet.bus import topics as _topics
|
||||
from decnet.webhook.worker import (
|
||||
_patterns_for,
|
||||
_union_patterns,
|
||||
webhook_worker,
|
||||
)
|
||||
|
||||
|
||||
def _sub(
|
||||
uuid: str,
|
||||
name: str,
|
||||
patterns: list[str],
|
||||
*,
|
||||
url: str = "https://w.example/x",
|
||||
secret: str = "s" * 32,
|
||||
enabled: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"uuid": uuid,
|
||||
"name": name,
|
||||
"url": url,
|
||||
"secret": secret,
|
||||
"topic_patterns": json.dumps(patterns),
|
||||
"enabled": enabled,
|
||||
"consecutive_failures": 0,
|
||||
"last_success_at": None,
|
||||
"last_failure_at": None,
|
||||
"last_error": None,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
}
|
||||
|
||||
|
||||
class _FakeRepo:
|
||||
def __init__(self, subs: list[dict[str, Any]]):
|
||||
self.subs = subs
|
||||
self.success_calls: list[str] = []
|
||||
self.failure_calls: list[tuple[str, str]] = []
|
||||
self.trip_calls: list[str] = []
|
||||
self._failure_counts: dict[str, int] = {}
|
||||
|
||||
async def list_webhook_subscriptions(self, enabled_only: bool = False) -> list[dict[str, Any]]:
|
||||
return [s for s in self.subs if s["enabled"]] if enabled_only else list(self.subs)
|
||||
|
||||
async def record_webhook_success(self, uuid: str, ts: datetime) -> None:
|
||||
self.success_calls.append(uuid)
|
||||
self._failure_counts[uuid] = 0
|
||||
|
||||
async def record_webhook_failure(self, uuid: str, ts: datetime, error: str) -> int:
|
||||
self.failure_calls.append((uuid, error))
|
||||
self._failure_counts[uuid] = self._failure_counts.get(uuid, 0) + 1
|
||||
return self._failure_counts[uuid]
|
||||
|
||||
async def trip_webhook_circuit(self, uuid: str, ts: datetime) -> None:
|
||||
self.trip_calls.append(uuid)
|
||||
# Mirror the real DB effect: flip enabled=False so next reload
|
||||
# skips this sub.
|
||||
for s in self.subs:
|
||||
if s["uuid"] == uuid:
|
||||
s["enabled"] = False
|
||||
s["auto_disabled_at"] = ts
|
||||
|
||||
|
||||
def test_patterns_for_decodes_json():
|
||||
assert _patterns_for(
|
||||
{"topic_patterns": json.dumps(["attacker.>", "decky.*.state"])}
|
||||
) == ["attacker.>", "decky.*.state"]
|
||||
|
||||
|
||||
def test_patterns_for_bad_json_returns_empty():
|
||||
assert _patterns_for({"topic_patterns": "not-json"}) == []
|
||||
|
||||
|
||||
def test_union_patterns_dedupes_across_subs():
|
||||
s1 = _sub("u1", "w1", ["attacker.>", "system.>"])
|
||||
s2 = _sub("u2", "w2", ["system.>", "decky.*.state"])
|
||||
assert _union_patterns([s1, s2]) == ["attacker.>", "system.>", "decky.*.state"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_dispatches_matching_event(fake_bus):
|
||||
"""A bus event matching a sub's pattern should produce an HTTP POST."""
|
||||
sub = _sub("u1", "w1", ["attacker.>"])
|
||||
repo = _FakeRepo([sub])
|
||||
captured: list[httpx.Request] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(request)
|
||||
return httpx.Response(200)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
with patch("decnet.webhook.worker.get_bus", return_value=fake_bus):
|
||||
task = asyncio.create_task(
|
||||
webhook_worker(repo, reload_interval=0.5, http_client=client)
|
||||
)
|
||||
# Give the worker a moment to subscribe.
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
await fake_bus.publish(
|
||||
"attacker.observed",
|
||||
{"ip": "1.2.3.4"},
|
||||
event_type="first_sighting",
|
||||
)
|
||||
# Poll briefly for delivery.
|
||||
for _ in range(40):
|
||||
if captured:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
assert len(captured) == 1
|
||||
req = captured[0]
|
||||
assert req.headers.get("X-DECNET-Signature", "").startswith("sha256=")
|
||||
assert "attacker.observed" in req.headers.get("X-DECNET-Event-Topic", "")
|
||||
assert repo.success_calls == ["u1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_ignores_non_matching_event(fake_bus):
|
||||
"""An event outside the sub's pattern must not trigger a POST."""
|
||||
sub = _sub("u1", "w1", ["attacker.>"])
|
||||
repo = _FakeRepo([sub])
|
||||
captured: list[httpx.Request] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(request)
|
||||
return httpx.Response(200)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
with patch("decnet.webhook.worker.get_bus", return_value=fake_bus):
|
||||
task = asyncio.create_task(
|
||||
webhook_worker(repo, reload_interval=0.5, http_client=client)
|
||||
)
|
||||
await asyncio.sleep(0.2)
|
||||
# system.log is NOT in attacker.>
|
||||
await fake_bus.publish(
|
||||
"system.log",
|
||||
{"m": "irrelevant"},
|
||||
event_type="batch_committed",
|
||||
)
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
assert captured == []
|
||||
assert repo.success_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_records_failure_on_5xx(fake_bus, monkeypatch):
|
||||
sub = _sub("u1", "w1", ["attacker.>"])
|
||||
repo = _FakeRepo([sub])
|
||||
|
||||
# Collapse the retry schedule to zero-delay so the test doesn't wait
|
||||
# the real 1+2+4s backoff sequence.
|
||||
monkeypatch.setattr(
|
||||
"decnet.webhook.client._DEFAULT_RETRY_SCHEDULE", (0.0, 0.0, 0.0)
|
||||
)
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(503)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
with patch("decnet.webhook.worker.get_bus", return_value=fake_bus):
|
||||
task = asyncio.create_task(
|
||||
webhook_worker(repo, reload_interval=0.5, http_client=client)
|
||||
)
|
||||
await asyncio.sleep(0.2)
|
||||
await fake_bus.publish(
|
||||
"attacker.observed", {"ip": "1.2.3.4"}, event_type="x"
|
||||
)
|
||||
for _ in range(80):
|
||||
if repo.failure_calls:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
assert repo.failure_calls
|
||||
assert repo.failure_calls[0][0] == "u1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_reloads_on_subscriptions_changed_signal(fake_bus):
|
||||
"""A newly-enabled sub that arrives via the reload-signal path must
|
||||
start receiving events without a worker restart."""
|
||||
subs = [_sub("u1", "w1", ["attacker.>"])]
|
||||
repo = _FakeRepo(subs)
|
||||
captured: list[httpx.Request] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(request)
|
||||
return httpx.Response(200)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
with patch("decnet.webhook.worker.get_bus", return_value=fake_bus):
|
||||
task = asyncio.create_task(
|
||||
webhook_worker(repo, reload_interval=60.0, http_client=client)
|
||||
)
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
# Hot-add a sub that wants system.>
|
||||
subs.append(_sub("u2", "w2", ["system.>"]))
|
||||
await fake_bus.publish(
|
||||
_topics.WEBHOOK_SUBSCRIPTIONS_CHANGED, {}, event_type="changed"
|
||||
)
|
||||
await asyncio.sleep(0.3) # let worker reload + resubscribe
|
||||
|
||||
await fake_bus.publish(
|
||||
"system.log", {"m": "hi"}, event_type="batch_committed"
|
||||
)
|
||||
for _ in range(80):
|
||||
if captured:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
# The new sub (u2) should have received the system.log event.
|
||||
assert len(captured) == 1
|
||||
assert "system.log" in captured[0].headers.get("X-DECNET-Event-Topic", "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_trips_circuit_after_threshold(fake_bus, monkeypatch):
|
||||
"""After N consecutive failures the worker auto-disables the sub."""
|
||||
sub = _sub("u1", "w1", ["attacker.>"])
|
||||
repo = _FakeRepo([sub])
|
||||
|
||||
# Tight threshold + zero-delay retry so the test finishes fast.
|
||||
monkeypatch.setattr("decnet.webhook.worker._CIRCUIT_THRESHOLD", 2)
|
||||
monkeypatch.setattr(
|
||||
"decnet.webhook.client._DEFAULT_RETRY_SCHEDULE", (0.0, 0.0, 0.0)
|
||||
)
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(503)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
with patch("decnet.webhook.worker.get_bus", return_value=fake_bus):
|
||||
task = asyncio.create_task(
|
||||
webhook_worker(repo, reload_interval=0.5, http_client=client)
|
||||
)
|
||||
await asyncio.sleep(0.2)
|
||||
# Publish two events — each fails N retries, each increments
|
||||
# consecutive_failures by 1. Second trip should fire.
|
||||
await fake_bus.publish("attacker.observed", {}, event_type="x")
|
||||
await fake_bus.publish("attacker.observed", {}, event_type="x")
|
||||
for _ in range(120):
|
||||
if repo.trip_calls:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
assert repo.trip_calls, "expected circuit to trip after threshold"
|
||||
assert repo.trip_calls[0] == "u1"
|
||||
# The sub was flipped to enabled=False by trip_webhook_circuit.
|
||||
assert sub["enabled"] is False
|
||||
assert sub["auto_disabled_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_self_heals_when_bus_starts_late(fake_bus):
|
||||
"""Bus down at startup → worker parks in a retry loop. Once the bus
|
||||
comes up on the next attempt, the worker transitions to dispatch
|
||||
mode and delivers events normally.
|
||||
"""
|
||||
sub = _sub("u1", "w1", ["attacker.>"])
|
||||
repo = _FakeRepo([sub])
|
||||
captured: list[httpx.Request] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(request)
|
||||
return httpx.Response(200)
|
||||
|
||||
# First call: raise (bus unavailable). Second call: hand out the
|
||||
# FakeBus. The worker's retry loop should bridge the gap.
|
||||
calls = {"n": 0}
|
||||
|
||||
def flaky_get_bus(*args, **kwargs):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
raise ConnectionError("bus not ready yet")
|
||||
return fake_bus
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
with patch("decnet.webhook.worker.get_bus", side_effect=flaky_get_bus):
|
||||
task = asyncio.create_task(
|
||||
webhook_worker(
|
||||
repo,
|
||||
reload_interval=0.5,
|
||||
http_client=client,
|
||||
bus_reconnect_secs=0.3,
|
||||
)
|
||||
)
|
||||
# First attempt fails, worker waits bus_reconnect_secs, second
|
||||
# attempt succeeds. Give it a generous window.
|
||||
await asyncio.sleep(0.8)
|
||||
|
||||
await fake_bus.publish(
|
||||
"attacker.observed", {"ip": "1.2.3.4"}, event_type="x"
|
||||
)
|
||||
for _ in range(60):
|
||||
if captured:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
assert calls["n"] >= 2, "expected at least one retry after initial failure"
|
||||
assert len(captured) == 1, "expected delivery once bus came up"
|
||||
|
||||
Reference in New Issue
Block a user