forked from claude-did-this/claude-hub
* feat: Implement modular webhook architecture for multi-provider support - Add generic webhook types and interfaces for provider-agnostic handling - Create WebhookRegistry for managing providers and event handlers - Implement WebhookProcessor for unified webhook request processing - Add GitHubWebhookProvider implementing the new interfaces - Create new /api/webhooks/:provider endpoint supporting multiple providers - Update GitHub types to include missing id, email, and merged_at properties - Add comprehensive unit tests for all webhook components - Maintain backward compatibility with existing /api/webhooks/github endpoint This architecture enables easy addition of new webhook providers (GitLab, Bitbucket, etc.) while keeping the codebase modular and maintainable. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * security: Implement webhook security enhancements - Add provider name validation against whitelist to prevent arbitrary provider injection - Implement generic error messages to avoid information disclosure - Make webhook signature verification mandatory in production environments - Fix linter warnings in GitHubWebhookProvider.ts - Add comprehensive security tests Security improvements address: - Input validation: Provider names validated against ALLOWED_WEBHOOK_PROVIDERS - Error disclosure: Generic messages replace detailed error information - Authentication: Signature verification cannot be skipped in production 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Fetch complete PR details for manual review commands When processing @MCPClaude review commands on PR comments, the webhook payload only contains minimal PR information. This fix ensures we fetch the complete PR details from GitHub API to get the correct head/base refs and SHA, preventing the "unknown" branch issue. Also fixes test initialization issue in webhooks.test.ts. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Fix failing webhook route tests in CI The webhook route tests were failing because the mock for the GitHub provider module was incomplete. Updated the mock to include the initializeGitHubProvider function to prevent import errors. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Move Jest mocks before imports to prevent auto-initialization The webhook tests were failing in CI because the GitHub provider mock was declared after the imports, allowing the auto-initialization to run. Moving all mocks to the top of the file ensures they are in place before any module loading occurs. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Mock webhook registry to prevent auto-initialization in tests The webhook route tests were failing because the webhook registry was being imported and triggering auto-initialization. By fully mocking the webhook registry module before any imports, we prevent side effects and ensure tests run in isolation. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Properly mock WebhookProcessor to avoid module initialization issues The webhook route tests were failing in CI due to differences in module loading between Node.js versions. By mocking the WebhookProcessor class and moving imports after mocks are set up, we ensure consistent behavior across environments. The mock now properly simulates the authorization logic to maintain test coverage. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Remove side effects from webhook module initialization The webhook tests were failing in CI because the GitHub provider was being auto-initialized during module import, causing unpredictable behavior across different Node.js versions and environments. Changes: - Moved provider initialization to dynamic import in non-test environments - Simplified webhook route tests to avoid complex mocking - Removed unnecessary mocks that were testing implementation details This ensures deterministic test behavior across all environments. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Fix webhook tests mock configuration for secureCredentials The webhook tests were failing with "secureCredentials.get is not a function" because the mock wasn't properly configured for ES module default exports. Changes: - Added __esModule: true to the mock to properly handle default exports - Removed debugging code from tests - Tests now pass consistently in all environments 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
378 lines
11 KiB
TypeScript
378 lines
11 KiB
TypeScript
import crypto from 'crypto';
|
|
import type { Request } from 'express';
|
|
import { GitHubWebhookProvider } from '../../../../src/providers/github/GitHubWebhookProvider';
|
|
import type {
|
|
GitHubRepository,
|
|
GitHubUser,
|
|
GitHubIssue,
|
|
GitHubPullRequest
|
|
} from '../../../../src/types/github';
|
|
|
|
// Mock the logger
|
|
jest.mock('../../../../src/utils/logger', () => ({
|
|
createLogger: () => ({
|
|
info: jest.fn(),
|
|
warn: jest.fn(),
|
|
error: jest.fn(),
|
|
debug: jest.fn()
|
|
})
|
|
}));
|
|
|
|
describe('GitHubWebhookProvider', () => {
|
|
let provider: GitHubWebhookProvider;
|
|
let mockReq: Partial<Request>;
|
|
|
|
beforeEach(() => {
|
|
provider = new GitHubWebhookProvider();
|
|
mockReq = {
|
|
headers: {},
|
|
body: {},
|
|
rawBody: ''
|
|
};
|
|
});
|
|
|
|
describe('verifySignature', () => {
|
|
it('should verify valid signature', async () => {
|
|
const secret = 'test-secret';
|
|
const payload = '{"test":"data"}';
|
|
const hmac = crypto.createHmac('sha256', secret);
|
|
const signature = 'sha256=' + hmac.update(payload).digest('hex');
|
|
|
|
mockReq.headers = { 'x-hub-signature-256': signature };
|
|
mockReq.rawBody = payload;
|
|
|
|
const result = await provider.verifySignature(mockReq as Request, secret);
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should reject invalid signature', async () => {
|
|
mockReq.headers = { 'x-hub-signature-256': 'sha256=invalid' };
|
|
mockReq.rawBody = '{"test":"data"}';
|
|
|
|
const result = await provider.verifySignature(mockReq as Request, 'test-secret');
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('should reject missing signature', async () => {
|
|
mockReq.headers = {};
|
|
mockReq.rawBody = '{"test":"data"}';
|
|
|
|
const result = await provider.verifySignature(mockReq as Request, 'test-secret');
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('should handle missing rawBody', async () => {
|
|
const secret = 'test-secret';
|
|
const payload = { test: 'data' };
|
|
const payloadString = JSON.stringify(payload);
|
|
const hmac = crypto.createHmac('sha256', secret);
|
|
const signature = 'sha256=' + hmac.update(payloadString).digest('hex');
|
|
|
|
mockReq.headers = { 'x-hub-signature-256': signature };
|
|
mockReq.body = payload;
|
|
mockReq.rawBody = undefined;
|
|
|
|
const result = await provider.verifySignature(mockReq as Request, secret);
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should handle signature verification errors', async () => {
|
|
mockReq.headers = { 'x-hub-signature-256': 'invalid-format' };
|
|
mockReq.rawBody = '{"test":"data"}';
|
|
|
|
const result = await provider.verifySignature(mockReq as Request, 'test-secret');
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('parsePayload', () => {
|
|
it('should parse GitHub webhook payload', async () => {
|
|
const mockGitHubPayload = {
|
|
action: 'opened',
|
|
repository: { full_name: 'owner/repo' } as GitHubRepository,
|
|
sender: { login: 'user123' } as GitHubUser,
|
|
installation: {
|
|
id: 12345,
|
|
account: { login: 'org' } as GitHubUser
|
|
}
|
|
};
|
|
|
|
mockReq.headers = {
|
|
'x-github-event': 'issues',
|
|
'x-github-delivery': 'abc-123'
|
|
};
|
|
mockReq.body = mockGitHubPayload;
|
|
|
|
const result = await provider.parsePayload(mockReq as Request);
|
|
|
|
expect(result).toMatchObject({
|
|
id: 'abc-123',
|
|
event: 'issues.opened',
|
|
source: 'github',
|
|
githubEvent: 'issues',
|
|
githubDelivery: 'abc-123',
|
|
action: 'opened',
|
|
repository: mockGitHubPayload.repository,
|
|
sender: mockGitHubPayload.sender,
|
|
installation: mockGitHubPayload.installation,
|
|
data: mockGitHubPayload
|
|
});
|
|
expect(result.timestamp).toBeDefined();
|
|
});
|
|
|
|
it('should handle missing delivery ID', async () => {
|
|
mockReq.headers = {
|
|
'x-github-event': 'push'
|
|
};
|
|
mockReq.body = {};
|
|
|
|
const result = await provider.parsePayload(mockReq as Request);
|
|
|
|
expect(result.id).toBeDefined();
|
|
expect(result.id).not.toBe('');
|
|
expect(result.event).toBe('push');
|
|
});
|
|
|
|
it('should handle events without action', async () => {
|
|
mockReq.headers = {
|
|
'x-github-event': 'push',
|
|
'x-github-delivery': 'xyz-456'
|
|
};
|
|
mockReq.body = {
|
|
repository: { full_name: 'owner/repo' } as GitHubRepository
|
|
};
|
|
|
|
const result = await provider.parsePayload(mockReq as Request);
|
|
|
|
expect(result.event).toBe('push');
|
|
expect(result.action).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('getEventType', () => {
|
|
it('should return the event type', () => {
|
|
const payload = {
|
|
id: '123',
|
|
timestamp: '2024-01-01T00:00:00Z',
|
|
event: 'issues.opened',
|
|
source: 'github',
|
|
githubEvent: 'issues',
|
|
githubDelivery: 'abc-123',
|
|
data: {}
|
|
};
|
|
|
|
const result = provider.getEventType(payload);
|
|
expect(result).toBe('issues.opened');
|
|
});
|
|
});
|
|
|
|
describe('getEventDescription', () => {
|
|
it('should generate description with all parts', () => {
|
|
const payload = {
|
|
id: '123',
|
|
timestamp: '2024-01-01T00:00:00Z',
|
|
event: 'issues.opened',
|
|
source: 'github',
|
|
githubEvent: 'issues',
|
|
githubDelivery: 'abc-123',
|
|
action: 'opened',
|
|
repository: { full_name: 'owner/repo' } as GitHubRepository,
|
|
sender: { login: 'user123' } as GitHubUser,
|
|
data: {}
|
|
};
|
|
|
|
const result = provider.getEventDescription(payload);
|
|
expect(result).toBe('issues opened in owner/repo by user123');
|
|
});
|
|
|
|
it('should handle missing optional parts', () => {
|
|
const payload = {
|
|
id: '123',
|
|
timestamp: '2024-01-01T00:00:00Z',
|
|
event: 'ping',
|
|
source: 'github',
|
|
githubEvent: 'ping',
|
|
githubDelivery: 'abc-123',
|
|
data: {}
|
|
};
|
|
|
|
const result = provider.getEventDescription(payload);
|
|
expect(result).toBe('ping');
|
|
});
|
|
});
|
|
|
|
describe('transformRepository', () => {
|
|
it('should transform GitHub repository to generic format', () => {
|
|
const githubRepo: GitHubRepository = {
|
|
id: 12345,
|
|
name: 'repo',
|
|
full_name: 'owner/repo',
|
|
owner: { login: 'owner' } as GitHubUser,
|
|
private: false,
|
|
default_branch: 'main'
|
|
} as GitHubRepository;
|
|
|
|
const result = GitHubWebhookProvider.transformRepository(githubRepo);
|
|
|
|
expect(result).toEqual({
|
|
id: '12345',
|
|
name: 'repo',
|
|
fullName: 'owner/repo',
|
|
owner: 'owner',
|
|
isPrivate: false,
|
|
defaultBranch: 'main'
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('transformUser', () => {
|
|
it('should transform GitHub user to generic format', () => {
|
|
const githubUser: GitHubUser = {
|
|
id: 123,
|
|
login: 'user123',
|
|
email: 'user@example.com',
|
|
name: 'User Name'
|
|
} as GitHubUser;
|
|
|
|
const result = GitHubWebhookProvider.transformUser(githubUser);
|
|
|
|
expect(result).toEqual({
|
|
id: '123',
|
|
username: 'user123',
|
|
email: 'user@example.com',
|
|
displayName: 'User Name'
|
|
});
|
|
});
|
|
|
|
it('should use login as displayName when name is missing', () => {
|
|
const githubUser: GitHubUser = {
|
|
id: 123,
|
|
login: 'user123'
|
|
} as GitHubUser;
|
|
|
|
const result = GitHubWebhookProvider.transformUser(githubUser);
|
|
|
|
expect(result.displayName).toBe('user123');
|
|
});
|
|
});
|
|
|
|
describe('transformIssue', () => {
|
|
it('should transform GitHub issue to generic format', () => {
|
|
const githubIssue: GitHubIssue = {
|
|
id: 1,
|
|
number: 42,
|
|
title: 'Test Issue',
|
|
body: 'Issue description',
|
|
state: 'open',
|
|
user: { id: 123, login: 'user123' } as GitHubUser,
|
|
labels: [{ name: 'bug' }, 'enhancement'],
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
updated_at: '2024-01-02T00:00:00Z'
|
|
} as GitHubIssue;
|
|
|
|
const result = GitHubWebhookProvider.transformIssue(githubIssue);
|
|
|
|
expect(result).toEqual({
|
|
id: 1,
|
|
number: 42,
|
|
title: 'Test Issue',
|
|
body: 'Issue description',
|
|
state: 'open',
|
|
author: expect.objectContaining({
|
|
id: '123',
|
|
username: 'user123'
|
|
}),
|
|
labels: ['bug', 'enhancement'],
|
|
createdAt: new Date('2024-01-01T00:00:00Z'),
|
|
updatedAt: new Date('2024-01-02T00:00:00Z')
|
|
});
|
|
});
|
|
|
|
it('should handle empty body and labels', () => {
|
|
const githubIssue: GitHubIssue = {
|
|
id: 1,
|
|
number: 42,
|
|
title: 'Test Issue',
|
|
body: null,
|
|
state: 'closed',
|
|
user: { id: 123, login: 'user123' } as GitHubUser,
|
|
labels: undefined,
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
updated_at: '2024-01-02T00:00:00Z'
|
|
} as unknown as GitHubIssue;
|
|
|
|
const result = GitHubWebhookProvider.transformIssue(githubIssue);
|
|
|
|
expect(result.body).toBe('');
|
|
expect(result.labels).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('transformPullRequest', () => {
|
|
it('should transform GitHub PR to generic format', () => {
|
|
const githubPR: GitHubPullRequest = {
|
|
id: 1,
|
|
number: 42,
|
|
title: 'Test PR',
|
|
body: 'PR description',
|
|
state: 'open',
|
|
user: { id: 123, login: 'user123' } as GitHubUser,
|
|
labels: [{ name: 'feature' }],
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
updated_at: '2024-01-02T00:00:00Z',
|
|
head: { ref: 'feature-branch' },
|
|
base: { ref: 'main' },
|
|
draft: false,
|
|
merged: false,
|
|
merged_at: null
|
|
} as GitHubPullRequest;
|
|
|
|
const result = GitHubWebhookProvider.transformPullRequest(githubPR);
|
|
|
|
expect(result).toEqual({
|
|
id: 1,
|
|
number: 42,
|
|
title: 'Test PR',
|
|
body: 'PR description',
|
|
state: 'open',
|
|
author: expect.objectContaining({
|
|
id: '123',
|
|
username: 'user123'
|
|
}),
|
|
labels: ['feature'],
|
|
createdAt: new Date('2024-01-01T00:00:00Z'),
|
|
updatedAt: new Date('2024-01-02T00:00:00Z'),
|
|
sourceBranch: 'feature-branch',
|
|
targetBranch: 'main',
|
|
isDraft: false,
|
|
isMerged: false,
|
|
mergedAt: undefined
|
|
});
|
|
});
|
|
|
|
it('should handle merged PR', () => {
|
|
const githubPR: GitHubPullRequest = {
|
|
id: 1,
|
|
number: 42,
|
|
title: 'Test PR',
|
|
body: 'PR description',
|
|
state: 'closed',
|
|
user: { id: 123, login: 'user123' } as GitHubUser,
|
|
labels: [],
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
updated_at: '2024-01-02T00:00:00Z',
|
|
head: { ref: 'feature-branch' },
|
|
base: { ref: 'main' },
|
|
draft: false,
|
|
merged: true,
|
|
merged_at: '2024-01-02T12:00:00Z'
|
|
} as GitHubPullRequest;
|
|
|
|
const result = GitHubWebhookProvider.transformPullRequest(githubPR);
|
|
|
|
expect(result.isMerged).toBe(true);
|
|
expect(result.mergedAt).toEqual(new Date('2024-01-02T12:00:00Z'));
|
|
});
|
|
});
|
|
});
|