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

839 lines
27 KiB
TypeScript

import * as crypto from 'node:crypto';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Triggers } from '../../src/models/Triggers';
import ComposioClient from '@composio/client';
import {
WebhookPayloadV1,
WebhookPayloadV2,
WebhookPayloadV3,
WebhookVersions,
} from '../../src/types/triggers.types';
import { ValidationError } from '../../src/errors';
import {
ComposioWebhookSignatureVerificationError,
ComposioWebhookPayloadError,
} from '../../src/errors/TriggerErrors';
// Mock dependencies
vi.mock('../../src/utils/logger');
vi.mock('../../src/telemetry/Telemetry', () => ({
telemetry: {
instrument: vi.fn(),
},
}));
vi.mock('../../src/services/pusher/Pusher');
// Create mock client
const createMockClient = () => ({
baseURL: 'https://api.composio.dev',
apiKey: 'test-api-key',
triggerInstances: {
listActive: vi.fn(),
upsert: vi.fn(),
manage: {
delete: vi.fn(),
update: vi.fn(),
},
},
triggersTypes: {
list: vi.fn(),
retrieve: vi.fn(),
retrieveEnum: vi.fn(),
},
connectedAccounts: {
list: vi.fn(),
},
});
/**
* Helper to create a valid webhook signature in the format used by Composio.
* Format: v1,base64(HMAC-SHA256(msgId.timestamp.payload, secret))
*/
const createSignature = (
webhookId: string,
webhookTimestamp: string,
payload: string,
secret: string
): string => {
const toSign = `${webhookId}.${webhookTimestamp}.${payload}`;
const signature = crypto.createHmac('sha256', secret).update(toSign, 'utf8').digest('base64');
return `v1,${signature}`;
};
// Mock V1 webhook payload
const createMockV1Payload = (overrides: Partial<WebhookPayloadV1> = {}): WebhookPayloadV1 => ({
trigger_name: 'GITHUB_PUSH_EVENT',
connection_id: 'conn-123',
trigger_id: 'trigger-123',
payload: { action: 'push', repository: 'test-repo' },
log_id: 'log-123',
...overrides,
});
// Mock V2 webhook payload
const createMockV2Payload = (overrides: Partial<WebhookPayloadV2> = {}): WebhookPayloadV2 => ({
type: 'github_push_event',
timestamp: new Date().toISOString(),
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',
},
...overrides,
});
// Mock V3 webhook payload
const createMockV3Payload = (overrides: Partial<WebhookPayloadV3> = {}): WebhookPayloadV3 => ({
id: 'msg_abc123',
timestamp: new Date().toISOString(),
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' },
...overrides,
});
describe('Triggers.verifyWebhook', () => {
let triggers: Triggers<any>;
let mockClient: ReturnType<typeof createMockClient>;
const testSecret = 'test-webhook-secret-12345';
const testWebhookId = 'msg_test123';
const testTimestamp = Math.floor(Date.now() / 1000).toString();
beforeEach(() => {
vi.clearAllMocks();
mockClient = createMockClient();
triggers = new Triggers(mockClient as unknown as ComposioClient);
});
describe('successful verification with V3 payload', () => {
it('should verify a valid V3 webhook payload and signature', async () => {
const v3Payload = createMockV3Payload();
const payloadStr = JSON.stringify(v3Payload);
const signature = createSignature(testWebhookId, testTimestamp, payloadStr, testSecret);
const result = await triggers.verifyWebhook({
payload: payloadStr,
signature,
secret: testSecret,
id: testWebhookId,
timestamp: testTimestamp,
});
expect(result).toBeDefined();
expect(result.version).toBe(WebhookVersions.V3);
expect(result.payload.triggerSlug).toBe('GITHUB_PUSH_EVENT');
expect(result.payload.userId).toBe('user-456');
expect(result.rawPayload).toEqual(v3Payload);
});
it('should correctly normalize V3 payload to IncomingTriggerPayload format', async () => {
const v3Payload = createMockV3Payload();
const payloadStr = JSON.stringify(v3Payload);
const signature = createSignature(testWebhookId, testTimestamp, payloadStr, testSecret);
const result = await triggers.verifyWebhook({
payload: payloadStr,
signature,
secret: testSecret,
id: testWebhookId,
timestamp: testTimestamp,
});
expect(result.payload.metadata.connectedAccount).toEqual({
id: 'conn-nano-123',
uuid: 'conn-nano-123',
authConfigId: 'auth-nano-123',
authConfigUUID: 'auth-nano-123',
userId: 'user-456',
status: 'ACTIVE',
});
});
it('should detect V3 payload with non-trigger event type (e.g., connected_account.expired)', async () => {
// This test uses realistic connection metadata (project_id, org_id)
// instead of fabricated trigger metadata, verifying that V3 detection
// works for events with different metadata shapes.
const payload = {
id: 'msg_abc123',
timestamp: new Date().toISOString(),
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',
},
};
const payloadStr = JSON.stringify(payload);
const signature = createSignature(testWebhookId, testTimestamp, payloadStr, testSecret);
const result = await triggers.verifyWebhook({
payload: payloadStr,
signature,
secret: testSecret,
id: testWebhookId,
timestamp: testTimestamp,
});
expect(result.version).toBe(WebhookVersions.V3);
expect(result.rawPayload).toEqual(payload);
});
});
describe('successful verification with V2 payload', () => {
it('should verify a valid V2 webhook payload and signature', async () => {
const v2Payload = createMockV2Payload();
const payloadStr = JSON.stringify(v2Payload);
const signature = createSignature(testWebhookId, testTimestamp, payloadStr, testSecret);
const result = await triggers.verifyWebhook({
payload: payloadStr,
signature,
secret: testSecret,
id: testWebhookId,
timestamp: testTimestamp,
});
expect(result).toBeDefined();
expect(result.version).toBe(WebhookVersions.V2);
expect(result.payload.triggerSlug).toBe('GITHUB_PUSH_EVENT');
expect(result.payload.userId).toBe('user-456');
});
});
describe('successful verification with V1 payload', () => {
it('should verify a valid V1 webhook payload and signature', async () => {
const v1Payload = createMockV1Payload();
const payloadStr = JSON.stringify(v1Payload);
const signature = createSignature(testWebhookId, testTimestamp, payloadStr, testSecret);
const result = await triggers.verifyWebhook({
payload: payloadStr,
signature,
secret: testSecret,
id: testWebhookId,
timestamp: testTimestamp,
});
expect(result).toBeDefined();
expect(result.version).toBe(WebhookVersions.V1);
expect(result.payload.triggerSlug).toBe('GITHUB_PUSH_EVENT');
expect(result.payload.id).toBe('trigger-123');
});
});
describe('tolerance settings', () => {
it('should verify webhook with tolerance set to 0 (skip timestamp validation)', async () => {
const v3Payload = createMockV3Payload();
const payloadStr = JSON.stringify(v3Payload);
// Use an old timestamp
const oldTimestamp = Math.floor((Date.now() - 60 * 60 * 1000) / 1000).toString();
const signature = createSignature(testWebhookId, oldTimestamp, payloadStr, testSecret);
const result = await triggers.verifyWebhook({
payload: payloadStr,
signature,
secret: testSecret,
id: testWebhookId,
timestamp: oldTimestamp,
tolerance: 0,
});
expect(result).toBeDefined();
expect(result.version).toBe(WebhookVersions.V3);
});
it('should verify webhook with custom tolerance', async () => {
const v3Payload = createMockV3Payload();
const payloadStr = JSON.stringify(v3Payload);
const signature = createSignature(testWebhookId, testTimestamp, payloadStr, testSecret);
const result = await triggers.verifyWebhook({
payload: payloadStr,
signature,
secret: testSecret,
id: testWebhookId,
timestamp: testTimestamp,
tolerance: 600, // 10 minutes
});
expect(result).toBeDefined();
});
});
describe('signature verification errors', () => {
it('should throw error when payload is empty', async () => {
await expect(() =>
triggers.verifyWebhook({
payload: '',
signature: 'v1,somesignature',
secret: testSecret,
id: testWebhookId,
timestamp: testTimestamp,
})
).rejects.toThrow(ComposioWebhookSignatureVerificationError);
await expect(() =>
triggers.verifyWebhook({
payload: '',
signature: 'v1,somesignature',
secret: testSecret,
id: testWebhookId,
timestamp: testTimestamp,
})
).rejects.toThrow('No webhook payload was provided.');
});
it('should throw error when signature is empty', async () => {
const payloadStr = JSON.stringify(createMockV3Payload());
await expect(() =>
triggers.verifyWebhook({
payload: payloadStr,
signature: '',
secret: testSecret,
id: testWebhookId,
timestamp: testTimestamp,
})
).rejects.toThrow(ComposioWebhookSignatureVerificationError);
await expect(() =>
triggers.verifyWebhook({
payload: payloadStr,
signature: '',
secret: testSecret,
id: testWebhookId,
timestamp: testTimestamp,
})
).rejects.toThrow('No signature header value was provided.');
});
it('should throw error when secret is empty', async () => {
const payloadStr = JSON.stringify(createMockV3Payload());
await expect(() =>
triggers.verifyWebhook({
payload: payloadStr,
signature: 'v1,somesignature',
secret: '',
id: testWebhookId,
timestamp: testTimestamp,
})
).rejects.toThrow(ComposioWebhookSignatureVerificationError);
await expect(() =>
triggers.verifyWebhook({
payload: payloadStr,
signature: 'v1,somesignature',
secret: '',
id: testWebhookId,
timestamp: testTimestamp,
})
).rejects.toThrow('No webhook secret was provided.');
});
it('should throw error when webhookId is empty', async () => {
const payloadStr = JSON.stringify(createMockV3Payload());
await expect(() =>
triggers.verifyWebhook({
payload: payloadStr,
signature: 'v1,somesignature',
secret: testSecret,
id: '',
timestamp: testTimestamp,
})
).rejects.toThrow(ComposioWebhookSignatureVerificationError);
await expect(() =>
triggers.verifyWebhook({
payload: payloadStr,
signature: 'v1,somesignature',
secret: testSecret,
id: '',
timestamp: testTimestamp,
})
).rejects.toThrow('No webhook ID was provided.');
});
it('should throw error when webhookTimestamp is empty', async () => {
const payloadStr = JSON.stringify(createMockV3Payload());
// Empty timestamp is treated as invalid timestamp format (parseInt('') = NaN)
await expect(() =>
triggers.verifyWebhook({
payload: payloadStr,
signature: 'v1,somesignature',
secret: testSecret,
id: testWebhookId,
timestamp: '',
})
).rejects.toThrow(ComposioWebhookPayloadError);
await expect(() =>
triggers.verifyWebhook({
payload: payloadStr,
signature: 'v1,somesignature',
secret: testSecret,
id: testWebhookId,
timestamp: '',
})
).rejects.toThrow('Invalid webhook timestamp');
});
it('should throw error when signature format is invalid (no v1 prefix)', async () => {
const payloadStr = JSON.stringify(createMockV3Payload());
await expect(() =>
triggers.verifyWebhook({
payload: payloadStr,
signature: 'invalid-signature-no-prefix',
secret: testSecret,
id: testWebhookId,
timestamp: testTimestamp,
})
).rejects.toThrow(ComposioWebhookSignatureVerificationError);
await expect(() =>
triggers.verifyWebhook({
payload: payloadStr,
signature: 'invalid-signature-no-prefix',
secret: testSecret,
id: testWebhookId,
timestamp: testTimestamp,
})
).rejects.toThrow('No valid v1 signature found');
});
it('should throw error when signature is invalid', async () => {
const payloadStr = JSON.stringify(createMockV3Payload());
await expect(() =>
triggers.verifyWebhook({
payload: payloadStr,
signature: 'v1,invalidbase64signature==',
secret: testSecret,
id: testWebhookId,
timestamp: testTimestamp,
})
).rejects.toThrow(ComposioWebhookSignatureVerificationError);
await expect(() =>
triggers.verifyWebhook({
payload: payloadStr,
signature: 'v1,invalidbase64signature==',
secret: testSecret,
id: testWebhookId,
timestamp: testTimestamp,
})
).rejects.toThrow('The signature provided is invalid.');
});
it('should throw error when signature was created with different secret', async () => {
const payloadStr = JSON.stringify(createMockV3Payload());
const signatureWithDifferentSecret = createSignature(
testWebhookId,
testTimestamp,
payloadStr,
'different-secret'
);
await expect(() =>
triggers.verifyWebhook({
payload: payloadStr,
signature: signatureWithDifferentSecret,
secret: testSecret,
id: testWebhookId,
timestamp: testTimestamp,
})
).rejects.toThrow(ComposioWebhookSignatureVerificationError);
});
it('should throw error when payload was modified after signing', async () => {
const originalPayload = createMockV3Payload();
const payloadStr = JSON.stringify(originalPayload);
const signature = createSignature(testWebhookId, testTimestamp, payloadStr, testSecret);
const modifiedPayload = JSON.stringify({
...originalPayload,
data: { modified: true },
});
await expect(() =>
triggers.verifyWebhook({
payload: modifiedPayload,
signature,
secret: testSecret,
id: testWebhookId,
timestamp: testTimestamp,
})
).rejects.toThrow(ComposioWebhookSignatureVerificationError);
});
});
describe('payload parsing errors', () => {
it('should throw error when payload is not valid JSON', async () => {
const invalidJson = 'not-valid-json{';
const signature = createSignature(testWebhookId, testTimestamp, invalidJson, testSecret);
await expect(() =>
triggers.verifyWebhook({
payload: invalidJson,
signature,
secret: testSecret,
id: testWebhookId,
timestamp: testTimestamp,
})
).rejects.toThrow(ComposioWebhookPayloadError);
await expect(() =>
triggers.verifyWebhook({
payload: invalidJson,
signature,
secret: testSecret,
id: testWebhookId,
timestamp: testTimestamp,
})
).rejects.toThrow('Failed to parse webhook payload as JSON');
});
it('should throw error for unrecognized payload format', async () => {
const unknownPayload = JSON.stringify({ unknown: 'format' });
const signature = createSignature(testWebhookId, testTimestamp, unknownPayload, testSecret);
await expect(() =>
triggers.verifyWebhook({
payload: unknownPayload,
signature,
secret: testSecret,
id: testWebhookId,
timestamp: testTimestamp,
})
).rejects.toThrow(ComposioWebhookPayloadError);
await expect(() =>
triggers.verifyWebhook({
payload: unknownPayload,
signature,
secret: testSecret,
id: testWebhookId,
timestamp: testTimestamp,
})
).rejects.toThrow('does not match any known version');
});
it('should include detailed schema errors when payload does not match any version', async () => {
const invalidPayload = JSON.stringify({ invalid: 'data' });
const signature = createSignature(testWebhookId, testTimestamp, invalidPayload, testSecret);
try {
await triggers.verifyWebhook({
payload: invalidPayload,
signature,
secret: testSecret,
id: testWebhookId,
timestamp: testTimestamp,
});
expect.fail('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(ComposioWebhookPayloadError);
const webhookError = error as ComposioWebhookPayloadError;
expect(webhookError.cause).toBeDefined();
// The cause should contain v1Error, v2Error, and v3Error with schema validation messages
expect((webhookError.cause as any).v1Error).toBeDefined();
expect((webhookError.cause as any).v2Error).toBeDefined();
expect((webhookError.cause as any).v3Error).toBeDefined();
}
});
});
describe('timestamp validation', () => {
it('should pass when webhook timestamp is within tolerance', async () => {
const v3Payload = createMockV3Payload();
const payloadStr = JSON.stringify(v3Payload);
const recentTimestamp = Math.floor(Date.now() / 1000).toString();
const signature = createSignature(testWebhookId, recentTimestamp, payloadStr, testSecret);
const result = await triggers.verifyWebhook({
payload: payloadStr,
signature,
secret: testSecret,
id: testWebhookId,
timestamp: recentTimestamp,
tolerance: 300,
});
expect(result).toBeDefined();
});
it('should throw error when webhook timestamp is outside tolerance', async () => {
const v3Payload = createMockV3Payload();
const payloadStr = JSON.stringify(v3Payload);
// 10 minutes ago
const oldTimestamp = Math.floor((Date.now() - 10 * 60 * 1000) / 1000).toString();
const signature = createSignature(testWebhookId, oldTimestamp, payloadStr, testSecret);
await expect(() =>
triggers.verifyWebhook({
payload: payloadStr,
signature,
secret: testSecret,
id: testWebhookId,
timestamp: oldTimestamp,
tolerance: 300, // 5 minutes
})
).rejects.toThrow(ComposioWebhookSignatureVerificationError);
await expect(() =>
triggers.verifyWebhook({
payload: payloadStr,
signature,
secret: testSecret,
id: testWebhookId,
timestamp: oldTimestamp,
tolerance: 300,
})
).rejects.toThrow('The webhook timestamp is outside the allowed tolerance');
});
it('should throw error for invalid timestamp format', async () => {
const v3Payload = createMockV3Payload();
const payloadStr = JSON.stringify(v3Payload);
const invalidTimestamp = 'not-a-timestamp';
const signature = createSignature(testWebhookId, invalidTimestamp, payloadStr, testSecret);
await expect(() =>
triggers.verifyWebhook({
payload: payloadStr,
signature,
secret: testSecret,
id: testWebhookId,
timestamp: invalidTimestamp,
tolerance: 300,
})
).rejects.toThrow(ComposioWebhookPayloadError);
await expect(() =>
triggers.verifyWebhook({
payload: payloadStr,
signature,
secret: testSecret,
id: testWebhookId,
timestamp: invalidTimestamp,
tolerance: 300,
})
).rejects.toThrow('Invalid webhook timestamp');
});
});
describe('input validation', () => {
it('should throw ValidationError for missing payload parameter', async () => {
await expect(() =>
triggers.verifyWebhook({
signature: 'v1,sig',
secret: testSecret,
webhookId: testWebhookId,
webhookTimestamp: testTimestamp,
} as any)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError for missing signature parameter', async () => {
await expect(() =>
triggers.verifyWebhook({
payload: '{}',
secret: testSecret,
webhookId: testWebhookId,
webhookTimestamp: testTimestamp,
} as any)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError for missing secret parameter', async () => {
await expect(() =>
triggers.verifyWebhook({
payload: '{}',
signature: 'v1,sig',
webhookId: testWebhookId,
webhookTimestamp: testTimestamp,
} as any)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError for missing webhookId parameter', async () => {
await expect(() =>
triggers.verifyWebhook({
payload: '{}',
signature: 'v1,sig',
secret: testSecret,
webhookTimestamp: testTimestamp,
} as any)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError for missing webhookTimestamp parameter', async () => {
await expect(() =>
triggers.verifyWebhook({
payload: '{}',
signature: 'v1,sig',
secret: testSecret,
webhookId: testWebhookId,
} as any)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError for invalid tolerance type', async () => {
await expect(() =>
triggers.verifyWebhook({
payload: '{}',
signature: 'v1,sig',
secret: testSecret,
id: testWebhookId,
timestamp: testTimestamp,
tolerance: 'invalid' as any,
})
).rejects.toThrow(ValidationError);
});
});
describe('security considerations', () => {
it('should use timing-safe comparison for signatures', async () => {
const v3Payload = createMockV3Payload();
const payloadStr = JSON.stringify(v3Payload);
const validSignature = createSignature(testWebhookId, testTimestamp, payloadStr, testSecret);
// Valid signature should work
await expect(
triggers.verifyWebhook({
payload: payloadStr,
signature: validSignature,
secret: testSecret,
id: testWebhookId,
timestamp: testTimestamp,
})
).resolves.toBeDefined();
// Invalid signature with same format should fail
const invalidSignature = 'v1,' + 'a'.repeat(44); // base64 SHA256 is 44 chars
await expect(() =>
triggers.verifyWebhook({
payload: payloadStr,
signature: invalidSignature,
secret: testSecret,
id: testWebhookId,
timestamp: testTimestamp,
})
).rejects.toThrow(ComposioWebhookSignatureVerificationError);
});
it('should handle unicode in payload correctly', async () => {
const v3Payload = createMockV3Payload({ data: { message: '你好世界 🌍 مرحبا' } });
const payloadStr = JSON.stringify(v3Payload);
const signature = createSignature(testWebhookId, testTimestamp, payloadStr, testSecret);
const result = await triggers.verifyWebhook({
payload: payloadStr,
signature,
secret: testSecret,
id: testWebhookId,
timestamp: testTimestamp,
});
expect(result).toBeDefined();
expect(result.payload.payload).toEqual({ message: '你好世界 🌍 مرحبا' });
});
it('should handle special characters in secret', async () => {
const specialSecret = 'secret!@#$%^&*()_+-=[]{}|;:,.<>?';
const v3Payload = createMockV3Payload();
const payloadStr = JSON.stringify(v3Payload);
const signature = createSignature(testWebhookId, testTimestamp, payloadStr, specialSecret);
const result = await triggers.verifyWebhook({
payload: payloadStr,
signature,
secret: specialSecret,
id: testWebhookId,
timestamp: testTimestamp,
});
expect(result).toBeDefined();
});
it('should support multiple signatures in header', async () => {
const v3Payload = createMockV3Payload();
const payloadStr = JSON.stringify(v3Payload);
const validSignature = createSignature(testWebhookId, testTimestamp, payloadStr, testSecret);
// Multiple signatures space-separated
const multipleSignatures = `v1,invalidsig== ${validSignature}`;
const result = await triggers.verifyWebhook({
payload: payloadStr,
signature: multipleSignatures,
secret: testSecret,
id: testWebhookId,
timestamp: testTimestamp,
});
expect(result).toBeDefined();
});
});
describe('error properties', () => {
it('should include proper error class for signature verification error', async () => {
const payloadStr = JSON.stringify(createMockV3Payload());
try {
await triggers.verifyWebhook({
payload: payloadStr,
signature: 'v1,invalid',
secret: testSecret,
id: testWebhookId,
timestamp: testTimestamp,
});
expect.fail('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(ComposioWebhookSignatureVerificationError);
expect((error as ComposioWebhookSignatureVerificationError).name).toBe(
'ComposioWebhookSignatureVerificationError'
);
}
});
it('should include proper error class for payload error', async () => {
const invalidPayload = 'not-json';
const signature = createSignature(testWebhookId, testTimestamp, invalidPayload, testSecret);
try {
await triggers.verifyWebhook({
payload: invalidPayload,
signature,
secret: testSecret,
id: testWebhookId,
timestamp: testTimestamp,
});
expect.fail('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(ComposioWebhookPayloadError);
expect((error as ComposioWebhookPayloadError).name).toBe('ComposioWebhookPayloadError');
}
});
});
});