Files
2026-07-13 12:38:34 +08:00

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