From 39a3ec960d47994b82c95891ca4ad65b760a6e8b Mon Sep 17 00:00:00 2001 From: Jonathan Flatt Date: Sun, 25 May 2025 12:27:17 -0500 Subject: [PATCH] Refactor test files and standardize crypto signature patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/controllers/githubController.js | 50 +++-- test/generate-signature.js | 14 +- test/test-issue-webhook.js | 71 ++----- test/test-outgoing-webhook.js | 121 ----------- test/test-webhook-manual.js | 57 ------ test/test-webhook-response.js | 68 +++---- test/tests/githubController.test.js | 184 ----------------- .../unit/controllers/githubController.test.js | 20 +- test/utils/signatureHelper.js | 53 +++++ test/utils/webhookTestHelper.js | 190 ++++++++++++++++++ 10 files changed, 333 insertions(+), 495 deletions(-) delete mode 100644 test/test-outgoing-webhook.js delete mode 100644 test/test-webhook-manual.js delete mode 100644 test/tests/githubController.test.js create mode 100644 test/utils/signatureHelper.js create mode 100644 test/utils/webhookTestHelper.js diff --git a/src/controllers/githubController.js b/src/controllers/githubController.js index 2372194..4953b19 100644 --- a/src/controllers/githubController.js +++ b/src/controllers/githubController.js @@ -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({ diff --git a/test/generate-signature.js b/test/generate-signature.js index 465e5af..19e2cdf 100644 --- a/test/generate-signature.js +++ b/test/generate-signature.js @@ -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}`); \ No newline at end of file + -d @${payloadPath}`); diff --git a/test/test-issue-webhook.js b/test/test-issue-webhook.js index 2d9c197..488cc3a 100644 --- a/test/test-issue-webhook.js +++ b/test/test-issue-webhook.js @@ -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 }; diff --git a/test/test-outgoing-webhook.js b/test/test-outgoing-webhook.js deleted file mode 100644 index 27e6c0e..0000000 --- a/test/test-outgoing-webhook.js +++ /dev/null @@ -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); - } -}); diff --git a/test/test-webhook-manual.js b/test/test-webhook-manual.js deleted file mode 100644 index d1e4f9f..0000000 --- a/test/test-webhook-manual.js +++ /dev/null @@ -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); diff --git a/test/test-webhook-response.js b/test/test-webhook-response.js index c76c6e6..0a6da4f 100755 --- a/test/test-webhook-response.js +++ b/test/test-webhook-response.js @@ -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(); diff --git a/test/tests/githubController.test.js b/test/tests/githubController.test.js deleted file mode 100644 index d3fc71c..0000000 --- a/test/tests/githubController.test.js +++ /dev/null @@ -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' - }) - ); - }); -}); diff --git a/test/unit/controllers/githubController.test.js b/test/unit/controllers/githubController.test.js index 05d346f..280773b 100644 --- a/test/unit/controllers/githubController.test.js +++ b/test/unit/controllers/githubController.test.js @@ -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); diff --git a/test/utils/signatureHelper.js b/test/utils/signatureHelper.js new file mode 100644 index 0000000..1256ff4 --- /dev/null +++ b/test/utils/signatureHelper.js @@ -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= 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; diff --git a/test/utils/webhookTestHelper.js b/test/utils/webhookTestHelper.js new file mode 100644 index 0000000..4f7d8b5 --- /dev/null +++ b/test/utils/webhookTestHelper.js @@ -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} - 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} - 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} - 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;