1587 lines
59 KiB
Python
1587 lines
59 KiB
Python
"""Tests for Triggers class."""
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import Mock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
from composio_client import NotFoundError, omit
|
|
|
|
from composio import exceptions
|
|
from composio.core.models.triggers import (
|
|
_MAX_LOGGED_FRAME_CHARS,
|
|
Triggers,
|
|
TriggerSubscription,
|
|
WebhookVersion,
|
|
_truncate_frame,
|
|
)
|
|
|
|
|
|
class TestTriggers:
|
|
"""Test cases for Triggers class."""
|
|
|
|
@pytest.fixture
|
|
def mock_client(self):
|
|
"""Create a mock HTTP client."""
|
|
client = Mock()
|
|
client.triggers_types = Mock()
|
|
client.trigger_instances = Mock()
|
|
client.trigger_instances.manage = Mock()
|
|
client.connected_accounts = Mock()
|
|
return client
|
|
|
|
@pytest.fixture
|
|
def triggers(self, mock_client):
|
|
"""Create a Triggers instance with default toolkit versions."""
|
|
return Triggers(client=mock_client)
|
|
|
|
@pytest.fixture
|
|
def triggers_with_versions(self, mock_client):
|
|
"""Create a Triggers instance with custom toolkit versions."""
|
|
return Triggers(
|
|
client=mock_client,
|
|
toolkit_versions={"github": "12082025_00", "slack": "10082025_01"},
|
|
)
|
|
|
|
@pytest.fixture
|
|
def mock_trigger_type(self):
|
|
"""Mock trigger type response."""
|
|
mock_type = Mock()
|
|
mock_type.slug = "GITHUB_COMMIT_EVENT"
|
|
mock_type.name = "GitHub Commit Event"
|
|
mock_type.description = "Triggered when a commit is pushed"
|
|
mock_type.toolkit = Mock()
|
|
mock_type.toolkit.slug = "github"
|
|
mock_type.toolkit.name = "GitHub"
|
|
return mock_type
|
|
|
|
@pytest.fixture
|
|
def mock_trigger_instances(self):
|
|
"""Mock trigger instances list response."""
|
|
mock_response = Mock()
|
|
mock_response.items = [
|
|
Mock(
|
|
id="trigger-1",
|
|
connected_account_id="conn-123",
|
|
disabled_at=None,
|
|
state={"lastRun": "2024-01-01T00:00:00Z"},
|
|
trigger_config={"webhook_url": "https://example.com/webhook"},
|
|
trigger_name="GITHUB_COMMIT_EVENT",
|
|
updated_at="2024-01-01T00:00:00Z",
|
|
trigger_data='{"event":"push"}',
|
|
),
|
|
]
|
|
mock_response.next_cursor = None
|
|
mock_response.total_pages = 1
|
|
return mock_response
|
|
|
|
def test_init_with_default_versions(self, mock_client):
|
|
"""Test Triggers initialization with default toolkit versions."""
|
|
triggers = Triggers(client=mock_client)
|
|
|
|
assert triggers._client == mock_client
|
|
assert triggers._toolkit_versions is None
|
|
assert callable(triggers.list_enum)
|
|
assert callable(triggers.delete)
|
|
assert callable(triggers.enable)
|
|
assert callable(triggers.disable)
|
|
|
|
def test_init_with_custom_versions(self, mock_client):
|
|
"""Test Triggers initialization with custom toolkit versions."""
|
|
custom_versions = {"github": "12082025_00", "slack": "10082025_01"}
|
|
triggers = Triggers(client=mock_client, toolkit_versions=custom_versions)
|
|
|
|
assert triggers._toolkit_versions == custom_versions
|
|
|
|
def test_set_webhook_subscription_creates_when_none_exists(
|
|
self, triggers, mock_client
|
|
):
|
|
"""Test set_webhook_subscription creates a subscription when none exists."""
|
|
webhook_url = "https://example.com/webhooks/composio"
|
|
raw_subscription = {
|
|
"id": "sub_123",
|
|
"webhook_url": webhook_url,
|
|
"version": "V3",
|
|
"enabled_events": ["composio.trigger.message"],
|
|
}
|
|
mock_client.get.return_value = {"items": []}
|
|
mock_client.post.return_value = raw_subscription
|
|
|
|
result = triggers.set_webhook_subscription(webhook_url=webhook_url)
|
|
|
|
mock_client.get.assert_called_once_with(
|
|
"/api/v3.1/webhook_subscriptions",
|
|
cast_to=object,
|
|
options={"params": {"limit": 1}},
|
|
)
|
|
mock_client.post.assert_called_once_with(
|
|
"/api/v3.1/webhook_subscriptions",
|
|
cast_to=object,
|
|
body={
|
|
"webhook_url": webhook_url,
|
|
"enabled_events": ["composio.trigger.message"],
|
|
"version": "V3",
|
|
},
|
|
)
|
|
mock_client.patch.assert_not_called()
|
|
assert result == raw_subscription
|
|
|
|
def test_set_webhook_subscription_updates_first_existing(
|
|
self, triggers, mock_client
|
|
):
|
|
"""Test set_webhook_subscription updates the first subscription when one exists."""
|
|
webhook_url = "https://example.com/webhooks/composio"
|
|
raw_subscription = {
|
|
"id": "sub_123",
|
|
"webhook_url": webhook_url,
|
|
"version": "V3",
|
|
"enabled_events": [
|
|
"composio.trigger.message",
|
|
"composio.connected_account.expired",
|
|
],
|
|
}
|
|
mock_client.get.return_value = {"items": [{"id": "sub_123"}]}
|
|
mock_client.patch.return_value = raw_subscription
|
|
|
|
result = triggers.set_webhook_subscription(
|
|
webhook_url=webhook_url,
|
|
enabled_events=[
|
|
"composio.trigger.message",
|
|
"composio.connected_account.expired",
|
|
],
|
|
version="V3",
|
|
)
|
|
|
|
mock_client.patch.assert_called_once_with(
|
|
"/api/v3.1/webhook_subscriptions/sub_123",
|
|
cast_to=object,
|
|
body={
|
|
"webhook_url": webhook_url,
|
|
"enabled_events": [
|
|
"composio.trigger.message",
|
|
"composio.connected_account.expired",
|
|
],
|
|
"version": "V3",
|
|
},
|
|
)
|
|
mock_client.post.assert_not_called()
|
|
assert result == raw_subscription
|
|
|
|
def test_set_webhook_subscription_rejects_empty_events(self, triggers):
|
|
"""Test set_webhook_subscription rejects empty enabled_events."""
|
|
with pytest.raises(exceptions.ValidationError):
|
|
triggers.set_webhook_subscription(
|
|
webhook_url="https://example.com/webhooks/composio",
|
|
enabled_events=[],
|
|
)
|
|
|
|
def test_get_type_with_default_versions(
|
|
self, triggers, mock_client, mock_trigger_type
|
|
):
|
|
"""Test get_type with default toolkit versions."""
|
|
mock_client.triggers_types.retrieve.return_value = mock_trigger_type
|
|
|
|
result = triggers.get_type("GITHUB_COMMIT_EVENT")
|
|
|
|
# When toolkit_versions is None, it should be converted to omit
|
|
call_kwargs = mock_client.triggers_types.retrieve.call_args.kwargs
|
|
assert call_kwargs["slug"] == "GITHUB_COMMIT_EVENT"
|
|
assert call_kwargs["toolkit_versions"] is omit
|
|
assert result == mock_trigger_type
|
|
|
|
def test_get_type_with_custom_versions(
|
|
self, triggers_with_versions, mock_client, mock_trigger_type
|
|
):
|
|
"""Test get_type with custom toolkit versions."""
|
|
mock_client.triggers_types.retrieve.return_value = mock_trigger_type
|
|
custom_versions = {"github": "12082025_00", "slack": "10082025_01"}
|
|
|
|
result = triggers_with_versions.get_type("GITHUB_COMMIT_EVENT")
|
|
|
|
mock_client.triggers_types.retrieve.assert_called_once_with(
|
|
slug="GITHUB_COMMIT_EVENT",
|
|
toolkit_versions=custom_versions,
|
|
)
|
|
assert result == mock_trigger_type
|
|
|
|
def test_list_active_without_filters(
|
|
self, triggers, mock_client, mock_trigger_instances
|
|
):
|
|
"""Test list_active without any filters."""
|
|
mock_client.trigger_instances.list_active.return_value = mock_trigger_instances
|
|
|
|
result = triggers.list_active()
|
|
|
|
mock_client.trigger_instances.list_active.assert_called_once()
|
|
assert result == mock_trigger_instances
|
|
|
|
def test_list_active_with_filters(
|
|
self, triggers, mock_client, mock_trigger_instances
|
|
):
|
|
"""Test list_active with filters."""
|
|
mock_client.trigger_instances.list_active.return_value = mock_trigger_instances
|
|
|
|
result = triggers.list_active(
|
|
trigger_ids=["trigger-1"],
|
|
trigger_names=["GITHUB_COMMIT_EVENT"],
|
|
auth_config_ids=["auth-123"],
|
|
connected_account_ids=["conn-123"],
|
|
show_disabled=False,
|
|
limit=10,
|
|
cursor="cursor-abc",
|
|
)
|
|
|
|
mock_client.trigger_instances.list_active.assert_called_once()
|
|
call_kwargs = mock_client.trigger_instances.list_active.call_args.kwargs
|
|
assert call_kwargs["query_trigger_ids_1"] == ["trigger-1"]
|
|
assert call_kwargs["query_trigger_names_1"] == ["GITHUB_COMMIT_EVENT"]
|
|
assert call_kwargs["query_auth_config_ids_1"] == ["auth-123"]
|
|
assert call_kwargs["query_connected_account_ids_1"] == ["conn-123"]
|
|
assert call_kwargs["query_show_disabled_1"] is False
|
|
assert call_kwargs["limit"] == 10
|
|
assert call_kwargs["cursor"] == "cursor-abc"
|
|
assert result == mock_trigger_instances
|
|
|
|
def test_list_trigger_types_without_filters(self, triggers, mock_client):
|
|
"""Test list trigger types without filters."""
|
|
mock_response = Mock()
|
|
mock_client.triggers_types.list.return_value = mock_response
|
|
|
|
result = triggers.list()
|
|
|
|
mock_client.triggers_types.list.assert_called_once()
|
|
assert result == mock_response
|
|
|
|
def test_list_trigger_types_with_filters(self, triggers_with_versions, mock_client):
|
|
"""Test list trigger types with filters and custom versions."""
|
|
mock_response = Mock()
|
|
mock_client.triggers_types.list.return_value = mock_response
|
|
custom_versions = {"github": "12082025_00", "slack": "10082025_01"}
|
|
|
|
result = triggers_with_versions.list(
|
|
cursor="cursor-123",
|
|
limit=10,
|
|
toolkit_slugs=["github", "slack"],
|
|
)
|
|
|
|
mock_client.triggers_types.list.assert_called_once()
|
|
call_kwargs = mock_client.triggers_types.list.call_args.kwargs
|
|
assert call_kwargs["cursor"] == "cursor-123"
|
|
assert call_kwargs["limit"] == 10
|
|
assert call_kwargs["toolkit_slugs"] == ["github", "slack"]
|
|
assert call_kwargs["toolkit_versions"] == custom_versions
|
|
assert result == mock_response
|
|
|
|
def test_create_with_connected_account_id(self, triggers, mock_client):
|
|
"""Test create trigger with connected_account_id."""
|
|
mock_response = Mock()
|
|
mock_response.trigger_id = "trigger-123"
|
|
mock_client.trigger_instances.upsert.return_value = mock_response
|
|
|
|
result = triggers.create(
|
|
slug="GITHUB_COMMIT_EVENT",
|
|
connected_account_id="conn-123",
|
|
trigger_config={"webhook_url": "https://example.com/webhook"},
|
|
)
|
|
|
|
# No extra lookup when an explicit connection is pinned.
|
|
mock_client.connected_accounts.list.assert_not_called()
|
|
mock_client.trigger_instances.upsert.assert_called_once()
|
|
call_kwargs = mock_client.trigger_instances.upsert.call_args.kwargs
|
|
assert call_kwargs["slug"] == "GITHUB_COMMIT_EVENT"
|
|
assert call_kwargs["connected_account_id"] == "conn-123"
|
|
assert call_kwargs["body_trigger_config_1"] == {
|
|
"webhook_url": "https://example.com/webhook"
|
|
}
|
|
assert call_kwargs["toolkit_versions"] is None
|
|
# No user_id supplied → omitted from the request (native kwarg, not extra_body).
|
|
assert call_kwargs["user_id"] is omit
|
|
assert result == mock_response
|
|
|
|
def test_create_with_user_id(self, triggers, mock_client):
|
|
"""Test create trigger with user_id only.
|
|
|
|
The backend resolves the connection from ``user_id``, so the SDK passes
|
|
it straight through and no longer lists connected accounts.
|
|
"""
|
|
mock_response = Mock()
|
|
mock_response.trigger_id = "trigger-123"
|
|
mock_client.trigger_instances.upsert.return_value = mock_response
|
|
|
|
result = triggers.create(
|
|
slug="GITHUB_COMMIT_EVENT",
|
|
user_id="user-123",
|
|
trigger_config={"webhook_url": "https://example.com/webhook"},
|
|
)
|
|
|
|
# The SDK no longer lists connected accounts to resolve the connection,
|
|
# but it still validates the slug up-front (parity with the TS SDK).
|
|
mock_client.connected_accounts.list.assert_not_called()
|
|
mock_client.triggers_types.retrieve.assert_called_once()
|
|
|
|
mock_client.trigger_instances.upsert.assert_called_once()
|
|
call_kwargs = mock_client.trigger_instances.upsert.call_args.kwargs
|
|
assert call_kwargs["slug"] == "GITHUB_COMMIT_EVENT"
|
|
# No explicit connection pinned → omitted; user_id is sent as a native kwarg.
|
|
assert call_kwargs["connected_account_id"] is omit
|
|
assert call_kwargs["user_id"] == "user-123"
|
|
assert call_kwargs["body_trigger_config_1"] == {
|
|
"webhook_url": "https://example.com/webhook"
|
|
}
|
|
assert call_kwargs["toolkit_versions"] is None
|
|
assert result == mock_response
|
|
|
|
def test_create_with_user_id_and_connected_account_id(self, triggers, mock_client):
|
|
"""Test create trigger with both user_id and a pinned connected_account_id.
|
|
|
|
When a connection is pinned and 2FA is enabled, the backend validates the
|
|
connection is owned by ``user_id``. Both values are forwarded natively
|
|
(no ``extra_body``). Mirrors the TS ``create`` test.
|
|
"""
|
|
mock_response = Mock()
|
|
mock_response.trigger_id = "trigger-123"
|
|
mock_client.trigger_instances.upsert.return_value = mock_response
|
|
|
|
result = triggers.create(
|
|
slug="GITHUB_COMMIT_EVENT",
|
|
user_id="user-123",
|
|
connected_account_id="conn-123",
|
|
trigger_config={"webhook_url": "https://example.com/webhook"},
|
|
)
|
|
|
|
mock_client.connected_accounts.list.assert_not_called()
|
|
mock_client.trigger_instances.upsert.assert_called_once()
|
|
call_kwargs = mock_client.trigger_instances.upsert.call_args.kwargs
|
|
assert call_kwargs["slug"] == "GITHUB_COMMIT_EVENT"
|
|
assert call_kwargs["connected_account_id"] == "conn-123"
|
|
assert call_kwargs["user_id"] == "user-123"
|
|
assert call_kwargs["body_trigger_config_1"] == {
|
|
"webhook_url": "https://example.com/webhook"
|
|
}
|
|
assert result == mock_response
|
|
|
|
def test_create_raises_trigger_type_not_found_for_unknown_slug(
|
|
self, triggers, mock_client
|
|
):
|
|
"""An unknown slug surfaces as TriggerTypeNotFound (parity with the TS SDK)."""
|
|
request = httpx.Request("GET", "https://backend.composio.dev")
|
|
response = httpx.Response(404, request=request)
|
|
mock_client.triggers_types.retrieve.side_effect = NotFoundError(
|
|
"not found", response=response, body=None
|
|
)
|
|
|
|
with pytest.raises(exceptions.TriggerTypeNotFound):
|
|
triggers.create(slug="UNKNOWN_TRIGGER", user_id="user-123")
|
|
|
|
mock_client.trigger_instances.upsert.assert_not_called()
|
|
|
|
def test_create_treats_blank_user_id_as_missing(self, triggers, mock_client):
|
|
"""A blank user_id is rejected like a missing one, before any request."""
|
|
with pytest.raises(exceptions.InvalidParams):
|
|
triggers.create(slug="GITHUB_COMMIT_EVENT", user_id=" ")
|
|
|
|
mock_client.triggers_types.retrieve.assert_not_called()
|
|
mock_client.trigger_instances.upsert.assert_not_called()
|
|
|
|
def test_create_with_custom_toolkit_versions(
|
|
self, triggers_with_versions, mock_client
|
|
):
|
|
"""Test create trigger with custom toolkit versions."""
|
|
mock_response = Mock()
|
|
mock_response.trigger_id = "trigger-123"
|
|
mock_client.trigger_instances.upsert.return_value = mock_response
|
|
custom_versions = {"github": "12082025_00", "slack": "10082025_01"}
|
|
|
|
result = triggers_with_versions.create(
|
|
slug="GITHUB_COMMIT_EVENT",
|
|
connected_account_id="conn-123",
|
|
trigger_config={"webhook_url": "https://example.com/webhook"},
|
|
)
|
|
|
|
mock_client.trigger_instances.upsert.assert_called_once()
|
|
call_kwargs = mock_client.trigger_instances.upsert.call_args.kwargs
|
|
assert call_kwargs["slug"] == "GITHUB_COMMIT_EVENT"
|
|
assert call_kwargs["connected_account_id"] == "conn-123"
|
|
assert call_kwargs["body_trigger_config_1"] == {
|
|
"webhook_url": "https://example.com/webhook"
|
|
}
|
|
assert call_kwargs["toolkit_versions"] == custom_versions
|
|
assert result == mock_response
|
|
|
|
def test_create_without_user_id_or_connected_account_raises_error(
|
|
self, triggers, mock_client
|
|
):
|
|
"""Test create trigger without user_id or connected_account_id raises error."""
|
|
with pytest.raises(exceptions.InvalidParams) as exc_info:
|
|
triggers.create(
|
|
slug="GITHUB_COMMIT_EVENT",
|
|
trigger_config={"webhook_url": "https://example.com/webhook"},
|
|
)
|
|
|
|
assert "please provide valid `connected_account_id` or `user_id`" in str(
|
|
exc_info.value
|
|
)
|
|
|
|
def test_enable_trigger(self, triggers, mock_client):
|
|
"""Test enable trigger."""
|
|
mock_response = Mock()
|
|
mock_client.trigger_instances.manage.update.return_value = mock_response
|
|
|
|
result = triggers.enable(trigger_id="trigger-123")
|
|
|
|
mock_client.trigger_instances.manage.update.assert_called_once_with(
|
|
trigger_id="trigger-123",
|
|
status="enable",
|
|
)
|
|
assert result == mock_response
|
|
|
|
def test_disable_trigger(self, triggers, mock_client):
|
|
"""Test disable trigger."""
|
|
mock_response = Mock()
|
|
mock_client.trigger_instances.manage.update.return_value = mock_response
|
|
|
|
result = triggers.disable(trigger_id="trigger-123")
|
|
|
|
mock_client.trigger_instances.manage.update.assert_called_once_with(
|
|
trigger_id="trigger-123",
|
|
status="disable",
|
|
)
|
|
assert result == mock_response
|
|
|
|
def test_delete_trigger(self, triggers, mock_client):
|
|
"""Test delete trigger."""
|
|
mock_response = Mock()
|
|
mock_client.trigger_instances.manage.delete.return_value = mock_response
|
|
|
|
result = triggers.delete(trigger_id="trigger-123")
|
|
|
|
mock_client.trigger_instances.manage.delete.assert_called_once_with(
|
|
trigger_id="trigger-123"
|
|
)
|
|
assert result == mock_response
|
|
|
|
def test_list_enum(self, triggers, mock_client):
|
|
"""Test list_enum method."""
|
|
mock_response = Mock()
|
|
mock_response.enum = ["GITHUB_COMMIT_EVENT", "SLACK_MESSAGE_RECEIVED"]
|
|
mock_client.triggers_types.retrieve_enum.return_value = mock_response
|
|
|
|
result = triggers.list_enum()
|
|
|
|
mock_client.triggers_types.retrieve_enum.assert_called_once()
|
|
assert result == mock_response
|
|
|
|
def test_subscribe(self, triggers, mock_client):
|
|
"""Test subscribe method."""
|
|
with patch(
|
|
"composio.core.models.triggers._SubcriptionBuilder"
|
|
) as mock_builder_class:
|
|
mock_builder = Mock()
|
|
mock_subscription = Mock()
|
|
mock_builder.connect.return_value = mock_subscription
|
|
mock_builder_class.return_value = mock_builder
|
|
|
|
result = triggers.subscribe(timeout=20.0)
|
|
|
|
mock_builder_class.assert_called_once_with(client=mock_client)
|
|
mock_builder.connect.assert_called_once_with(timeout=20.0)
|
|
assert result == mock_subscription
|
|
|
|
def test_subscribe_with_default_timeout(self, triggers, mock_client):
|
|
"""Test subscribe method with default timeout."""
|
|
with patch(
|
|
"composio.core.models.triggers._SubcriptionBuilder"
|
|
) as mock_builder_class:
|
|
mock_builder = Mock()
|
|
mock_subscription = Mock()
|
|
mock_builder.connect.return_value = mock_subscription
|
|
mock_builder_class.return_value = mock_builder
|
|
|
|
result = triggers.subscribe()
|
|
|
|
mock_builder.connect.assert_called_once_with(timeout=15.0)
|
|
assert result == mock_subscription
|
|
|
|
|
|
class TestVerifyWebhook:
|
|
"""Test cases for verify_webhook method."""
|
|
|
|
@pytest.fixture
|
|
def mock_client(self):
|
|
"""Create a mock HTTP client."""
|
|
client = Mock()
|
|
client.triggers_types = Mock()
|
|
client.trigger_instances = Mock()
|
|
client.trigger_instances.manage = Mock()
|
|
client.connected_accounts = Mock()
|
|
return client
|
|
|
|
@pytest.fixture
|
|
def triggers(self, mock_client):
|
|
"""Create a Triggers instance."""
|
|
return Triggers(client=mock_client)
|
|
|
|
@pytest.fixture
|
|
def test_secret(self):
|
|
"""Test webhook secret."""
|
|
return "test-webhook-secret-12345"
|
|
|
|
@pytest.fixture
|
|
def test_webhook_id(self):
|
|
"""Test webhook ID."""
|
|
return "msg_test123"
|
|
|
|
@pytest.fixture
|
|
def test_timestamp(self):
|
|
"""Test webhook timestamp (current time in Unix seconds)."""
|
|
return str(int(time.time()))
|
|
|
|
@pytest.fixture
|
|
def mock_v1_payload(self):
|
|
"""Create mock V1 webhook payload."""
|
|
return {
|
|
"trigger_name": "GITHUB_PUSH_EVENT",
|
|
"connection_id": "conn-123",
|
|
"trigger_id": "trigger-123",
|
|
"payload": {"action": "push", "repository": "test-repo"},
|
|
"log_id": "log-123",
|
|
}
|
|
|
|
@pytest.fixture
|
|
def mock_v2_payload(self):
|
|
"""Create mock V2 webhook payload."""
|
|
return {
|
|
"type": "github_push_event",
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"log_id": "log-123",
|
|
"data": {
|
|
"connection_id": "conn-123",
|
|
"connection_nano_id": "conn-nano-123",
|
|
"trigger_nano_id": "trigger-nano-123",
|
|
"trigger_id": "trigger-123",
|
|
"user_id": "user-456",
|
|
"action": "push",
|
|
"repository": "test-repo",
|
|
},
|
|
}
|
|
|
|
@pytest.fixture
|
|
def mock_v3_payload(self):
|
|
"""Create mock V3 webhook payload."""
|
|
return {
|
|
"id": "evt-123",
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"type": "composio.trigger.message",
|
|
"metadata": {
|
|
"log_id": "log-123",
|
|
"trigger_slug": "GITHUB_PUSH_EVENT",
|
|
"trigger_id": "trigger-nano-123",
|
|
"connected_account_id": "conn-nano-123",
|
|
"auth_config_id": "auth-nano-123",
|
|
"user_id": "user-456",
|
|
},
|
|
"data": {"action": "push", "repository": "test-repo"},
|
|
}
|
|
|
|
def create_signature(
|
|
self, webhook_id: str, timestamp: str, payload: str, secret: str
|
|
) -> str:
|
|
"""Helper to create a valid v1,base64 signature."""
|
|
to_sign = f"{webhook_id}.{timestamp}.{payload}"
|
|
signature_bytes = hmac.new(
|
|
key=secret.encode("utf-8"),
|
|
msg=to_sign.encode("utf-8"),
|
|
digestmod=hashlib.sha256,
|
|
).digest()
|
|
return f"v1,{base64.b64encode(signature_bytes).decode('utf-8')}"
|
|
|
|
# Successful verification tests with V3 payload
|
|
|
|
def test_verify_webhook_v3_payload(
|
|
self, triggers, test_secret, test_webhook_id, test_timestamp, mock_v3_payload
|
|
):
|
|
"""Test successful V3 webhook verification."""
|
|
payload = json.dumps(mock_v3_payload)
|
|
signature = self.create_signature(
|
|
test_webhook_id, test_timestamp, payload, test_secret
|
|
)
|
|
|
|
result = triggers.verify_webhook(
|
|
id=test_webhook_id,
|
|
payload=payload,
|
|
signature=signature,
|
|
timestamp=test_timestamp,
|
|
secret=test_secret,
|
|
)
|
|
|
|
assert result["version"] == WebhookVersion.V3
|
|
assert result["payload"]["trigger_slug"] == "GITHUB_PUSH_EVENT"
|
|
assert result["payload"]["user_id"] == "user-456"
|
|
assert result["raw_payload"] == mock_v3_payload
|
|
|
|
def test_verify_webhook_v3_normalizes_payload(
|
|
self, triggers, test_secret, test_webhook_id, test_timestamp, mock_v3_payload
|
|
):
|
|
"""Test V3 payload normalization."""
|
|
payload = json.dumps(mock_v3_payload)
|
|
signature = self.create_signature(
|
|
test_webhook_id, test_timestamp, payload, test_secret
|
|
)
|
|
|
|
result = triggers.verify_webhook(
|
|
id=test_webhook_id,
|
|
payload=payload,
|
|
signature=signature,
|
|
timestamp=test_timestamp,
|
|
secret=test_secret,
|
|
)
|
|
|
|
assert (
|
|
result["payload"]["metadata"]["connected_account"]["id"] == "conn-nano-123"
|
|
)
|
|
assert (
|
|
result["payload"]["metadata"]["connected_account"]["auth_config_id"]
|
|
== "auth-nano-123"
|
|
)
|
|
assert (
|
|
result["payload"]["metadata"]["connected_account"]["user_id"] == "user-456"
|
|
)
|
|
|
|
def test_verify_webhook_v3_non_trigger_event_type(
|
|
self, triggers, test_secret, test_webhook_id, test_timestamp
|
|
):
|
|
"""Test V3 payload with non-trigger event type is detected as V3, not V2.
|
|
|
|
Uses realistic connection metadata (project_id, org_id) instead of
|
|
fabricated trigger metadata, verifying V3 detection works for events
|
|
with different metadata shapes.
|
|
"""
|
|
payload_dict = {
|
|
"id": "msg_abc123",
|
|
"timestamp": "2024-01-01T00:00:00Z",
|
|
"type": "composio.connected_account.expired",
|
|
"metadata": {
|
|
"project_id": "pr_koucdrMIwRsf",
|
|
"org_id": "4a4ded8f-d3ae-4dea-a229-c30234298b05",
|
|
},
|
|
"data": {
|
|
"toolkit": {"slug": "gmail"},
|
|
"id": "ca__IvSeEzEBjVt",
|
|
"user_id": "test-user",
|
|
"status": "EXPIRED",
|
|
},
|
|
}
|
|
payload = json.dumps(payload_dict)
|
|
signature = self.create_signature(
|
|
test_webhook_id, test_timestamp, payload, test_secret
|
|
)
|
|
|
|
result = triggers.verify_webhook(
|
|
id=test_webhook_id,
|
|
payload=payload,
|
|
signature=signature,
|
|
timestamp=test_timestamp,
|
|
secret=test_secret,
|
|
)
|
|
|
|
# Should be detected as V3, not fall back to V2
|
|
assert result["version"] == WebhookVersion.V3
|
|
assert result["raw_payload"] == payload_dict
|
|
|
|
# Successful verification with V2 payload
|
|
|
|
def test_verify_webhook_v2_payload(
|
|
self, triggers, test_secret, test_webhook_id, test_timestamp, mock_v2_payload
|
|
):
|
|
"""Test successful V2 webhook verification."""
|
|
payload = json.dumps(mock_v2_payload)
|
|
signature = self.create_signature(
|
|
test_webhook_id, test_timestamp, payload, test_secret
|
|
)
|
|
|
|
result = triggers.verify_webhook(
|
|
id=test_webhook_id,
|
|
payload=payload,
|
|
signature=signature,
|
|
timestamp=test_timestamp,
|
|
secret=test_secret,
|
|
)
|
|
|
|
assert result["version"] == WebhookVersion.V2
|
|
assert result["payload"]["user_id"] == "user-456"
|
|
|
|
# Successful verification with V1 payload
|
|
|
|
def test_verify_webhook_v1_payload(
|
|
self, triggers, test_secret, test_webhook_id, test_timestamp, mock_v1_payload
|
|
):
|
|
"""Test successful V1 webhook verification."""
|
|
payload = json.dumps(mock_v1_payload)
|
|
signature = self.create_signature(
|
|
test_webhook_id, test_timestamp, payload, test_secret
|
|
)
|
|
|
|
result = triggers.verify_webhook(
|
|
id=test_webhook_id,
|
|
payload=payload,
|
|
signature=signature,
|
|
timestamp=test_timestamp,
|
|
secret=test_secret,
|
|
)
|
|
|
|
assert result["version"] == WebhookVersion.V1
|
|
assert result["payload"]["trigger_slug"] == "GITHUB_PUSH_EVENT"
|
|
assert result["payload"]["id"] == "trigger-123"
|
|
|
|
# Tolerance tests
|
|
|
|
def test_verify_webhook_with_tolerance_zero(
|
|
self, triggers, test_secret, test_webhook_id, mock_v3_payload
|
|
):
|
|
"""Test webhook verification with tolerance set to 0 (skip timestamp validation)."""
|
|
# Use an old timestamp (1 hour ago)
|
|
old_timestamp = str(int(time.time()) - 3600)
|
|
payload = json.dumps(mock_v3_payload)
|
|
signature = self.create_signature(
|
|
test_webhook_id, old_timestamp, payload, test_secret
|
|
)
|
|
|
|
result = triggers.verify_webhook(
|
|
id=test_webhook_id,
|
|
payload=payload,
|
|
signature=signature,
|
|
timestamp=old_timestamp,
|
|
secret=test_secret,
|
|
tolerance=0,
|
|
)
|
|
|
|
assert result["version"] == WebhookVersion.V3
|
|
|
|
def test_verify_webhook_with_custom_tolerance(
|
|
self, triggers, test_secret, test_webhook_id, test_timestamp, mock_v3_payload
|
|
):
|
|
"""Test webhook verification with custom tolerance."""
|
|
payload = json.dumps(mock_v3_payload)
|
|
signature = self.create_signature(
|
|
test_webhook_id, test_timestamp, payload, test_secret
|
|
)
|
|
|
|
result = triggers.verify_webhook(
|
|
id=test_webhook_id,
|
|
payload=payload,
|
|
signature=signature,
|
|
timestamp=test_timestamp,
|
|
secret=test_secret,
|
|
tolerance=600, # 10 minutes
|
|
)
|
|
|
|
assert result is not None
|
|
|
|
# Signature verification error tests
|
|
|
|
def test_verify_webhook_empty_payload_raises_error(
|
|
self, triggers, test_secret, test_webhook_id, test_timestamp
|
|
):
|
|
"""Test that empty payload raises WebhookSignatureVerificationError."""
|
|
with pytest.raises(exceptions.WebhookSignatureVerificationError) as exc_info:
|
|
triggers.verify_webhook(
|
|
id=test_webhook_id,
|
|
payload="",
|
|
signature="v1,somesignature",
|
|
timestamp=test_timestamp,
|
|
secret=test_secret,
|
|
)
|
|
|
|
assert "No webhook payload was provided" in str(exc_info.value)
|
|
|
|
def test_verify_webhook_empty_signature_raises_error(
|
|
self, triggers, test_secret, test_webhook_id, test_timestamp, mock_v3_payload
|
|
):
|
|
"""Test that empty signature raises WebhookSignatureVerificationError."""
|
|
payload = json.dumps(mock_v3_payload)
|
|
|
|
with pytest.raises(exceptions.WebhookSignatureVerificationError) as exc_info:
|
|
triggers.verify_webhook(
|
|
id=test_webhook_id,
|
|
payload=payload,
|
|
signature="",
|
|
timestamp=test_timestamp,
|
|
secret=test_secret,
|
|
)
|
|
|
|
assert "No signature header value was provided" in str(exc_info.value)
|
|
|
|
def test_verify_webhook_empty_secret_raises_error(
|
|
self, triggers, test_webhook_id, test_timestamp, mock_v3_payload
|
|
):
|
|
"""Test that empty secret raises WebhookSignatureVerificationError."""
|
|
payload = json.dumps(mock_v3_payload)
|
|
|
|
with pytest.raises(exceptions.WebhookSignatureVerificationError) as exc_info:
|
|
triggers.verify_webhook(
|
|
id=test_webhook_id,
|
|
payload=payload,
|
|
signature="v1,somesignature",
|
|
timestamp=test_timestamp,
|
|
secret="",
|
|
)
|
|
|
|
assert "No webhook secret was provided" in str(exc_info.value)
|
|
|
|
def test_verify_webhook_empty_webhook_id_raises_error(
|
|
self, triggers, test_secret, test_timestamp, mock_v3_payload
|
|
):
|
|
"""Test that empty webhook ID raises WebhookSignatureVerificationError."""
|
|
payload = json.dumps(mock_v3_payload)
|
|
|
|
with pytest.raises(exceptions.WebhookSignatureVerificationError) as exc_info:
|
|
triggers.verify_webhook(
|
|
id="",
|
|
payload=payload,
|
|
signature="v1,somesignature",
|
|
timestamp=test_timestamp,
|
|
secret=test_secret,
|
|
)
|
|
|
|
assert "No webhook ID was provided" in str(exc_info.value)
|
|
|
|
def test_verify_webhook_empty_timestamp_raises_error(
|
|
self, triggers, test_secret, test_webhook_id, mock_v3_payload
|
|
):
|
|
"""Test that empty timestamp raises WebhookPayloadError."""
|
|
payload = json.dumps(mock_v3_payload)
|
|
|
|
with pytest.raises(exceptions.WebhookPayloadError) as exc_info:
|
|
triggers.verify_webhook(
|
|
id=test_webhook_id,
|
|
payload=payload,
|
|
signature="v1,somesignature",
|
|
timestamp="",
|
|
secret=test_secret,
|
|
)
|
|
|
|
assert "Invalid webhook timestamp" in str(exc_info.value)
|
|
|
|
def test_verify_webhook_invalid_signature_format_raises_error(
|
|
self, triggers, test_secret, test_webhook_id, test_timestamp, mock_v3_payload
|
|
):
|
|
"""Test that signature without v1 prefix raises error."""
|
|
payload = json.dumps(mock_v3_payload)
|
|
|
|
with pytest.raises(exceptions.WebhookSignatureVerificationError) as exc_info:
|
|
triggers.verify_webhook(
|
|
id=test_webhook_id,
|
|
payload=payload,
|
|
signature="invalid-signature-no-prefix",
|
|
timestamp=test_timestamp,
|
|
secret=test_secret,
|
|
)
|
|
|
|
assert "No valid v1 signature found" in str(exc_info.value)
|
|
|
|
def test_verify_webhook_invalid_signature_raises_error(
|
|
self, triggers, test_secret, test_webhook_id, test_timestamp, mock_v3_payload
|
|
):
|
|
"""Test that invalid signature raises WebhookSignatureVerificationError."""
|
|
payload = json.dumps(mock_v3_payload)
|
|
|
|
with pytest.raises(exceptions.WebhookSignatureVerificationError) as exc_info:
|
|
triggers.verify_webhook(
|
|
id=test_webhook_id,
|
|
payload=payload,
|
|
signature="v1,invalidbase64signature==",
|
|
timestamp=test_timestamp,
|
|
secret=test_secret,
|
|
)
|
|
|
|
assert "The signature provided is invalid" in str(exc_info.value)
|
|
|
|
def test_verify_webhook_wrong_secret_raises_error(
|
|
self, triggers, test_secret, test_webhook_id, test_timestamp, mock_v3_payload
|
|
):
|
|
"""Test that signature created with different secret raises error."""
|
|
payload = json.dumps(mock_v3_payload)
|
|
signature = self.create_signature(
|
|
test_webhook_id, test_timestamp, payload, "different-secret"
|
|
)
|
|
|
|
with pytest.raises(exceptions.WebhookSignatureVerificationError) as exc_info:
|
|
triggers.verify_webhook(
|
|
id=test_webhook_id,
|
|
payload=payload,
|
|
signature=signature,
|
|
timestamp=test_timestamp,
|
|
secret=test_secret,
|
|
)
|
|
|
|
assert "The signature provided is invalid" in str(exc_info.value)
|
|
|
|
def test_verify_webhook_modified_payload_raises_error(
|
|
self, triggers, test_secret, test_webhook_id, test_timestamp, mock_v3_payload
|
|
):
|
|
"""Test that modified payload after signing raises error."""
|
|
original_payload = json.dumps(mock_v3_payload)
|
|
signature = self.create_signature(
|
|
test_webhook_id, test_timestamp, original_payload, test_secret
|
|
)
|
|
|
|
# Modify the payload
|
|
mock_v3_payload["data"] = {"modified": True}
|
|
modified_payload = json.dumps(mock_v3_payload)
|
|
|
|
with pytest.raises(exceptions.WebhookSignatureVerificationError):
|
|
triggers.verify_webhook(
|
|
id=test_webhook_id,
|
|
payload=modified_payload,
|
|
signature=signature,
|
|
timestamp=test_timestamp,
|
|
secret=test_secret,
|
|
)
|
|
|
|
# Payload parsing error tests
|
|
|
|
def test_verify_webhook_invalid_json_raises_error(
|
|
self, triggers, test_secret, test_webhook_id, test_timestamp
|
|
):
|
|
"""Test that invalid JSON payload raises WebhookPayloadError."""
|
|
invalid_json = "not-valid-json{"
|
|
signature = self.create_signature(
|
|
test_webhook_id, test_timestamp, invalid_json, test_secret
|
|
)
|
|
|
|
with pytest.raises(exceptions.WebhookPayloadError) as exc_info:
|
|
triggers.verify_webhook(
|
|
id=test_webhook_id,
|
|
payload=invalid_json,
|
|
signature=signature,
|
|
timestamp=test_timestamp,
|
|
secret=test_secret,
|
|
)
|
|
|
|
assert "Failed to parse webhook payload as JSON" in str(exc_info.value)
|
|
|
|
def test_verify_webhook_unrecognized_payload_raises_error(
|
|
self, triggers, test_secret, test_webhook_id, test_timestamp
|
|
):
|
|
"""Test that unrecognized payload format raises WebhookPayloadError."""
|
|
unknown_payload = json.dumps({"unknown": "format"})
|
|
signature = self.create_signature(
|
|
test_webhook_id, test_timestamp, unknown_payload, test_secret
|
|
)
|
|
|
|
with pytest.raises(exceptions.WebhookPayloadError) as exc_info:
|
|
triggers.verify_webhook(
|
|
id=test_webhook_id,
|
|
payload=unknown_payload,
|
|
signature=signature,
|
|
timestamp=test_timestamp,
|
|
secret=test_secret,
|
|
)
|
|
|
|
assert "does not match any known version" in str(exc_info.value)
|
|
|
|
def test_verify_webhook_v3_missing_data_field_raises_error(
|
|
self, triggers, test_secret, test_webhook_id, test_timestamp
|
|
):
|
|
"""Test that V3-like payload missing 'data' field raises WebhookPayloadError.
|
|
|
|
This tests the fix for a potential KeyError crash when a malformed payload
|
|
has valid V3 markers (type, id, metadata) but is missing the required 'data' field.
|
|
"""
|
|
# Payload with V3 markers but missing 'data' field
|
|
v3_like_payload_missing_data = {
|
|
"id": "evt-123",
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"type": "composio.trigger.message",
|
|
"metadata": {
|
|
"log_id": "log-123",
|
|
"trigger_slug": "GITHUB_PUSH_EVENT",
|
|
"trigger_id": "trigger-nano-123",
|
|
"connected_account_id": "conn-nano-123",
|
|
"auth_config_id": "auth-nano-123",
|
|
"user_id": "user-456",
|
|
},
|
|
# Note: 'data' field is intentionally missing
|
|
}
|
|
payload = json.dumps(v3_like_payload_missing_data)
|
|
signature = self.create_signature(
|
|
test_webhook_id, test_timestamp, payload, test_secret
|
|
)
|
|
|
|
with pytest.raises(exceptions.WebhookPayloadError) as exc_info:
|
|
triggers.verify_webhook(
|
|
id=test_webhook_id,
|
|
payload=payload,
|
|
signature=signature,
|
|
timestamp=test_timestamp,
|
|
secret=test_secret,
|
|
)
|
|
|
|
# Should be rejected as unrecognized format, not crash with KeyError
|
|
assert "does not match any known version" in str(exc_info.value)
|
|
|
|
# Timestamp validation tests
|
|
|
|
def test_verify_webhook_timestamp_within_tolerance(
|
|
self, triggers, test_secret, test_webhook_id, mock_v3_payload
|
|
):
|
|
"""Test that timestamp within tolerance passes validation."""
|
|
recent_timestamp = str(int(time.time()))
|
|
payload = json.dumps(mock_v3_payload)
|
|
signature = self.create_signature(
|
|
test_webhook_id, recent_timestamp, payload, test_secret
|
|
)
|
|
|
|
result = triggers.verify_webhook(
|
|
id=test_webhook_id,
|
|
payload=payload,
|
|
signature=signature,
|
|
timestamp=recent_timestamp,
|
|
secret=test_secret,
|
|
tolerance=300,
|
|
)
|
|
|
|
assert result is not None
|
|
|
|
def test_verify_webhook_timestamp_outside_tolerance_raises_error(
|
|
self, triggers, test_secret, test_webhook_id, mock_v3_payload
|
|
):
|
|
"""Test that timestamp outside tolerance raises error."""
|
|
# 10 minutes ago
|
|
old_timestamp = str(int(time.time()) - 600)
|
|
payload = json.dumps(mock_v3_payload)
|
|
signature = self.create_signature(
|
|
test_webhook_id, old_timestamp, payload, test_secret
|
|
)
|
|
|
|
with pytest.raises(exceptions.WebhookSignatureVerificationError) as exc_info:
|
|
triggers.verify_webhook(
|
|
id=test_webhook_id,
|
|
payload=payload,
|
|
signature=signature,
|
|
timestamp=old_timestamp,
|
|
secret=test_secret,
|
|
tolerance=300, # 5 minutes
|
|
)
|
|
|
|
assert "outside the allowed tolerance" in str(exc_info.value)
|
|
|
|
def test_verify_webhook_invalid_timestamp_format_raises_error(
|
|
self, triggers, test_secret, test_webhook_id, mock_v3_payload
|
|
):
|
|
"""Test that invalid timestamp format raises WebhookPayloadError."""
|
|
invalid_timestamp = "not-a-timestamp"
|
|
payload = json.dumps(mock_v3_payload)
|
|
signature = self.create_signature(
|
|
test_webhook_id, invalid_timestamp, payload, test_secret
|
|
)
|
|
|
|
with pytest.raises(exceptions.WebhookPayloadError) as exc_info:
|
|
triggers.verify_webhook(
|
|
id=test_webhook_id,
|
|
payload=payload,
|
|
signature=signature,
|
|
timestamp=invalid_timestamp,
|
|
secret=test_secret,
|
|
tolerance=300,
|
|
)
|
|
|
|
assert "Invalid webhook timestamp" in str(exc_info.value)
|
|
|
|
# Security tests
|
|
|
|
def test_verify_webhook_uses_timing_safe_comparison(
|
|
self, triggers, test_secret, test_webhook_id, test_timestamp, mock_v3_payload
|
|
):
|
|
"""Test that signature comparison is timing-safe."""
|
|
payload = json.dumps(mock_v3_payload)
|
|
valid_signature = self.create_signature(
|
|
test_webhook_id, test_timestamp, payload, test_secret
|
|
)
|
|
|
|
# Valid signature should work
|
|
result = triggers.verify_webhook(
|
|
id=test_webhook_id,
|
|
payload=payload,
|
|
signature=valid_signature,
|
|
timestamp=test_timestamp,
|
|
secret=test_secret,
|
|
)
|
|
assert result is not None
|
|
|
|
# Invalid signature with same format should fail
|
|
invalid_signature = "v1," + "a" * 44 # base64 SHA256 is 44 chars
|
|
with pytest.raises(exceptions.WebhookSignatureVerificationError):
|
|
triggers.verify_webhook(
|
|
id=test_webhook_id,
|
|
payload=payload,
|
|
signature=invalid_signature,
|
|
timestamp=test_timestamp,
|
|
secret=test_secret,
|
|
)
|
|
|
|
def test_verify_webhook_handles_unicode_payload(
|
|
self, triggers, test_secret, test_webhook_id, test_timestamp, mock_v3_payload
|
|
):
|
|
"""Test that unicode in payload is handled correctly."""
|
|
mock_v3_payload["data"] = {"message": "你好世界 🌍 مرحبا"}
|
|
payload = json.dumps(mock_v3_payload, ensure_ascii=False)
|
|
signature = self.create_signature(
|
|
test_webhook_id, test_timestamp, payload, test_secret
|
|
)
|
|
|
|
result = triggers.verify_webhook(
|
|
id=test_webhook_id,
|
|
payload=payload,
|
|
signature=signature,
|
|
timestamp=test_timestamp,
|
|
secret=test_secret,
|
|
)
|
|
|
|
assert result["payload"]["payload"]["message"] == "你好世界 🌍 مرحبا"
|
|
|
|
def test_verify_webhook_handles_special_characters_in_secret(
|
|
self, triggers, test_webhook_id, test_timestamp, mock_v3_payload
|
|
):
|
|
"""Test that special characters in secret are handled correctly."""
|
|
special_secret = "secret!@#$%^&*()_+-=[]{}|;:,.<>?"
|
|
payload = json.dumps(mock_v3_payload)
|
|
signature = self.create_signature(
|
|
test_webhook_id, test_timestamp, payload, special_secret
|
|
)
|
|
|
|
result = triggers.verify_webhook(
|
|
id=test_webhook_id,
|
|
payload=payload,
|
|
signature=signature,
|
|
timestamp=test_timestamp,
|
|
secret=special_secret,
|
|
)
|
|
|
|
assert result is not None
|
|
|
|
def test_verify_webhook_supports_multiple_signatures(
|
|
self, triggers, test_secret, test_webhook_id, test_timestamp, mock_v3_payload
|
|
):
|
|
"""Test that multiple signatures in header are supported."""
|
|
payload = json.dumps(mock_v3_payload)
|
|
valid_signature = self.create_signature(
|
|
test_webhook_id, test_timestamp, payload, test_secret
|
|
)
|
|
# Multiple signatures space-separated
|
|
multiple_signatures = f"v1,invalidsig== {valid_signature}"
|
|
|
|
result = triggers.verify_webhook(
|
|
id=test_webhook_id,
|
|
payload=payload,
|
|
signature=multiple_signatures,
|
|
timestamp=test_timestamp,
|
|
secret=test_secret,
|
|
)
|
|
|
|
assert result is not None
|
|
|
|
# Error class tests
|
|
|
|
def test_webhook_signature_verification_error_is_trigger_error(self):
|
|
"""Test that WebhookSignatureVerificationError inherits from TriggerError."""
|
|
error = exceptions.WebhookSignatureVerificationError("test")
|
|
assert isinstance(error, exceptions.TriggerError)
|
|
|
|
def test_webhook_payload_error_is_trigger_error(self):
|
|
"""Test that WebhookPayloadError inherits from TriggerError."""
|
|
error = exceptions.WebhookPayloadError("test")
|
|
assert isinstance(error, exceptions.TriggerError)
|
|
|
|
|
|
class TestParseWebhook:
|
|
"""Test cases for the parse() webhook helper."""
|
|
|
|
@pytest.fixture
|
|
def mock_client(self):
|
|
"""Create a mock HTTP client."""
|
|
client = Mock()
|
|
client.triggers_types = Mock()
|
|
client.trigger_instances = Mock()
|
|
client.trigger_instances.manage = Mock()
|
|
client.connected_accounts = Mock()
|
|
return client
|
|
|
|
@pytest.fixture
|
|
def triggers(self, mock_client):
|
|
"""Create a Triggers instance."""
|
|
return Triggers(client=mock_client)
|
|
|
|
@pytest.fixture
|
|
def test_secret(self):
|
|
"""Test webhook secret."""
|
|
return "test-webhook-secret-12345"
|
|
|
|
@pytest.fixture
|
|
def test_webhook_id(self):
|
|
"""Test webhook ID."""
|
|
return "msg_test123"
|
|
|
|
@pytest.fixture
|
|
def test_timestamp(self):
|
|
"""Test webhook timestamp (current time in Unix seconds)."""
|
|
return str(int(time.time()))
|
|
|
|
@pytest.fixture
|
|
def mock_v3_payload(self):
|
|
"""Create mock V3 webhook payload."""
|
|
return {
|
|
"id": "evt-123",
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"type": "composio.trigger.message",
|
|
"metadata": {
|
|
"log_id": "log-123",
|
|
"trigger_slug": "GITHUB_PUSH_EVENT",
|
|
"trigger_id": "trigger-nano-123",
|
|
"connected_account_id": "conn-nano-123",
|
|
"auth_config_id": "auth-nano-123",
|
|
"user_id": "user-456",
|
|
},
|
|
"data": {"action": "push", "repository": "test-repo"},
|
|
}
|
|
|
|
def create_signature(
|
|
self, webhook_id: str, timestamp: str, payload: str, secret: str
|
|
) -> str:
|
|
"""Helper to create a valid v1,base64 signature."""
|
|
to_sign = f"{webhook_id}.{timestamp}.{payload}"
|
|
signature_bytes = hmac.new(
|
|
key=secret.encode("utf-8"),
|
|
msg=to_sign.encode("utf-8"),
|
|
digestmod=hashlib.sha256,
|
|
).digest()
|
|
return f"v1,{base64.b64encode(signature_bytes).decode('utf-8')}"
|
|
|
|
def test_parse_with_request_object_and_verify(
|
|
self, triggers, test_secret, test_webhook_id, test_timestamp, mock_v3_payload
|
|
):
|
|
"""Parse and verify from a framework-style request object."""
|
|
payload = json.dumps(mock_v3_payload)
|
|
signature = self.create_signature(
|
|
test_webhook_id, test_timestamp, payload, test_secret
|
|
)
|
|
|
|
request = Mock()
|
|
request.body = payload.encode("utf-8")
|
|
request.headers = {
|
|
"webhook-id": test_webhook_id,
|
|
"webhook-timestamp": test_timestamp,
|
|
"webhook-signature": signature,
|
|
}
|
|
# Avoid Mock auto-creating a callable get_data attribute.
|
|
del request.get_data
|
|
|
|
result = triggers.parse(request, verify_secret=test_secret)
|
|
|
|
assert result["version"] == WebhookVersion.V3
|
|
assert result["payload"]["trigger_slug"] == "GITHUB_PUSH_EVENT"
|
|
assert result["payload"]["payload"] == {
|
|
"action": "push",
|
|
"repository": "test-repo",
|
|
}
|
|
|
|
def test_parse_with_explicit_body_and_headers_no_verify(
|
|
self, triggers, mock_v3_payload
|
|
):
|
|
"""Parse without verifying using explicit body/headers kwargs."""
|
|
payload = json.dumps(mock_v3_payload)
|
|
|
|
result = triggers.parse(body=payload, headers={})
|
|
|
|
assert result["version"] == WebhookVersion.V3
|
|
assert result["payload"]["trigger_slug"] == "GITHUB_PUSH_EVENT"
|
|
assert result["payload"]["payload"] == {
|
|
"action": "push",
|
|
"repository": "test-repo",
|
|
}
|
|
|
|
def test_parse_bad_signature_raises(
|
|
self, triggers, test_secret, test_webhook_id, test_timestamp, mock_v3_payload
|
|
):
|
|
"""A bad signature with verify_secret raises a verification error."""
|
|
payload = json.dumps(mock_v3_payload)
|
|
|
|
with pytest.raises(exceptions.WebhookSignatureVerificationError):
|
|
triggers.parse(
|
|
body=payload,
|
|
headers={
|
|
"webhook-id": test_webhook_id,
|
|
"webhook-timestamp": test_timestamp,
|
|
"webhook-signature": "v1,not-a-valid-signature",
|
|
},
|
|
verify_secret=test_secret,
|
|
)
|
|
|
|
def test_parse_missing_headers_with_verify_raises(
|
|
self, triggers, test_secret, mock_v3_payload
|
|
):
|
|
"""verify_secret set but missing signature headers raises ValidationError."""
|
|
payload = json.dumps(mock_v3_payload)
|
|
|
|
with pytest.raises(exceptions.ValidationError) as exc_info:
|
|
triggers.parse(body=payload, headers={}, verify_secret=test_secret)
|
|
|
|
assert "missing signature header" in str(exc_info.value)
|
|
|
|
|
|
class TestTriggerSubscriptionParsing:
|
|
"""Tests for realtime (Pusher) payload parsing in TriggerSubscription."""
|
|
|
|
@pytest.fixture
|
|
def subscription(self):
|
|
"""Create a TriggerSubscription with a mock client."""
|
|
return TriggerSubscription(client=Mock())
|
|
|
|
def test_parse_payload_v3_realtime_envelope(self, subscription):
|
|
"""A V3 realtime envelope is parsed (no KeyError: 'nanoId')."""
|
|
event = json.dumps(
|
|
{
|
|
"id": "evt-1",
|
|
"type": "composio.trigger.message",
|
|
"metadata": {
|
|
"log_id": "log-1",
|
|
"trigger_slug": "GMAIL_NEW_GMAIL_MESSAGE",
|
|
"trigger_id": "ti_abc",
|
|
"connected_account_id": "ca_abc",
|
|
"auth_config_id": "ac_abc",
|
|
"user_id": "user-1",
|
|
},
|
|
"data": {"subject": "hello"},
|
|
}
|
|
)
|
|
|
|
result = subscription._parse_payload(event)
|
|
|
|
assert result is not None
|
|
assert result["id"] == "ti_abc"
|
|
assert result["trigger_slug"] == "GMAIL_NEW_GMAIL_MESSAGE"
|
|
assert result["toolkit_slug"] == "GMAIL"
|
|
assert result["user_id"] == "user-1"
|
|
assert result["payload"] == {"subject": "hello"}
|
|
assert result["metadata"]["connected_account"]["id"] == "ca_abc"
|
|
assert result["metadata"]["connected_account"]["auth_config_id"] == "ac_abc"
|
|
|
|
def test_parse_payload_legacy_envelope(self, subscription):
|
|
"""A legacy (V1/V2) realtime envelope still parses correctly."""
|
|
event = json.dumps(
|
|
{
|
|
"appName": "gmail",
|
|
"payload": {"subject": "hello"},
|
|
"originalPayload": {"raw": 1},
|
|
"metadata": {
|
|
"id": "uuid-1",
|
|
"nanoId": "ti_abc",
|
|
"triggerName": "GMAIL_NEW_GMAIL_MESSAGE",
|
|
"triggerData": "",
|
|
"triggerConfig": {},
|
|
"connection": {
|
|
"id": "conn-uuid",
|
|
"connectedAccountNanoId": "ca_abc",
|
|
"authConfigNanoId": "ac_abc",
|
|
"integrationId": "int-uuid",
|
|
"clientUniqueUserId": "user-1",
|
|
"status": "ACTIVE",
|
|
},
|
|
},
|
|
}
|
|
)
|
|
|
|
result = subscription._parse_payload(event)
|
|
|
|
assert result is not None
|
|
assert result["id"] == "ti_abc"
|
|
assert result["toolkit_slug"] == "gmail"
|
|
assert result["trigger_slug"] == "GMAIL_NEW_GMAIL_MESSAGE"
|
|
assert result["metadata"]["connected_account"]["id"] == "ca_abc"
|
|
assert result["original_payload"] == {"raw": 1}
|
|
|
|
def test_parse_payload_malformed_returns_none(self, subscription):
|
|
"""Truly undecodable or unrecognized frames are skipped, not raised."""
|
|
assert subscription._parse_payload("not-json") is None
|
|
assert subscription._parse_payload(json.dumps({"unexpected": True})) is None
|
|
|
|
def test_parse_payload_legacy_non_dict_metadata_does_not_raise(self, subscription):
|
|
"""A legacy frame with a non-dict metadata is skipped, not raised.
|
|
|
|
Guards the broadened `except Exception` in _parse_payload: such a frame
|
|
must return None rather than propagate into pysher's dispatch loop.
|
|
"""
|
|
event = json.dumps({"appName": "gmail", "metadata": "not-a-dict"})
|
|
assert subscription._parse_payload(event) is None
|
|
|
|
def test_parse_payload_non_trigger_v3_event(self, subscription):
|
|
"""A non-trigger composio.* event is normalized as a COMPOSIO event."""
|
|
event = json.dumps(
|
|
{
|
|
"id": "evt-1",
|
|
"type": "composio.connected_account.expired",
|
|
"metadata": {"project_id": "pr_1"},
|
|
"data": {"status": "EXPIRED"},
|
|
}
|
|
)
|
|
|
|
result = subscription._parse_payload(event)
|
|
|
|
assert result is not None
|
|
assert result["toolkit_slug"] == "COMPOSIO"
|
|
assert result["trigger_slug"] == "composio.connected_account.expired"
|
|
assert result["original_payload"] == json.loads(event)
|
|
|
|
def test_parse_payload_trigger_empty_metadata_does_not_raise(self, subscription):
|
|
"""A trigger frame with empty metadata is delivered with empty identity."""
|
|
event = json.dumps(
|
|
{
|
|
"id": "evt-1",
|
|
"type": "composio.trigger.message",
|
|
"metadata": {},
|
|
"data": {},
|
|
}
|
|
)
|
|
|
|
result = subscription._parse_payload(event)
|
|
|
|
assert result is not None
|
|
assert result["trigger_slug"] == ""
|
|
assert result["toolkit_slug"] == "UNKNOWN"
|
|
|
|
def test_parse_payload_legacy_missing_optional_fields(self, subscription):
|
|
"""A legacy frame missing optional fields is delivered, not dropped."""
|
|
event = json.dumps(
|
|
{
|
|
"appName": "gmail",
|
|
"payload": {"subject": "hello"},
|
|
# originalPayload + triggerData intentionally omitted
|
|
"metadata": {
|
|
"id": "uuid-1",
|
|
"nanoId": "ti_abc",
|
|
"triggerName": "GMAIL_NEW_GMAIL_MESSAGE",
|
|
"triggerConfig": {},
|
|
"connection": {
|
|
"id": "conn-uuid",
|
|
"connectedAccountNanoId": "ca_abc",
|
|
"authConfigNanoId": "ac_abc",
|
|
"integrationId": "int-uuid",
|
|
"clientUniqueUserId": "user-1",
|
|
"status": "ACTIVE",
|
|
},
|
|
},
|
|
}
|
|
)
|
|
|
|
result = subscription._parse_payload(event)
|
|
|
|
assert result is not None
|
|
assert result["original_payload"] is None
|
|
assert result["metadata"]["trigger_data"] is None
|
|
|
|
def test_parse_payload_trigger_keyed_off_type_not_all_metadata(self, subscription):
|
|
"""A trigger frame missing one metadata field is still a trigger event.
|
|
|
|
Detection keys off ``type == "composio.trigger.message"`` rather than
|
|
requiring all six metadata fields, so the event is delivered (not
|
|
silently demoted to a non-trigger COMPOSIO event).
|
|
"""
|
|
event = json.dumps(
|
|
{
|
|
"id": "evt-1",
|
|
"type": "composio.trigger.message",
|
|
"metadata": {
|
|
# ``log_id`` intentionally omitted
|
|
"trigger_slug": "GMAIL_NEW_GMAIL_MESSAGE",
|
|
"trigger_id": "ti_abc",
|
|
"connected_account_id": "ca_abc",
|
|
"auth_config_id": "ac_abc",
|
|
"user_id": "user-1",
|
|
},
|
|
"data": {"subject": "hello"},
|
|
}
|
|
)
|
|
|
|
result = subscription._parse_payload(event)
|
|
|
|
assert result is not None
|
|
assert result["trigger_slug"] == "GMAIL_NEW_GMAIL_MESSAGE"
|
|
assert result["toolkit_slug"] == "GMAIL"
|
|
assert result["user_id"] == "user-1"
|
|
|
|
def test_parse_payload_non_string_trigger_slug_does_not_raise(self, subscription):
|
|
"""A null/non-string trigger_slug must not raise AttributeError (Vector A)."""
|
|
event = json.dumps(
|
|
{
|
|
"id": "evt-1",
|
|
"type": "composio.trigger.message",
|
|
"metadata": {
|
|
"trigger_slug": None,
|
|
"trigger_id": "ti_abc",
|
|
"connected_account_id": "ca_abc",
|
|
"auth_config_id": "ac_abc",
|
|
"user_id": 123,
|
|
"log_id": "log-1",
|
|
},
|
|
"data": {"subject": "hello"},
|
|
}
|
|
)
|
|
|
|
result = subscription._parse_payload(event)
|
|
|
|
assert result is not None
|
|
assert result["trigger_slug"] == ""
|
|
assert result["toolkit_slug"] == "UNKNOWN"
|
|
assert result["user_id"] == "123"
|
|
|
|
@staticmethod
|
|
def _make_event(**overrides):
|
|
"""Build a minimal TriggerEvent dict for filter-matching tests."""
|
|
event = {
|
|
"id": "ti_1",
|
|
"uuid": "ti_1",
|
|
"user_id": "user-1",
|
|
"toolkit_slug": "GMAIL",
|
|
"trigger_slug": "GMAIL_NEW_GMAIL_MESSAGE",
|
|
"metadata": {
|
|
"id": "ti_1",
|
|
"connected_account": {
|
|
"id": "ca_1",
|
|
"auth_config_id": "ac_1",
|
|
},
|
|
},
|
|
}
|
|
event.update(overrides)
|
|
return event
|
|
|
|
def test_filters_match_no_filters_matches(self, subscription):
|
|
"""An empty filterset matches any event."""
|
|
assert subscription._filters_match(self._make_event(), {}, "cb") is True
|
|
|
|
def test_filters_match_does_not_crash_on_non_string_event_value(self, subscription):
|
|
"""A non-string identity field must not raise AttributeError (Vector B)."""
|
|
event = self._make_event(user_id=123)
|
|
assert subscription._filters_match(event, {"user_id": "123"}, "cb") is True
|
|
assert subscription._filters_match(event, {"user_id": "999"}, "cb") is False
|
|
|
|
def test_filters_match_empty_event_value_does_not_fail_open(self, subscription):
|
|
"""A synthesized empty identity field must not match a "" filter."""
|
|
event = self._make_event(user_id="")
|
|
assert subscription._filters_match(event, {"user_id": ""}, "cb") is False
|
|
|
|
def test_truncate_frame_bounds_long_frames(self):
|
|
"""A long raw frame is truncated so PII-bearing frames aren't dumped."""
|
|
short = "x" * 10
|
|
assert _truncate_frame(short) == short
|
|
|
|
long = "y" * (_MAX_LOGGED_FRAME_CHARS + 100)
|
|
truncated = _truncate_frame(long)
|
|
assert len(truncated) < len(long)
|
|
assert truncated.startswith("y" * _MAX_LOGGED_FRAME_CHARS)
|
|
assert str(len(long)) in truncated
|