Replaces LICENSE (GPLv3 -> AGPLv3) and prepends `SPDX-License-Identifier: AGPL-3.0-or-later` to every source file across decnet/, decnet_web/, tests/, scripts/, and tools/. Rationale: closes the GPLv3 ASP loophole so any party operating a modified DECNET as a network service must offer their modified source. Personal copyright (Samuel Paschuan) + inbound=outbound contributions make a future unilateral relicense infeasible. - LICENSE: full AGPL-3.0 text (gnu.org/licenses/agpl-3.0.txt) - COPYRIGHT: project copyright notice - tools/add_spdx_headers.py: idempotent header injector (shebang- and PEP 263-aware) Touches 1565 source files (.py, .ts, .tsx, .js, .jsx, .css, .sh). No behavior change; comments only.
147 lines
4.6 KiB
Python
147 lines
4.6 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
"""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
|