"""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