395 lines
13 KiB
TypeScript
395 lines
13 KiB
TypeScript
import * as crypto from 'node:crypto';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { Triggers } from '../../src/models/Triggers';
|
|
import ComposioClient from '@composio/client';
|
|
import { WebhookVersions } from '../../src/types/triggers.types';
|
|
|
|
// 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(),
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Fixture type definition
|
|
*/
|
|
interface WebhookFixture {
|
|
description: string;
|
|
capturedAt: string;
|
|
headers: {
|
|
'webhook-id': string;
|
|
'webhook-timestamp': string;
|
|
'webhook-signature': string;
|
|
};
|
|
payload: string;
|
|
testSecret: string;
|
|
expectedResult: {
|
|
version: string;
|
|
triggerSlug: string;
|
|
userId?: string;
|
|
connectedAccountId?: string;
|
|
triggerId?: string;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Golden signature test case
|
|
*/
|
|
interface GoldenSignatureTestCase {
|
|
name: string;
|
|
id: string;
|
|
timestamp: string;
|
|
payload: string;
|
|
secret: string;
|
|
expectedSignature: string;
|
|
}
|
|
|
|
interface GoldenSignatures {
|
|
description: string;
|
|
algorithm: string;
|
|
format: string;
|
|
testCases: GoldenSignatureTestCase[];
|
|
}
|
|
|
|
/**
|
|
* Load all webhook fixtures from the fixtures directory
|
|
*/
|
|
function loadFixtures(): WebhookFixture[] {
|
|
const fixturesDir = path.join(__dirname, '../fixtures/webhook');
|
|
const fixtureFiles = fs
|
|
.readdirSync(fixturesDir)
|
|
.filter(f => f.startsWith('v') && f.endsWith('.json') && !f.includes('golden'));
|
|
|
|
return fixtureFiles.map(file => {
|
|
const content = fs.readFileSync(path.join(fixturesDir, file), 'utf-8');
|
|
return JSON.parse(content) as WebhookFixture;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Load golden signatures for contract testing
|
|
*/
|
|
function loadGoldenSignatures(): GoldenSignatures {
|
|
const fixturesDir = path.join(__dirname, '../fixtures/webhook');
|
|
const content = fs.readFileSync(path.join(fixturesDir, 'golden-signatures.json'), 'utf-8');
|
|
return JSON.parse(content) as GoldenSignatures;
|
|
}
|
|
|
|
/**
|
|
* Compute signature using the documented algorithm
|
|
*/
|
|
function computeSignature(id: string, timestamp: string, payload: string, secret: string): string {
|
|
const toSign = `${id}.${timestamp}.${payload}`;
|
|
const signature = crypto.createHmac('sha256', secret).update(toSign, 'utf8').digest('base64');
|
|
return `v1,${signature}`;
|
|
}
|
|
|
|
describe('Triggers.verifyWebhook - Integration Tests', () => {
|
|
let triggers: Triggers<any>;
|
|
let mockClient: ReturnType<typeof createMockClient>;
|
|
const fixtures = loadFixtures();
|
|
const goldenSignatures = loadGoldenSignatures();
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockClient = createMockClient();
|
|
triggers = new Triggers(mockClient as unknown as ComposioClient);
|
|
});
|
|
|
|
describe('fixture-based verification', () => {
|
|
it.each(fixtures)('verifies $description', async fixture => {
|
|
const result = await triggers.verifyWebhook({
|
|
payload: fixture.payload,
|
|
signature: fixture.headers['webhook-signature'],
|
|
id: fixture.headers['webhook-id'],
|
|
timestamp: fixture.headers['webhook-timestamp'],
|
|
secret: fixture.testSecret,
|
|
tolerance: 0, // Disable timestamp validation for fixtures
|
|
});
|
|
|
|
expect(result.version).toBe(fixture.expectedResult.version);
|
|
expect(result.payload.triggerSlug).toBe(fixture.expectedResult.triggerSlug);
|
|
|
|
if (fixture.expectedResult.userId) {
|
|
expect(result.payload.userId).toBe(fixture.expectedResult.userId);
|
|
}
|
|
|
|
if (fixture.expectedResult.connectedAccountId) {
|
|
expect(result.payload.metadata.connectedAccount.id).toBe(
|
|
fixture.expectedResult.connectedAccountId
|
|
);
|
|
}
|
|
|
|
if (fixture.expectedResult.triggerId) {
|
|
expect(result.payload.id).toBe(fixture.expectedResult.triggerId);
|
|
}
|
|
});
|
|
|
|
it('detects correct version for V3 payload', async () => {
|
|
const v3Fixture = fixtures.find(f => f.expectedResult.version === 'V3');
|
|
if (!v3Fixture) {
|
|
throw new Error('V3 fixture not found');
|
|
}
|
|
|
|
const result = await triggers.verifyWebhook({
|
|
payload: v3Fixture.payload,
|
|
signature: v3Fixture.headers['webhook-signature'],
|
|
id: v3Fixture.headers['webhook-id'],
|
|
timestamp: v3Fixture.headers['webhook-timestamp'],
|
|
secret: v3Fixture.testSecret,
|
|
tolerance: 0,
|
|
});
|
|
|
|
expect(result.version).toBe(WebhookVersions.V3);
|
|
expect(result.rawPayload).toHaveProperty('type', 'composio.trigger.message');
|
|
expect(result.rawPayload).toHaveProperty('metadata');
|
|
});
|
|
|
|
it('detects correct version for V2 payload', async () => {
|
|
const v2Fixture = fixtures.find(f => f.expectedResult.version === 'V2');
|
|
if (!v2Fixture) {
|
|
throw new Error('V2 fixture not found');
|
|
}
|
|
|
|
const result = await triggers.verifyWebhook({
|
|
payload: v2Fixture.payload,
|
|
signature: v2Fixture.headers['webhook-signature'],
|
|
id: v2Fixture.headers['webhook-id'],
|
|
timestamp: v2Fixture.headers['webhook-timestamp'],
|
|
secret: v2Fixture.testSecret,
|
|
tolerance: 0,
|
|
});
|
|
|
|
expect(result.version).toBe(WebhookVersions.V2);
|
|
expect(result.rawPayload).toHaveProperty('type');
|
|
expect(result.rawPayload).toHaveProperty('data');
|
|
});
|
|
|
|
it('detects correct version for V1 payload', async () => {
|
|
const v1Fixture = fixtures.find(f => f.expectedResult.version === 'V1');
|
|
if (!v1Fixture) {
|
|
throw new Error('V1 fixture not found');
|
|
}
|
|
|
|
const result = await triggers.verifyWebhook({
|
|
payload: v1Fixture.payload,
|
|
signature: v1Fixture.headers['webhook-signature'],
|
|
id: v1Fixture.headers['webhook-id'],
|
|
timestamp: v1Fixture.headers['webhook-timestamp'],
|
|
secret: v1Fixture.testSecret,
|
|
tolerance: 0,
|
|
});
|
|
|
|
expect(result.version).toBe(WebhookVersions.V1);
|
|
expect(result.rawPayload).toHaveProperty('trigger_name');
|
|
expect(result.rawPayload).toHaveProperty('connection_id');
|
|
});
|
|
});
|
|
|
|
describe('golden signature contract tests', () => {
|
|
it.each(goldenSignatures.testCases)(
|
|
'produces identical signature for: $name',
|
|
async testCase => {
|
|
const computed = computeSignature(
|
|
testCase.id,
|
|
testCase.timestamp,
|
|
testCase.payload,
|
|
testCase.secret
|
|
);
|
|
expect(computed).toBe(testCase.expectedSignature);
|
|
}
|
|
);
|
|
|
|
it('algorithm matches documented format', () => {
|
|
expect(goldenSignatures.algorithm).toBe('HMAC-SHA256');
|
|
expect(goldenSignatures.format).toBe('v1,base64(HMAC-SHA256(id.timestamp.payload, secret))');
|
|
});
|
|
});
|
|
|
|
describe('signature algorithm validation', () => {
|
|
it('computes signature using id.timestamp.payload format', async () => {
|
|
const fixture = fixtures[0];
|
|
const expectedSignature = computeSignature(
|
|
fixture.headers['webhook-id'],
|
|
fixture.headers['webhook-timestamp'],
|
|
fixture.payload,
|
|
fixture.testSecret
|
|
);
|
|
|
|
expect(expectedSignature).toBe(fixture.headers['webhook-signature']);
|
|
});
|
|
|
|
it('rejects signature computed with wrong format (payload only)', async () => {
|
|
const fixture = fixtures[0];
|
|
// Wrong format: just payload
|
|
const wrongSignature =
|
|
'v1,' +
|
|
crypto
|
|
.createHmac('sha256', fixture.testSecret)
|
|
.update(fixture.payload, 'utf8')
|
|
.digest('base64');
|
|
|
|
await expect(
|
|
triggers.verifyWebhook({
|
|
payload: fixture.payload,
|
|
signature: wrongSignature,
|
|
id: fixture.headers['webhook-id'],
|
|
timestamp: fixture.headers['webhook-timestamp'],
|
|
secret: fixture.testSecret,
|
|
tolerance: 0,
|
|
})
|
|
).rejects.toThrow('The signature provided is invalid');
|
|
});
|
|
|
|
it('rejects signature computed with wrong format (timestamp.payload)', async () => {
|
|
const fixture = fixtures[0];
|
|
// Wrong format: timestamp.payload (missing id)
|
|
const toSign = `${fixture.headers['webhook-timestamp']}.${fixture.payload}`;
|
|
const wrongSignature =
|
|
'v1,' +
|
|
crypto.createHmac('sha256', fixture.testSecret).update(toSign, 'utf8').digest('base64');
|
|
|
|
await expect(
|
|
triggers.verifyWebhook({
|
|
payload: fixture.payload,
|
|
signature: wrongSignature,
|
|
id: fixture.headers['webhook-id'],
|
|
timestamp: fixture.headers['webhook-timestamp'],
|
|
secret: fixture.testSecret,
|
|
tolerance: 0,
|
|
})
|
|
).rejects.toThrow('The signature provided is invalid');
|
|
});
|
|
});
|
|
|
|
describe('payload structure validation', () => {
|
|
it('preserves exact JSON structure from fixture', async () => {
|
|
const v3Fixture = fixtures.find(f => f.expectedResult.version === 'V3');
|
|
if (!v3Fixture) throw new Error('V3 fixture not found');
|
|
|
|
const result = await triggers.verifyWebhook({
|
|
payload: v3Fixture.payload,
|
|
signature: v3Fixture.headers['webhook-signature'],
|
|
id: v3Fixture.headers['webhook-id'],
|
|
timestamp: v3Fixture.headers['webhook-timestamp'],
|
|
secret: v3Fixture.testSecret,
|
|
tolerance: 0,
|
|
});
|
|
|
|
const parsedPayload = JSON.parse(v3Fixture.payload);
|
|
expect(result.rawPayload).toEqual(parsedPayload);
|
|
});
|
|
|
|
it('normalizes V3 payload to IncomingTriggerPayload format', async () => {
|
|
const v3Fixture = fixtures.find(f => f.expectedResult.version === 'V3');
|
|
if (!v3Fixture) throw new Error('V3 fixture not found');
|
|
|
|
const result = await triggers.verifyWebhook({
|
|
payload: v3Fixture.payload,
|
|
signature: v3Fixture.headers['webhook-signature'],
|
|
id: v3Fixture.headers['webhook-id'],
|
|
timestamp: v3Fixture.headers['webhook-timestamp'],
|
|
secret: v3Fixture.testSecret,
|
|
tolerance: 0,
|
|
});
|
|
|
|
// Verify normalized payload has expected structure
|
|
expect(result.payload).toHaveProperty('id');
|
|
expect(result.payload).toHaveProperty('uuid');
|
|
expect(result.payload).toHaveProperty('triggerSlug');
|
|
expect(result.payload).toHaveProperty('toolkitSlug');
|
|
expect(result.payload).toHaveProperty('userId');
|
|
expect(result.payload).toHaveProperty('payload');
|
|
expect(result.payload).toHaveProperty('metadata');
|
|
expect(result.payload.metadata).toHaveProperty('connectedAccount');
|
|
});
|
|
});
|
|
|
|
describe('whitespace sensitivity', () => {
|
|
it('fails verification if whitespace in payload changes', async () => {
|
|
const fixture = fixtures[0];
|
|
// Add extra whitespace to payload
|
|
const modifiedPayload = fixture.payload.replace('{', '{ ');
|
|
|
|
await expect(
|
|
triggers.verifyWebhook({
|
|
payload: modifiedPayload,
|
|
signature: fixture.headers['webhook-signature'],
|
|
id: fixture.headers['webhook-id'],
|
|
timestamp: fixture.headers['webhook-timestamp'],
|
|
secret: fixture.testSecret,
|
|
tolerance: 0,
|
|
})
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it('fails verification if payload is re-serialized', async () => {
|
|
const fixture = fixtures[0];
|
|
// Parse and re-serialize - may change whitespace/key order
|
|
const reserialized = JSON.stringify(JSON.parse(fixture.payload));
|
|
|
|
// Only test if re-serialization actually changed the payload
|
|
if (reserialized !== fixture.payload) {
|
|
await expect(
|
|
triggers.verifyWebhook({
|
|
payload: reserialized,
|
|
signature: fixture.headers['webhook-signature'],
|
|
id: fixture.headers['webhook-id'],
|
|
timestamp: fixture.headers['webhook-timestamp'],
|
|
secret: fixture.testSecret,
|
|
tolerance: 0,
|
|
})
|
|
).rejects.toThrow();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('cross-fixture consistency', () => {
|
|
it('all fixtures use the same test secret', () => {
|
|
const secrets = new Set(fixtures.map(f => f.testSecret));
|
|
expect(secrets.size).toBe(1);
|
|
expect(secrets.has('test-webhook-secret-for-fixtures')).toBe(true);
|
|
});
|
|
|
|
it('all fixtures have unique webhook IDs', () => {
|
|
const ids = fixtures.map(f => f.headers['webhook-id']);
|
|
const uniqueIds = new Set(ids);
|
|
expect(uniqueIds.size).toBe(fixtures.length);
|
|
});
|
|
|
|
it('fixtures cover all supported versions', () => {
|
|
const versions = new Set(fixtures.map(f => f.expectedResult.version));
|
|
expect(versions.has('V1')).toBe(true);
|
|
expect(versions.has('V2')).toBe(true);
|
|
expect(versions.has('V3')).toBe(true);
|
|
});
|
|
});
|
|
});
|