forked from claude-did-this/claude-hub
Refactor test files and standardize crypto signature patterns
- Create unified SignatureHelper utility for consistent crypto operations - Create WebhookTestHelper for streamlined webhook testing - Remove duplicate test files and consolidate functionality - Update generate-signature.js to use new utilities and remove hardcoded secrets - Fix webhook signature verification to handle different buffer lengths - Standardize test patterns across webhook and unit tests 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -63,7 +63,11 @@ function verifyWebhookSignature(req) {
|
||||
}
|
||||
|
||||
// Properly verify the signature using timing-safe comparison
|
||||
if (crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(calculatedSignature))) {
|
||||
// Check lengths first to avoid timingSafeEqual error with different-length buffers
|
||||
if (
|
||||
signature.length === calculatedSignature.length &&
|
||||
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(calculatedSignature))
|
||||
) {
|
||||
logger.debug('Webhook signature verification succeeded');
|
||||
return true;
|
||||
}
|
||||
@@ -425,21 +429,25 @@ Please check with an administrator to review the logs for more details.`
|
||||
);
|
||||
|
||||
// Only proceed if the check suite is for a pull request and conclusion is success
|
||||
if (checkSuite.conclusion === 'success' && checkSuite.pull_requests && checkSuite.pull_requests.length > 0) {
|
||||
if (
|
||||
checkSuite.conclusion === 'success' &&
|
||||
checkSuite.pull_requests &&
|
||||
checkSuite.pull_requests.length > 0
|
||||
) {
|
||||
try {
|
||||
// Process PRs in parallel for better performance
|
||||
const prPromises = checkSuite.pull_requests.map(async (pr) => {
|
||||
const prPromises = checkSuite.pull_requests.map(async pr => {
|
||||
const prResult = {
|
||||
prNumber: pr.number,
|
||||
success: false,
|
||||
error: null,
|
||||
skippedReason: null
|
||||
};
|
||||
|
||||
|
||||
try {
|
||||
// Extract SHA from PR data first, only fall back to check suite SHA if absolutely necessary
|
||||
const commitSha = pr.head?.sha;
|
||||
|
||||
|
||||
if (!commitSha) {
|
||||
logger.error(
|
||||
{
|
||||
@@ -458,10 +466,10 @@ Please check with an administrator to review the logs for more details.`
|
||||
prResult.error = 'Missing PR head SHA';
|
||||
return prResult;
|
||||
}
|
||||
|
||||
// Note: We rely on the check_suite conclusion being 'success'
|
||||
|
||||
// Note: We rely on the check_suite conclusion being 'success'
|
||||
// which already indicates all checks have passed.
|
||||
// The Combined Status API (legacy) won't show results for
|
||||
// The Combined Status API (legacy) won't show results for
|
||||
// modern GitHub Actions check runs.
|
||||
|
||||
// Check if we've already reviewed this PR at this commit
|
||||
@@ -722,7 +730,7 @@ Please perform a comprehensive review of PR #${pr.number} in repository ${repo.f
|
||||
},
|
||||
'Automated PR review completed successfully'
|
||||
);
|
||||
|
||||
|
||||
// Update label to show review is complete
|
||||
try {
|
||||
await githubService.managePRLabels({
|
||||
@@ -743,7 +751,7 @@ Please perform a comprehensive review of PR #${pr.number} in repository ${repo.f
|
||||
);
|
||||
// Don't fail the review if label update fails
|
||||
}
|
||||
|
||||
|
||||
prResult.success = true;
|
||||
return prResult;
|
||||
} catch (reviewError) {
|
||||
@@ -757,7 +765,7 @@ Please perform a comprehensive review of PR #${pr.number} in repository ${repo.f
|
||||
},
|
||||
'Error processing automated PR review'
|
||||
);
|
||||
|
||||
|
||||
// Remove in-progress label on error
|
||||
try {
|
||||
await githubService.managePRLabels({
|
||||
@@ -776,23 +784,25 @@ Please perform a comprehensive review of PR #${pr.number} in repository ${repo.f
|
||||
'Failed to remove review-in-progress label after error'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
prResult.error = reviewError.message || 'Unknown error during review';
|
||||
return prResult;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Wait for all PR reviews to complete
|
||||
const results = await Promise.allSettled(prPromises);
|
||||
const prResults = results.map(result =>
|
||||
const prResults = results.map(result =>
|
||||
result.status === 'fulfilled' ? result.value : { success: false, error: result.reason }
|
||||
);
|
||||
|
||||
|
||||
// Count successes and failures (mutually exclusive)
|
||||
const successCount = prResults.filter(r => r.success).length;
|
||||
const failureCount = prResults.filter(r => !r.success && r.error && !r.skippedReason).length;
|
||||
const failureCount = prResults.filter(
|
||||
r => !r.success && r.error && !r.skippedReason
|
||||
).length;
|
||||
const skippedCount = prResults.filter(r => !r.success && r.skippedReason).length;
|
||||
|
||||
|
||||
logger.info(
|
||||
{
|
||||
repo: repo.full_name,
|
||||
@@ -805,7 +815,7 @@ Please perform a comprehensive review of PR #${pr.number} in repository ${repo.f
|
||||
},
|
||||
'Check suite PR review processing completed'
|
||||
);
|
||||
|
||||
|
||||
// Return detailed status
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
@@ -826,7 +836,7 @@ Please perform a comprehensive review of PR #${pr.number} in repository ${repo.f
|
||||
},
|
||||
'Error processing check suite for PR reviews'
|
||||
);
|
||||
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to process check suite',
|
||||
@@ -850,7 +860,7 @@ Please perform a comprehensive review of PR #${pr.number} in repository ${repo.f
|
||||
},
|
||||
'Check suite succeeded but no pull requests found in payload - possible fork PR'
|
||||
);
|
||||
|
||||
|
||||
// TODO: Could query GitHub API to find PRs for this branch/SHA
|
||||
// For now, just acknowledge the webhook
|
||||
return res.status(200).json({
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const SignatureHelper = require('./utils/signatureHelper');
|
||||
|
||||
const webhookSecret = '17DEE6196F8C9804EB536315536F5A44600078FDEEEA646EF2AFBFB1876F3E0F'; // Same as in .env file
|
||||
const webhookSecret = process.env.GITHUB_WEBHOOK_SECRET || 'test_secret';
|
||||
const payloadPath = process.argv[2] || './test-payload.json';
|
||||
const webhookUrl = process.argv[3] || 'http://localhost:3001/api/webhooks/github';
|
||||
|
||||
// Read the payload file
|
||||
const payload = fs.readFileSync(payloadPath, 'utf8');
|
||||
|
||||
// Calculate the signature
|
||||
const hmac = crypto.createHmac('sha256', webhookSecret);
|
||||
const signature = 'sha256=' + hmac.update(payload).digest('hex');
|
||||
// Calculate the signature using the utility
|
||||
const signature = SignatureHelper.createGitHubSignature(payload, webhookSecret);
|
||||
|
||||
console.log('X-Hub-Signature-256:', signature);
|
||||
console.log('\nCommand to test the webhook:');
|
||||
console.log(`curl -X POST \\
|
||||
http://localhost:3001/api/webhooks/github \\
|
||||
${webhookUrl} \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-GitHub-Event: issue_comment" \\
|
||||
-H "X-Hub-Signature-256: ${signature}" \\
|
||||
-d @${payloadPath}`);
|
||||
-d @${payloadPath}`);
|
||||
|
||||
@@ -1,66 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const axios = require('axios');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Mock GitHub issue opened event payload
|
||||
const mockPayload = {
|
||||
action: 'opened',
|
||||
issue: {
|
||||
number: 123,
|
||||
title: 'Application crashes when loading user data',
|
||||
body: 'The app consistently crashes when trying to load user profiles. This appears to be a critical bug affecting all users. Error occurs in the API endpoint.',
|
||||
user: {
|
||||
login: 'testuser'
|
||||
}
|
||||
},
|
||||
repository: {
|
||||
name: 'test-repo',
|
||||
full_name: 'testowner/test-repo',
|
||||
owner: {
|
||||
login: 'testowner'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Function to create GitHub webhook signature
|
||||
function createSignature(payload, secret) {
|
||||
const hmac = crypto.createHmac('sha256', secret);
|
||||
hmac.update(JSON.stringify(payload));
|
||||
return `sha256=${hmac.digest('hex')}`;
|
||||
}
|
||||
const WebhookTestHelper = require('./utils/webhookTestHelper');
|
||||
|
||||
async function testIssueWebhook() {
|
||||
const webhookUrl = 'http://localhost:8082/api/webhooks/github';
|
||||
const webhookUrl = process.env.WEBHOOK_URL || 'http://localhost:8082/api/webhooks/github';
|
||||
const secret = process.env.GITHUB_WEBHOOK_SECRET || 'test-secret';
|
||||
|
||||
const webhookHelper = new WebhookTestHelper(webhookUrl, secret);
|
||||
|
||||
try {
|
||||
console.log('Testing issue webhook with mock payload...');
|
||||
console.log('Issue:', mockPayload.issue.title);
|
||||
console.log('Testing issue opened webhook for auto-tagging...');
|
||||
|
||||
const signature = createSignature(mockPayload, secret);
|
||||
|
||||
const response = await axios.post(webhookUrl, mockPayload, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-GitHub-Event': 'issues',
|
||||
'X-GitHub-Delivery': 'test-delivery-id',
|
||||
'X-Hub-Signature-256': signature,
|
||||
'User-Agent': 'GitHub-Hookshot/test'
|
||||
},
|
||||
timeout: 30000
|
||||
const result = await webhookHelper.testIssueOpened({
|
||||
title: 'Application crashes when loading user data',
|
||||
body: 'The app consistently crashes when trying to load user profiles. This appears to be a critical bug affecting all users. Error occurs in the API endpoint.'
|
||||
});
|
||||
|
||||
console.log('✓ Webhook response status:', response.status);
|
||||
console.log('✓ Response data:', response.data);
|
||||
} catch (error) {
|
||||
console.error('✗ Webhook test failed:');
|
||||
if (error.response) {
|
||||
console.error('Status:', error.response.status);
|
||||
console.error('Data:', error.response.data);
|
||||
if (result.success) {
|
||||
console.log('✓ Webhook response status:', result.status);
|
||||
console.log('✓ Response data:', result.data);
|
||||
console.log('Issue:', result.payload.issue.title);
|
||||
} else {
|
||||
console.error('Error:', error.message);
|
||||
console.error('✗ Webhook test failed:');
|
||||
console.error('Status:', result.status);
|
||||
console.error('Data:', result.data);
|
||||
console.error('Error:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('✗ Unexpected error:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,4 +35,4 @@ if (require.main === module) {
|
||||
testIssueWebhook();
|
||||
}
|
||||
|
||||
module.exports = { testIssueWebhook, mockPayload };
|
||||
module.exports = { testIssueWebhook };
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
const http = require('http');
|
||||
// const { promisify } = require('util');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const axios = require('axios');
|
||||
|
||||
// Configuration
|
||||
const port = 3002; // Different from the main server
|
||||
const webhookSecret = 'testing_webhook_secret';
|
||||
const testPayloadPath = './test-payload.json';
|
||||
const mainServerUrl = 'http://localhost:3001/api/webhooks/github';
|
||||
|
||||
// Create a simple webhook receiving server
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (req.method === 'POST') {
|
||||
console.log('Received webhook request');
|
||||
console.log('Headers:', req.headers);
|
||||
|
||||
let body = '';
|
||||
req.on('data', chunk => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
|
||||
req.on('end', () => {
|
||||
console.log('Webhook Payload:', body);
|
||||
|
||||
// Verify signature if sent
|
||||
if (req.headers['x-hub-signature-256']) {
|
||||
const hmac = crypto.createHmac('sha256', webhookSecret);
|
||||
const signature = 'sha256=' + hmac.update(body).digest('hex');
|
||||
console.log('Expected signature:', signature);
|
||||
console.log('Received signature:', req.headers['x-hub-signature-256']);
|
||||
|
||||
if (signature === req.headers['x-hub-signature-256']) {
|
||||
console.log('✅ Signature verification passed');
|
||||
} else {
|
||||
console.log('❌ Signature verification failed');
|
||||
}
|
||||
}
|
||||
|
||||
// Send response
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
status: 'success',
|
||||
message: 'Webhook received successfully',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
);
|
||||
});
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
// Start the server
|
||||
server.listen(port, async () => {
|
||||
console.log(`Test webhook receiver listening on port ${port}`);
|
||||
|
||||
try {
|
||||
// Read the test payload
|
||||
const payload = fs.readFileSync(testPayloadPath, 'utf8');
|
||||
|
||||
// Calculate the signature for GitHub webhook
|
||||
const hmac = crypto.createHmac('sha256', webhookSecret);
|
||||
const signature = 'sha256=' + hmac.update(payload).digest('hex');
|
||||
|
||||
console.log('Test setup:');
|
||||
console.log('- Webhook receiver is running on port', port);
|
||||
console.log('- Will send test payload to main server at', mainServerUrl);
|
||||
console.log('- Signature calculated for GitHub webhook:', signature);
|
||||
|
||||
console.log('\nMake sure your .env file contains:');
|
||||
console.log('GITHUB_WEBHOOK_SECRET=testing_webhook_secret');
|
||||
console.log('OUTGOING_WEBHOOK_SECRET=testing_webhook_secret');
|
||||
console.log(
|
||||
`OUTGOING_WEBHOOK_URLS=http://localhost:${port},https://claude.jonathanflatt.org/webhook`
|
||||
);
|
||||
console.log('COMMENT_WEBHOOK_URLS=https://claude.jonathanflatt.org/comment-webhook');
|
||||
|
||||
console.log('\nYou can now manually test the webhook by running:');
|
||||
console.log(`curl -X POST \\
|
||||
${mainServerUrl} \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-GitHub-Event: issue_comment" \\
|
||||
-H "X-Hub-Signature-256: ${signature}" \\
|
||||
-d @${testPayloadPath}`);
|
||||
|
||||
console.log('\nOr press Enter to send the test webhook automatically...');
|
||||
|
||||
// Wait for user input
|
||||
process.stdin.once('data', async () => {
|
||||
try {
|
||||
console.log('\nSending test webhook to main server...');
|
||||
|
||||
// Send the webhook
|
||||
const response = await axios.post(mainServerUrl, JSON.parse(payload), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-GitHub-Event': 'issue_comment',
|
||||
'X-Hub-Signature-256': signature
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Main server response (${response.status}):`, response.data);
|
||||
console.log(
|
||||
'\nIf everything is set up correctly, you should see a webhook received above ☝️'
|
||||
);
|
||||
console.log('\nPress Ctrl+C to exit');
|
||||
} catch (error) {
|
||||
console.error('Error sending test webhook:', error.message);
|
||||
if (error.response) {
|
||||
console.error('Response data:', error.response.data);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in test setup:', error.message);
|
||||
}
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
const https = require('https');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
|
||||
// Configuration
|
||||
const webhookSecret = '17DEE6196F8C9804EB536315536F5A44600078FDEEEA646EF2AFBFB1876F3E0F';
|
||||
const payload = fs.readFileSync('./test-payload.json', 'utf8');
|
||||
const url = 'https://claude.jonathanflatt.org/api/webhooks/github';
|
||||
|
||||
// Generate signature
|
||||
const hmac = crypto.createHmac('sha256', webhookSecret);
|
||||
const signature = 'sha256=' + hmac.update(payload).digest('hex');
|
||||
|
||||
console.log('Webhook URL:', url);
|
||||
console.log('Payload:', JSON.parse(payload));
|
||||
console.log('Generated signature:', signature);
|
||||
|
||||
// Parse URL
|
||||
const urlParts = new URL(url);
|
||||
|
||||
// Prepare request
|
||||
const options = {
|
||||
hostname: urlParts.hostname,
|
||||
port: urlParts.port || 443,
|
||||
path: urlParts.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-GitHub-Event': 'issue_comment',
|
||||
'X-Hub-Signature-256': signature,
|
||||
'Content-Length': Buffer.byteLength(payload)
|
||||
}
|
||||
};
|
||||
|
||||
// Make request
|
||||
const req = https.request(options, res => {
|
||||
console.log(`\nResponse status: ${res.statusCode}`);
|
||||
console.log('Response headers:', res.headers);
|
||||
|
||||
let data = '';
|
||||
res.on('data', chunk => {
|
||||
data += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
console.log('Response body:', data);
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', e => {
|
||||
console.error('Request error:', e.message);
|
||||
});
|
||||
|
||||
// Send the request
|
||||
req.write(payload);
|
||||
req.end();
|
||||
|
||||
console.log('\nSending webhook to:', url);
|
||||
@@ -5,65 +5,43 @@
|
||||
* instead of posting to GitHub
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const WebhookTestHelper = require('./utils/webhookTestHelper');
|
||||
|
||||
const API_URL = process.env.API_URL || 'http://localhost:3003';
|
||||
|
||||
// Sample webhook payload
|
||||
const payload = {
|
||||
action: 'created',
|
||||
issue: {
|
||||
number: 1,
|
||||
title: 'Test Issue',
|
||||
body: 'Test issue body'
|
||||
},
|
||||
comment: {
|
||||
id: 123,
|
||||
body: `${process.env.BOT_USERNAME || '@ClaudeBot'} Test command for webhook response`,
|
||||
user: {
|
||||
login: 'testuser'
|
||||
}
|
||||
},
|
||||
repository: {
|
||||
full_name: 'test/repo',
|
||||
name: 'repo',
|
||||
owner: {
|
||||
login: 'test'
|
||||
}
|
||||
},
|
||||
sender: {
|
||||
login: 'testuser'
|
||||
}
|
||||
};
|
||||
const secret = process.env.GITHUB_WEBHOOK_SECRET || 'test_secret';
|
||||
|
||||
async function testWebhookResponse() {
|
||||
try {
|
||||
console.log('Sending webhook request to:', `${API_URL}/api/webhooks/github`);
|
||||
console.log('Payload:', JSON.stringify(payload, null, 2));
|
||||
const webhookHelper = new WebhookTestHelper(`${API_URL}/api/webhooks/github`, secret);
|
||||
|
||||
const response = await axios.post(`${API_URL}/api/webhooks/github`, payload, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-GitHub-Event': 'issue_comment',
|
||||
'X-Hub-Signature-256': 'sha256=dummy-signature',
|
||||
'X-GitHub-Delivery': 'test-delivery-' + Date.now()
|
||||
}
|
||||
try {
|
||||
console.log('Testing webhook response with Claude response...\n');
|
||||
console.log('Sending webhook request to:', `${API_URL}/api/webhooks/github`);
|
||||
|
||||
const result = await webhookHelper.testIssueComment({
|
||||
commentBody: `${process.env.BOT_USERNAME || '@ClaudeBot'} Test command for webhook response`,
|
||||
repoOwner: 'test',
|
||||
repoName: 'repo',
|
||||
issueNumber: 1,
|
||||
userLogin: 'testuser'
|
||||
});
|
||||
|
||||
console.log('\nResponse Status:', response.status);
|
||||
console.log('Response Data:', JSON.stringify(response.data, null, 2));
|
||||
console.log('Payload:', JSON.stringify(result.payload, null, 2));
|
||||
console.log('\nResponse Status:', result.status);
|
||||
console.log('Response Data:', JSON.stringify(result.data, null, 2));
|
||||
|
||||
if (response.data.claudeResponse) {
|
||||
if (result.success && result.data.claudeResponse) {
|
||||
console.log('\n✅ Success! Claude response received in webhook response:');
|
||||
console.log(response.data.claudeResponse);
|
||||
} else {
|
||||
console.log(result.data.claudeResponse);
|
||||
} else if (result.success) {
|
||||
console.log('\n❌ No Claude response found in webhook response');
|
||||
} else {
|
||||
console.log('\n❌ Webhook request failed:');
|
||||
console.log('Error:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('\nError:', error.response ? error.response.data : error.message);
|
||||
console.error('\nUnexpected error:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Testing webhook response with Claude response...\n');
|
||||
testWebhookResponse();
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Set required environment variables before requiring modules
|
||||
process.env.BOT_USERNAME = '@TestBot';
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.GITHUB_TOKEN = 'test_token';
|
||||
process.env.AUTHORIZED_USERS = 'testuser,admin';
|
||||
|
||||
// Mock services before requiring actual modules
|
||||
jest.mock('../../src/services/claudeService', () => ({
|
||||
processCommand: jest.fn().mockResolvedValue('Claude response')
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/githubService', () => ({
|
||||
postComment: jest.fn().mockResolvedValue({ id: 456 })
|
||||
}));
|
||||
|
||||
// Now require modules after environment and mocks are set up
|
||||
const githubController = require('../../src/controllers/githubController');
|
||||
const claudeService = require('../../src/services/claudeService');
|
||||
const githubService = require('../../src/services/githubService');
|
||||
|
||||
describe('GitHub Controller', () => {
|
||||
let req, res;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Create request and response mocks
|
||||
req = {
|
||||
headers: {
|
||||
'x-github-event': 'issue_comment',
|
||||
'x-hub-signature-256': '',
|
||||
'x-github-delivery': 'test-delivery-id'
|
||||
},
|
||||
body: {
|
||||
action: 'created',
|
||||
comment: {
|
||||
body: '@TestBot Tell me about this repository',
|
||||
id: 123456,
|
||||
user: {
|
||||
login: 'testuser'
|
||||
}
|
||||
},
|
||||
issue: {
|
||||
number: 123
|
||||
},
|
||||
repository: {
|
||||
full_name: 'owner/repo',
|
||||
name: 'repo',
|
||||
owner: {
|
||||
login: 'owner'
|
||||
}
|
||||
},
|
||||
sender: {
|
||||
login: 'testuser'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn()
|
||||
};
|
||||
|
||||
// Mock the environment variables
|
||||
process.env.GITHUB_WEBHOOK_SECRET = 'test_secret';
|
||||
|
||||
// Set up the signature
|
||||
const payload = JSON.stringify(req.body);
|
||||
const hmac = crypto.createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET);
|
||||
req.headers['x-hub-signature-256'] = 'sha256=' + hmac.update(payload).digest('hex');
|
||||
|
||||
// Mock successful responses from services
|
||||
claudeService.processCommand.mockResolvedValue('Claude response');
|
||||
githubService.postComment.mockResolvedValue({ id: 456 });
|
||||
});
|
||||
|
||||
test('should process a valid webhook with @TestBot mention', async () => {
|
||||
await githubController.handleWebhook(req, res);
|
||||
|
||||
// Verify that Claude service was called with correct parameters
|
||||
expect(claudeService.processCommand).toHaveBeenCalledWith({
|
||||
repoFullName: 'owner/repo',
|
||||
issueNumber: 123,
|
||||
command: 'Tell me about this repository',
|
||||
isPullRequest: false,
|
||||
branchName: null
|
||||
});
|
||||
|
||||
// Verify that GitHub service was called to post a comment
|
||||
expect(githubService.postComment).toHaveBeenCalledWith({
|
||||
repoOwner: 'owner',
|
||||
repoName: 'repo',
|
||||
issueNumber: 123,
|
||||
body: 'Claude response'
|
||||
});
|
||||
|
||||
// Verify response
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: true,
|
||||
message: 'Command processed and response posted'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should reject a webhook with invalid signature', async () => {
|
||||
// Tamper with the signature
|
||||
req.headers['x-hub-signature-256'] = 'sha256=invalid_signature';
|
||||
|
||||
// Reset mocks before test
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Set NODE_ENV to production for this test to enable signature verification
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
await githubController.handleWebhook(req, res);
|
||||
|
||||
// Restore NODE_ENV
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
|
||||
// Verify that services were not called
|
||||
expect(claudeService.processCommand).not.toHaveBeenCalled();
|
||||
expect(githubService.postComment).not.toHaveBeenCalled();
|
||||
|
||||
// Verify response
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'Invalid webhook signature'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should ignore comments without @TestBot mention', async () => {
|
||||
// Remove the @TestBot mention
|
||||
req.body.comment.body = 'This is a regular comment';
|
||||
|
||||
// Update the signature
|
||||
const payload = JSON.stringify(req.body);
|
||||
const hmac = crypto.createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET);
|
||||
req.headers['x-hub-signature-256'] = 'sha256=' + hmac.update(payload).digest('hex');
|
||||
|
||||
await githubController.handleWebhook(req, res);
|
||||
|
||||
// Verify that services were not called
|
||||
expect(claudeService.processCommand).not.toHaveBeenCalled();
|
||||
expect(githubService.postComment).not.toHaveBeenCalled();
|
||||
|
||||
// Verify response
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: 'Webhook processed successfully' });
|
||||
});
|
||||
|
||||
test('should handle errors from Claude service', async () => {
|
||||
// Make Claude service throw an error
|
||||
claudeService.processCommand.mockRejectedValue(new Error('Claude error'));
|
||||
|
||||
await githubController.handleWebhook(req, res);
|
||||
|
||||
// Verify that GitHub service was called to post an error comment
|
||||
expect(githubService.postComment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
repoOwner: 'owner',
|
||||
repoName: 'repo',
|
||||
issueNumber: 123,
|
||||
body: expect.stringContaining('An error occurred while processing your command')
|
||||
})
|
||||
);
|
||||
|
||||
// Verify response
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
error: 'Failed to process command'
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
const crypto = require('crypto');
|
||||
const SignatureHelper = require('../../utils/signatureHelper');
|
||||
|
||||
// Set required environment variables before requiring modules
|
||||
process.env.BOT_USERNAME = '@TestBot';
|
||||
@@ -87,10 +87,11 @@ describe('GitHub Controller', () => {
|
||||
// Mock the environment variables
|
||||
process.env.GITHUB_WEBHOOK_SECRET = 'test_secret';
|
||||
|
||||
// Set up the signature
|
||||
const payload = JSON.stringify(req.body);
|
||||
const hmac = crypto.createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET);
|
||||
req.headers['x-hub-signature-256'] = 'sha256=' + hmac.update(payload).digest('hex');
|
||||
// Set up the signature using the helper
|
||||
req.headers['x-hub-signature-256'] = SignatureHelper.createGitHubSignature(
|
||||
req.body,
|
||||
process.env.GITHUB_WEBHOOK_SECRET
|
||||
);
|
||||
|
||||
// Mock successful responses from services
|
||||
claudeService.processCommand.mockResolvedValue('Claude response');
|
||||
@@ -160,10 +161,11 @@ describe('GitHub Controller', () => {
|
||||
// Remove the @TestBot mention
|
||||
req.body.comment.body = 'This is a regular comment';
|
||||
|
||||
// Update the signature
|
||||
const payload = JSON.stringify(req.body);
|
||||
const hmac = crypto.createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET);
|
||||
req.headers['x-hub-signature-256'] = 'sha256=' + hmac.update(payload).digest('hex');
|
||||
// Update the signature using the helper
|
||||
req.headers['x-hub-signature-256'] = SignatureHelper.createGitHubSignature(
|
||||
req.body,
|
||||
process.env.GITHUB_WEBHOOK_SECRET
|
||||
);
|
||||
|
||||
await githubController.handleWebhook(req, res);
|
||||
|
||||
|
||||
53
test/utils/signatureHelper.js
Normal file
53
test/utils/signatureHelper.js
Normal file
@@ -0,0 +1,53 @@
|
||||
const crypto = require('crypto');
|
||||
|
||||
/**
|
||||
* Utility for generating GitHub webhook signatures for testing
|
||||
*/
|
||||
class SignatureHelper {
|
||||
/**
|
||||
* Create a GitHub webhook signature
|
||||
* @param {string|object} payload - The payload data (string or object to be stringified)
|
||||
* @param {string} secret - The webhook secret
|
||||
* @returns {string} - The signature in sha256=<hash> format
|
||||
*/
|
||||
static createGitHubSignature(payload, secret) {
|
||||
const payloadString = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
||||
const hmac = crypto.createHmac('sha256', secret);
|
||||
return `sha256=${hmac.update(payloadString).digest('hex')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a GitHub webhook signature
|
||||
* @param {string|object} payload - The payload data
|
||||
* @param {string} signature - The signature to verify
|
||||
* @param {string} secret - The webhook secret
|
||||
* @returns {boolean} - True if signature is valid
|
||||
*/
|
||||
static verifyGitHubSignature(payload, signature, secret) {
|
||||
const expectedSignature = this.createGitHubSignature(payload, secret);
|
||||
// Check lengths first to avoid timingSafeEqual error with different-length buffers
|
||||
return (
|
||||
signature.length === expectedSignature.length &&
|
||||
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create mock GitHub webhook headers with signature
|
||||
* @param {string|object} payload - The payload data
|
||||
* @param {string} secret - The webhook secret
|
||||
* @param {string} eventType - The GitHub event type (default: 'issue_comment')
|
||||
* @returns {object} - Headers object with signature and other webhook headers
|
||||
*/
|
||||
static createWebhookHeaders(payload, secret, eventType = 'issue_comment') {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'X-GitHub-Event': eventType,
|
||||
'X-GitHub-Delivery': `test-delivery-${Date.now()}`,
|
||||
'X-Hub-Signature-256': this.createGitHubSignature(payload, secret),
|
||||
'User-Agent': 'GitHub-Hookshot/test'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SignatureHelper;
|
||||
190
test/utils/webhookTestHelper.js
Normal file
190
test/utils/webhookTestHelper.js
Normal file
@@ -0,0 +1,190 @@
|
||||
const axios = require('axios');
|
||||
const SignatureHelper = require('./signatureHelper');
|
||||
|
||||
/**
|
||||
* Utility for testing webhook functionality
|
||||
*/
|
||||
class WebhookTestHelper {
|
||||
constructor(webhookUrl = 'http://localhost:3001/api/webhooks/github', secret = 'test_secret') {
|
||||
this.webhookUrl = webhookUrl;
|
||||
this.secret = secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock issue comment payload
|
||||
* @param {object} options - Configuration options
|
||||
* @returns {object} - Mock GitHub issue comment payload
|
||||
*/
|
||||
createIssueCommentPayload(options = {}) {
|
||||
const defaults = {
|
||||
action: 'created',
|
||||
commentBody: '@TestBot Tell me about this repository',
|
||||
issueNumber: 123,
|
||||
repoOwner: 'testowner',
|
||||
repoName: 'test-repo',
|
||||
userLogin: 'testuser'
|
||||
};
|
||||
|
||||
const config = { ...defaults, ...options };
|
||||
|
||||
return {
|
||||
action: config.action,
|
||||
comment: {
|
||||
id: Date.now(),
|
||||
body: config.commentBody,
|
||||
user: {
|
||||
login: config.userLogin
|
||||
}
|
||||
},
|
||||
issue: {
|
||||
number: config.issueNumber
|
||||
},
|
||||
repository: {
|
||||
name: config.repoName,
|
||||
full_name: `${config.repoOwner}/${config.repoName}`,
|
||||
owner: {
|
||||
login: config.repoOwner
|
||||
}
|
||||
},
|
||||
sender: {
|
||||
login: config.userLogin
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock issue opened payload for auto-tagging
|
||||
* @param {object} options - Configuration options
|
||||
* @returns {object} - Mock GitHub issue opened payload
|
||||
*/
|
||||
createIssueOpenedPayload(options = {}) {
|
||||
const defaults = {
|
||||
title: 'Application crashes when loading user data',
|
||||
body: 'The app consistently crashes when trying to load user profiles. This appears to be a critical bug affecting all users. Error occurs in the API endpoint.',
|
||||
issueNumber: 123,
|
||||
repoOwner: 'testowner',
|
||||
repoName: 'test-repo',
|
||||
userLogin: 'testuser'
|
||||
};
|
||||
|
||||
const config = { ...defaults, ...options };
|
||||
|
||||
return {
|
||||
action: 'opened',
|
||||
issue: {
|
||||
number: config.issueNumber,
|
||||
title: config.title,
|
||||
body: config.body,
|
||||
user: {
|
||||
login: config.userLogin
|
||||
}
|
||||
},
|
||||
repository: {
|
||||
name: config.repoName,
|
||||
full_name: `${config.repoOwner}/${config.repoName}`,
|
||||
owner: {
|
||||
login: config.repoOwner
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a webhook request
|
||||
* @param {object} payload - The webhook payload
|
||||
* @param {string} eventType - The GitHub event type
|
||||
* @param {object} options - Additional options
|
||||
* @returns {Promise<object>} - Axios response
|
||||
*/
|
||||
async sendWebhook(payload, eventType = 'issue_comment', options = {}) {
|
||||
const headers = SignatureHelper.createWebhookHeaders(payload, this.secret, eventType);
|
||||
|
||||
const config = {
|
||||
headers,
|
||||
timeout: options.timeout || 30000,
|
||||
...options
|
||||
};
|
||||
|
||||
return axios.post(this.webhookUrl, payload, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test issue comment webhook
|
||||
* @param {object} commentOptions - Options for comment payload
|
||||
* @returns {Promise<object>} - Test result
|
||||
*/
|
||||
async testIssueComment(commentOptions = {}) {
|
||||
const payload = this.createIssueCommentPayload(commentOptions);
|
||||
|
||||
try {
|
||||
const response = await this.sendWebhook(payload, 'issue_comment');
|
||||
return {
|
||||
success: true,
|
||||
status: response.status,
|
||||
data: response.data,
|
||||
payload
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
status: error.response?.status,
|
||||
data: error.response?.data,
|
||||
payload
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test issue opened webhook for auto-tagging
|
||||
* @param {object} issueOptions - Options for issue payload
|
||||
* @returns {Promise<object>} - Test result
|
||||
*/
|
||||
async testIssueOpened(issueOptions = {}) {
|
||||
const payload = this.createIssueOpenedPayload(issueOptions);
|
||||
|
||||
try {
|
||||
const response = await this.sendWebhook(payload, 'issues');
|
||||
return {
|
||||
success: true,
|
||||
status: response.status,
|
||||
data: response.data,
|
||||
payload
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
status: error.response?.status,
|
||||
data: error.response?.data,
|
||||
payload
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate curl command for manual testing
|
||||
* @param {object} payload - The webhook payload
|
||||
* @param {string} eventType - The GitHub event type
|
||||
* @returns {string} - Curl command
|
||||
*/
|
||||
generateCurlCommand(payload, eventType = 'issue_comment') {
|
||||
const headers = SignatureHelper.createWebhookHeaders(payload, this.secret, eventType);
|
||||
const payloadFile = 'webhook-payload.json';
|
||||
|
||||
let cmd = '# Save payload to file:\n';
|
||||
cmd += `echo '${JSON.stringify(payload, null, 2)}' > ${payloadFile}\n\n`;
|
||||
cmd += '# Send webhook:\n';
|
||||
cmd += `curl -X POST \\\n ${this.webhookUrl} \\\n`;
|
||||
|
||||
Object.entries(headers).forEach(([key, value]) => {
|
||||
cmd += ` -H "${key}: ${value}" \\\n`;
|
||||
});
|
||||
|
||||
cmd += ` -d @${payloadFile}`;
|
||||
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = WebhookTestHelper;
|
||||
Reference in New Issue
Block a user