Files
composiohq--composio/python/tests/test_verify_webhook_integration.py
T
2026-07-13 12:38:34 +08:00

312 lines
11 KiB
Python

"""Integration tests for verify_webhook using fixtures.
These tests use the same fixtures as the TypeScript SDK to ensure
cross-SDK compatibility and algorithm correctness.
"""
import base64
import hashlib
import hmac
import json
import pytest
from composio import exceptions
from composio.core.models.triggers import Triggers, WebhookVersion
from tests.conftest import compute_signature, load_fixtures, load_golden_signatures
class TestVerifyWebhook:
"""Tests for webhook verification against fixture data."""
@pytest.mark.parametrize(
"fixture",
load_fixtures(),
ids=lambda f: f["description"],
)
def test_verify_fixture(self, triggers: Triggers, fixture: dict) -> None:
"""Verify each fixture passes verification."""
result = triggers.verify_webhook(
id=fixture["headers"]["webhook-id"],
payload=fixture["payload"],
signature=fixture["headers"]["webhook-signature"],
timestamp=fixture["headers"]["webhook-timestamp"],
secret=fixture["testSecret"],
tolerance=0,
)
assert result["version"].value == fixture["expectedResult"]["version"]
expected_slug = fixture["expectedResult"]["triggerSlug"]
assert result["payload"]["trigger_slug"] == expected_slug
if "userId" in fixture["expectedResult"]:
assert result["payload"]["user_id"] == fixture["expectedResult"]["userId"]
if "connectedAccountId" in fixture["expectedResult"]:
assert (
result["payload"]["metadata"]["connected_account"]["id"]
== fixture["expectedResult"]["connectedAccountId"]
)
if "triggerId" in fixture["expectedResult"]:
assert result["payload"]["id"] == fixture["expectedResult"]["triggerId"]
@pytest.mark.parametrize(
"version,expected_enum,expected_keys",
[
("V3", WebhookVersion.V3, ["type", "metadata"]),
("V2", WebhookVersion.V2, ["type", "data"]),
("V1", WebhookVersion.V1, ["trigger_name", "connection_id"]),
],
)
def test_detects_version(
self,
triggers: Triggers,
webhook_fixtures: list[dict],
version: str,
expected_enum: WebhookVersion,
expected_keys: list[str],
) -> None:
"""Test version detection for V1, V2, V3."""
fixture = next(
(f for f in webhook_fixtures if f["expectedResult"]["version"] == version),
None,
)
if fixture is None:
pytest.skip(f"No {version} fixture found")
result = triggers.verify_webhook(
id=fixture["headers"]["webhook-id"],
payload=fixture["payload"],
signature=fixture["headers"]["webhook-signature"],
timestamp=fixture["headers"]["webhook-timestamp"],
secret=fixture["testSecret"],
tolerance=0,
)
assert result["version"] == expected_enum
for key in expected_keys:
assert key in result["raw_payload"]
class TestGoldenSignatures:
"""Contract tests for signature algorithm."""
@pytest.mark.parametrize(
"test_case",
load_golden_signatures()["testCases"],
ids=lambda tc: tc["name"],
)
def test_produces_identical_signature(self, test_case: dict) -> None:
"""Verify signature algorithm produces identical output."""
computed = compute_signature(
test_case["id"],
test_case["timestamp"],
test_case["payload"],
test_case["secret"],
)
assert computed == test_case["expectedSignature"]
def test_algorithm_matches_documented_format(self, golden_signatures: dict) -> None:
"""Verify algorithm documentation."""
assert golden_signatures["algorithm"] == "HMAC-SHA256"
assert (
golden_signatures["format"]
== "v1,base64(HMAC-SHA256(id.timestamp.payload, secret))"
)
class TestSignatureValidation:
"""Tests for signature algorithm validation."""
def test_computes_signature_using_id_timestamp_payload_format(
self, triggers: Triggers, webhook_fixtures: list[dict]
) -> None:
"""Verify signature is computed using id.timestamp.payload format."""
fixture = webhook_fixtures[0]
expected_signature = compute_signature(
fixture["headers"]["webhook-id"],
fixture["headers"]["webhook-timestamp"],
fixture["payload"],
fixture["testSecret"],
)
assert expected_signature == fixture["headers"]["webhook-signature"]
def test_rejects_signature_computed_with_payload_only(
self, triggers: Triggers, webhook_fixtures: list[dict]
) -> None:
"""Reject signature computed with only payload (wrong format)."""
fixture = webhook_fixtures[0]
wrong_signature = "v1," + base64.b64encode(
hmac.new(
key=fixture["testSecret"].encode("utf-8"),
msg=fixture["payload"].encode("utf-8"),
digestmod=hashlib.sha256,
).digest()
).decode("utf-8")
with pytest.raises(exceptions.WebhookSignatureVerificationError):
triggers.verify_webhook(
id=fixture["headers"]["webhook-id"],
payload=fixture["payload"],
signature=wrong_signature,
timestamp=fixture["headers"]["webhook-timestamp"],
secret=fixture["testSecret"],
tolerance=0,
)
def test_rejects_signature_missing_id(
self, triggers: Triggers, webhook_fixtures: list[dict]
) -> None:
"""Reject signature computed with timestamp.payload (missing id)."""
fixture = webhook_fixtures[0]
to_sign = f"{fixture['headers']['webhook-timestamp']}.{fixture['payload']}"
wrong_signature = "v1," + base64.b64encode(
hmac.new(
key=fixture["testSecret"].encode("utf-8"),
msg=to_sign.encode("utf-8"),
digestmod=hashlib.sha256,
).digest()
).decode("utf-8")
with pytest.raises(exceptions.WebhookSignatureVerificationError):
triggers.verify_webhook(
id=fixture["headers"]["webhook-id"],
payload=fixture["payload"],
signature=wrong_signature,
timestamp=fixture["headers"]["webhook-timestamp"],
secret=fixture["testSecret"],
tolerance=0,
)
class TestPayloadStructure:
"""Tests for payload structure validation."""
def test_preserves_exact_json_structure(
self, triggers: Triggers, webhook_fixtures: list[dict]
) -> None:
"""Verify raw payload matches fixture exactly."""
v3_fixture = next(
(f for f in webhook_fixtures if f["expectedResult"]["version"] == "V3"),
None,
)
if v3_fixture is None:
pytest.skip("No V3 fixture found")
result = triggers.verify_webhook(
id=v3_fixture["headers"]["webhook-id"],
payload=v3_fixture["payload"],
signature=v3_fixture["headers"]["webhook-signature"],
timestamp=v3_fixture["headers"]["webhook-timestamp"],
secret=v3_fixture["testSecret"],
tolerance=0,
)
parsed_payload = json.loads(v3_fixture["payload"])
assert result["raw_payload"] == parsed_payload
def test_normalizes_v3_payload(
self, triggers: Triggers, webhook_fixtures: list[dict]
) -> None:
"""Verify V3 payload is normalized correctly."""
v3_fixture = next(
(f for f in webhook_fixtures if f["expectedResult"]["version"] == "V3"),
None,
)
if v3_fixture is None:
pytest.skip("No V3 fixture found")
result = triggers.verify_webhook(
id=v3_fixture["headers"]["webhook-id"],
payload=v3_fixture["payload"],
signature=v3_fixture["headers"]["webhook-signature"],
timestamp=v3_fixture["headers"]["webhook-timestamp"],
secret=v3_fixture["testSecret"],
tolerance=0,
)
payload = result["payload"]
assert "id" in payload
assert "uuid" in payload
assert "trigger_slug" in payload
assert "toolkit_slug" in payload
assert "user_id" in payload
assert "payload" in payload
assert "metadata" in payload
assert "connected_account" in payload["metadata"]
class TestWhitespaceSensitivity:
"""Tests for whitespace sensitivity in payload verification."""
def test_fails_verification_if_whitespace_changes(
self, triggers: Triggers, webhook_fixtures: list[dict]
) -> None:
"""Verify whitespace changes cause verification failure."""
fixture = webhook_fixtures[0]
modified_payload = fixture["payload"].replace("{", "{ ")
expected_errors = (
exceptions.WebhookSignatureVerificationError,
exceptions.WebhookPayloadError,
)
with pytest.raises(expected_errors):
triggers.verify_webhook(
id=fixture["headers"]["webhook-id"],
payload=modified_payload,
signature=fixture["headers"]["webhook-signature"],
timestamp=fixture["headers"]["webhook-timestamp"],
secret=fixture["testSecret"],
tolerance=0,
)
def test_fails_verification_if_payload_reserialized(
self, triggers: Triggers, webhook_fixtures: list[dict]
) -> None:
"""Verify re-serialization may cause verification failure."""
fixture = webhook_fixtures[0]
reserialized = json.dumps(json.loads(fixture["payload"]))
# Only test if re-serialization actually changed the payload
if reserialized != fixture["payload"]:
expected_errors = (
exceptions.WebhookSignatureVerificationError,
exceptions.WebhookPayloadError,
)
with pytest.raises(expected_errors):
triggers.verify_webhook(
id=fixture["headers"]["webhook-id"],
payload=reserialized,
signature=fixture["headers"]["webhook-signature"],
timestamp=fixture["headers"]["webhook-timestamp"],
secret=fixture["testSecret"],
tolerance=0,
)
class TestFixtureConsistency:
"""Tests for fixture consistency."""
def test_all_fixtures_use_same_test_secret(
self, webhook_fixtures: list[dict]
) -> None:
"""Verify all fixtures use the same test secret."""
secrets = {f["testSecret"] for f in webhook_fixtures}
assert len(secrets) == 1
assert "test-webhook-secret-for-fixtures" in secrets
def test_all_fixtures_have_unique_webhook_ids(
self, webhook_fixtures: list[dict]
) -> None:
"""Verify all fixtures have unique webhook IDs."""
ids = [f["headers"]["webhook-id"] for f in webhook_fixtures]
assert len(set(ids)) == len(webhook_fixtures)
def test_fixtures_cover_all_supported_versions(
self, webhook_fixtures: list[dict]
) -> None:
"""Verify fixtures cover V1, V2, and V3."""
versions = {f["expectedResult"]["version"] for f in webhook_fixtures}
assert {"V1", "V2", "V3"} <= versions