Files
optimclaw/tests/e2e/scenarios/test_webhook.py
T
Henry ParkandGitHub 026beb00f2 fix: cover staging CI all-features and routine batch regressions (#1256)
* fix staging CI coverage regressions

* ci: cover all e2e scenarios in staging

* ci: restrict staging PR checks and fix webhook assertions

* ci: keep code style checks on PRs

* ci: preserve e2e PR coverage

* test: stabilize staging e2e coverage

* fix: propagate postgres tls builder errors
2026-03-16 15:06:31 -07:00

204 lines
6.7 KiB
Python

"""HTTP webhook authentication tests with HMAC-SHA256 signatures."""
import hashlib
import hmac
import json
import httpx
import pytest
from helpers import HTTP_WEBHOOK_SECRET
def compute_signature(secret: str, body: bytes) -> str:
"""Compute X-Hub-Signature-256 HMAC-SHA256 signature."""
mac = hmac.new(secret.encode(), body, hashlib.sha256)
return f"sha256={mac.hexdigest()}"
async def _post_webhook(
base_url: str,
body_data: dict,
*,
signature: str | None = None,
content_type: str = "application/json",
) -> httpx.Response:
"""Send a raw webhook request with optional signature."""
body_bytes = json.dumps(body_data).encode()
headers = {"Content-Type": content_type}
if signature is not None:
headers["X-Hub-Signature-256"] = signature
async with httpx.AsyncClient() as client:
return await client.post(
f"{base_url}/webhook",
content=body_bytes,
headers=headers,
)
@pytest.mark.asyncio
async def test_webhook_requires_http_webhook_secret_configured(
http_channel_server_without_secret,
):
"""Webhook fails closed when no secret is configured."""
response = await _post_webhook(
http_channel_server_without_secret,
{"content": "test message"},
)
assert response.status_code == 503
data = response.json()
assert data["status"] == "error"
assert "Webhook authentication not configured" in data.get("response", "")
@pytest.mark.asyncio
async def test_webhook_hmac_signature_valid(http_channel_server):
"""Valid X-Hub-Signature-256 HMAC signature is accepted."""
body = {"content": "hello from webhook"}
signature = compute_signature(HTTP_WEBHOOK_SECRET, json.dumps(body).encode())
response = await _post_webhook(http_channel_server, body, signature=signature)
assert response.status_code == 200, (
f"Expected 200, got {response.status_code}: {response.text}"
)
data = response.json()
assert data["status"] == "accepted"
@pytest.mark.asyncio
async def test_webhook_invalid_hmac_signature_rejected(http_channel_server):
"""Invalid X-Hub-Signature-256 signature is rejected with 401."""
response = await _post_webhook(
http_channel_server,
{"content": "hello"},
signature="sha256=0000000000000000000000000000000000000000000000000000000000000000",
)
assert response.status_code == 401
data = response.json()
assert data["status"] == "error"
assert "Invalid webhook signature" in data.get("response", "")
@pytest.mark.asyncio
async def test_webhook_wrong_secret_rejected(http_channel_server):
"""Signature computed with wrong secret is rejected."""
body = {"content": "hello"}
signature = compute_signature("wrong-secret", json.dumps(body).encode())
response = await _post_webhook(http_channel_server, body, signature=signature)
assert response.status_code == 401
assert response.json()["status"] == "error"
@pytest.mark.asyncio
async def test_webhook_missing_signature_header_rejected(http_channel_server):
"""Missing X-Hub-Signature-256 header is rejected when no body secret is provided."""
response = await _post_webhook(http_channel_server, {"content": "hello"})
assert response.status_code == 401
data = response.json()
assert "Webhook authentication required" in data.get("response", "")
assert "X-Hub-Signature-256" in data.get("response", "")
@pytest.mark.asyncio
async def test_webhook_deprecated_body_secret_still_works(http_channel_server):
"""Deprecated body secret support still accepts old clients."""
response = await _post_webhook(
http_channel_server,
{"content": "hello", "secret": HTTP_WEBHOOK_SECRET},
)
assert response.status_code == 200, (
f"Expected 200, got {response.status_code}: {response.text}"
)
assert response.json()["status"] == "accepted"
@pytest.mark.asyncio
async def test_webhook_header_takes_precedence_over_body_secret(http_channel_server):
"""Header signature wins when both header and body secret are provided."""
body = {"content": "hello", "secret": "wrong-secret-in-body"}
signature = compute_signature(HTTP_WEBHOOK_SECRET, json.dumps(body).encode())
response = await _post_webhook(http_channel_server, body, signature=signature)
assert response.status_code == 200
assert response.json()["status"] == "accepted"
@pytest.mark.asyncio
async def test_webhook_case_insensitive_header_lookup(http_channel_server):
"""HTTP headers are treated case-insensitively."""
body = {"content": "hello"}
body_bytes = json.dumps(body).encode()
signature = compute_signature(HTTP_WEBHOOK_SECRET, body_bytes)
async with httpx.AsyncClient() as client:
response = await client.post(
f"{http_channel_server}/webhook",
content=body_bytes,
headers={
"Content-Type": "application/json",
"x-hub-signature-256": signature,
},
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_webhook_wrong_content_type_rejected(http_channel_server):
"""Webhook only accepts application/json Content-Type."""
body = {"content": "hello"}
signature = compute_signature(HTTP_WEBHOOK_SECRET, json.dumps(body).encode())
response = await _post_webhook(
http_channel_server,
body,
signature=signature,
content_type="text/plain",
)
assert response.status_code == 415
assert "application/json" in response.json().get("response", "")
@pytest.mark.asyncio
async def test_webhook_invalid_json_rejected(http_channel_server):
"""Invalid JSON in body is rejected."""
body_bytes = b"not valid json"
signature = compute_signature(HTTP_WEBHOOK_SECRET, body_bytes)
async with httpx.AsyncClient() as client:
response = await client.post(
f"{http_channel_server}/webhook",
content=body_bytes,
headers={
"Content-Type": "application/json",
"X-Hub-Signature-256": signature,
},
)
assert response.status_code in (400, 401)
@pytest.mark.asyncio
async def test_webhook_message_queued_for_processing(http_channel_server):
"""Accepted webhook requests return a real message id."""
body = {"content": "webhook test message 12345"}
signature = compute_signature(HTTP_WEBHOOK_SECRET, json.dumps(body).encode())
response = await _post_webhook(http_channel_server, body, signature=signature)
assert response.status_code == 200
data = response.json()
assert data["status"] == "accepted"
assert "message_id" in data
assert data["message_id"] != "00000000-0000-0000-0000-000000000000"