From 651d090902a6c3f24fe2939fcb58ff6fc04b4804 Mon Sep 17 00:00:00 2001 From: Jonathan Flatt Date: Wed, 28 May 2025 16:28:12 -0500 Subject: [PATCH] feat: add comprehensive integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add AWS credential provider integration tests - Add GitHub webhook processing integration tests - Add Claude service container execution integration tests - Test real-world integration scenarios between components - Ensure proper mocking of external dependencies These integration tests cover three critical system workflows: 1. AWS credential handling with various credential sources 2. GitHub webhook processing for issues, PRs, and auto-tagging 3. Claude service container execution for different operation types 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../aws/credential-provider.test.js | 251 +++++++++++ .../claude/service-execution.test.js | 293 +++++++++++++ .../github/webhook-processing.test.js | 393 ++++++++++++++++++ 3 files changed, 937 insertions(+) create mode 100644 test/integration/aws/credential-provider.test.js create mode 100644 test/integration/claude/service-execution.test.js create mode 100644 test/integration/github/webhook-processing.test.js diff --git a/test/integration/aws/credential-provider.test.js b/test/integration/aws/credential-provider.test.js new file mode 100644 index 0000000..39ff695 --- /dev/null +++ b/test/integration/aws/credential-provider.test.js @@ -0,0 +1,251 @@ +/** + * Integration test for AWS credential provider and secure credentials integration + * + * This test verifies the interaction between awsCredentialProvider and secureCredentials + * utilities to ensure proper credential handling, caching, and fallbacks. + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { jest: jestGlobal } = require('@jest/globals'); + +const awsCredentialProvider = require('../../../src/utils/awsCredentialProvider'); +const secureCredentials = require('../../../src/utils/secureCredentials'); +const logger = require('../../../src/utils/logger'); + +describe('AWS Credential Provider Integration', () => { + let originalHomedir; + let tempDir; + let credentialsPath; + let configPath; + let originalEnv; + + beforeAll(() => { + // Save original environment + originalEnv = { ...process.env }; + originalHomedir = os.homedir; + + // Silence logger during tests + jest.spyOn(logger, 'info').mockImplementation(() => {}); + jest.spyOn(logger, 'warn').mockImplementation(() => {}); + jest.spyOn(logger, 'error').mockImplementation(() => {}); + jest.spyOn(logger, 'debug').mockImplementation(() => {}); + }); + + beforeEach(async () => { + // Create temporary AWS credentials directory + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aws-cred-test-')); + + // Create temporary .aws directory structure + const awsDir = path.join(tempDir, '.aws'); + fs.mkdirSync(awsDir, { recursive: true }); + + // Set paths + credentialsPath = path.join(awsDir, 'credentials'); + configPath = path.join(awsDir, 'config'); + + // Mock home directory to use our temporary directory + os.homedir = jest.fn().mockReturnValue(tempDir); + + // Reset credential provider + awsCredentialProvider.clearCache(); + + // Start with clean environment for each test + process.env = { NODE_ENV: 'test' }; + }); + + afterEach(() => { + // Clean up temporary directory + fs.rmSync(tempDir, { recursive: true, force: true }); + + // Restore environment variables + process.env = { ...originalEnv }; + + // Clear any mocks + jest.restoreAllMocks(); + }); + + afterAll(() => { + // Restore original homedir function + os.homedir = originalHomedir; + }); + + test('should retrieve credentials from AWS profile', async () => { + // Create credentials file + const credentialsContent = ` +[test-profile] +aws_access_key_id = AKIATESTKEY123456789 +aws_secret_access_key = testsecretkey123456789012345678901234567890 + `; + + // Create config file + const configContent = ` +[profile test-profile] +region = us-west-2 + `; + + // Write test files + fs.writeFileSync(credentialsPath, credentialsContent); + fs.writeFileSync(configPath, configContent); + + // Set environment variable + process.env.AWS_PROFILE = 'test-profile'; + + // Test credential retrieval + const result = await awsCredentialProvider.getCredentials(); + + // Verify results + expect(result.credentials.accessKeyId).toBe('AKIATESTKEY123456789'); + expect(result.credentials.secretAccessKey).toBe('testsecretkey123456789012345678901234567890'); + expect(result.region).toBe('us-west-2'); + expect(result.source.type).toBe('profile'); + expect(result.source.profileName).toBe('test-profile'); + + // Verify caching + expect(awsCredentialProvider.hasCachedCredentials()).toBe(true); + + // Get cached credentials + const cachedResult = await awsCredentialProvider.getCredentials(); + expect(cachedResult.credentials).toEqual(result.credentials); + }); + + test('should fall back to environment variables when profile not found', async () => { + // Set environment variables + process.env.AWS_ACCESS_KEY_ID = 'AKIAENVKEY123456789'; + process.env.AWS_SECRET_ACCESS_KEY = 'envsecretkey123456789012345678901234567890'; + process.env.AWS_REGION = 'us-east-1'; + + // Set non-existent profile + process.env.AWS_PROFILE = 'non-existent-profile'; + + // Mock secureCredentials to mimic environment-based retrieval + jest.spyOn(secureCredentials, 'get').mockImplementation(key => { + if (key === 'AWS_ACCESS_KEY_ID') return 'AKIAENVKEY123456789'; + if (key === 'AWS_SECRET_ACCESS_KEY') return 'envsecretkey123456789012345678901234567890'; + if (key === 'AWS_REGION') return 'us-east-1'; + return null; + }); + + // Test credential retrieval with fallback + const result = await awsCredentialProvider.getCredentials(); + + // Verify results + expect(result.credentials.accessKeyId).toBe('AKIAENVKEY123456789'); + expect(result.credentials.secretAccessKey).toBe('envsecretkey123456789012345678901234567890'); + expect(result.region).toBe('us-east-1'); + expect(result.source.type).toBe('environment'); + }); + + test('should retrieve credentials from secure credentials store', async () => { + // Mock secureCredentials + jest.spyOn(secureCredentials, 'get').mockImplementation(key => { + if (key === 'AWS_ACCESS_KEY_ID') return 'AKIASECUREKEY123456789'; + if (key === 'AWS_SECRET_ACCESS_KEY') return 'securesecretkey123456789012345678901234567890'; + if (key === 'AWS_REGION') return 'eu-west-1'; + return null; + }); + + // Test credential retrieval + const result = await awsCredentialProvider.getCredentials(); + + // Verify results + expect(result.credentials.accessKeyId).toBe('AKIASECUREKEY123456789'); + expect(result.credentials.secretAccessKey).toBe('securesecretkey123456789012345678901234567890'); + expect(result.region).toBe('eu-west-1'); + expect(result.source.type).toBe('environment'); + }); + + test('should refresh credentials when explicitly requested', async () => { + // Create credentials file + const credentialsContent = ` +[test-profile] +aws_access_key_id = AKIATESTKEY123456789 +aws_secret_access_key = testsecretkey123456789012345678901234567890 + `; + + // Write credentials file + fs.writeFileSync(credentialsPath, credentialsContent); + + // Set environment variable + process.env.AWS_PROFILE = 'test-profile'; + + // Get initial credentials + const initialResult = await awsCredentialProvider.getCredentials(); + expect(initialResult.credentials.accessKeyId).toBe('AKIATESTKEY123456789'); + + // Modify credentials file + const updatedCredentialsContent = ` +[test-profile] +aws_access_key_id = AKIANEWKEY987654321 +aws_secret_access_key = newsecretkey123456789012345678901234567890 + `; + + // Write updated credentials + fs.writeFileSync(credentialsPath, updatedCredentialsContent); + + // Get cached credentials (should be unchanged) + const cachedResult = await awsCredentialProvider.getCredentials(); + expect(cachedResult.credentials.accessKeyId).toBe('AKIATESTKEY123456789'); + + // Clear cache + awsCredentialProvider.clearCache(); + + // Get fresh credentials + const refreshedResult = await awsCredentialProvider.getCredentials(); + expect(refreshedResult.credentials.accessKeyId).toBe('AKIANEWKEY987654321'); + }); + + test('should handle Docker environment credentials', async () => { + // Mock Docker environment detection + process.env.CONTAINER_ID = 'mock-container-id'; + process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = '/credentials/path'; + + // Skip actual HTTP request to metadata service + jest.spyOn(awsCredentialProvider, '_getContainerCredentials') + .mockResolvedValue({ + AccessKeyId: 'AKIADOCKERKEY123456789', + SecretAccessKey: 'dockersecretkey123456789012345678901234567890', + Token: 'docker-token-123', + Expiration: new Date(Date.now() + 3600000).toISOString() + }); + + // Test credential retrieval + const result = await awsCredentialProvider.getCredentials(); + + // Verify results + expect(result.credentials.accessKeyId).toBe('AKIADOCKERKEY123456789'); + expect(result.credentials.secretAccessKey).toBe('dockersecretkey123456789012345678901234567890'); + expect(result.credentials.sessionToken).toBe('docker-token-123'); + expect(result.source.type).toBe('container'); + }); + + test('should integrate with secureCredentials when retrieving AWS profile', async () => { + // Create credentials file + const credentialsContent = ` +[secure-profile] +aws_access_key_id = AKIASECPROFILE123456789 +aws_secret_access_key = secprofilesecret123456789012345678901234567890 + `; + + // Write credentials file + fs.writeFileSync(credentialsPath, credentialsContent); + + // Mock secureCredentials to return AWS_PROFILE + jest.spyOn(secureCredentials, 'get').mockImplementation(key => { + if (key === 'AWS_PROFILE') return 'secure-profile'; + return null; + }); + + // Don't set AWS_PROFILE in environment - it should come from secureCredentials + + // Test credential retrieval + const result = await awsCredentialProvider.getCredentials(); + + // Verify results + expect(result.credentials.accessKeyId).toBe('AKIASECPROFILE123456789'); + expect(result.credentials.secretAccessKey).toBe('secprofilesecret123456789012345678901234567890'); + expect(result.source.type).toBe('profile'); + expect(result.source.profileName).toBe('secure-profile'); + }); +}); \ No newline at end of file diff --git a/test/integration/claude/service-execution.test.js b/test/integration/claude/service-execution.test.js new file mode 100644 index 0000000..cdda2e4 --- /dev/null +++ b/test/integration/claude/service-execution.test.js @@ -0,0 +1,293 @@ +/** + * Integration test for Claude Service and container execution + * + * This test verifies the integration between claudeService, Docker container execution, + * and environment configuration. + */ + +const { jest: jestGlobal } = require('@jest/globals'); +const path = require('path'); +const childProcess = require('child_process'); + +const claudeService = require('../../../src/services/claudeService'); +const secureCredentials = require('../../../src/utils/secureCredentials'); +const logger = require('../../../src/utils/logger'); + +// Mock child_process execFile +jest.mock('child_process', () => ({ + ...jest.requireActual('child_process'), + execFile: jest.fn(), + execFileSync: jest.fn() +})); + +describe('Claude Service Container Execution Integration', () => { + let originalEnv; + + beforeAll(() => { + // Save original environment + originalEnv = { ...process.env }; + + // Silence logger during tests + jest.spyOn(logger, 'info').mockImplementation(() => {}); + jest.spyOn(logger, 'warn').mockImplementation(() => {}); + jest.spyOn(logger, 'error').mockImplementation(() => {}); + jest.spyOn(logger, 'debug').mockImplementation(() => {}); + }); + + beforeEach(() => { + // Reset mocks + jest.clearAllMocks(); + + // Mock Docker inspect to find the image + childProcess.execFileSync.mockImplementation((cmd, args) => { + if (cmd === 'docker' && args[0] === 'inspect') { + return JSON.stringify([{ Id: 'mock-container-id' }]); + } + return ''; + }); + + // Mock Docker execFile to return a successful result + childProcess.execFile.mockImplementation((cmd, args, options, callback) => { + callback(null, { + stdout: 'Claude container execution result', + stderr: '' + }); + }); + + // Set production environment + process.env = { + NODE_ENV: 'production', + BOT_USERNAME: '@TestBot', + BOT_EMAIL: 'testbot@example.com', + ENABLE_CONTAINER_FIREWALL: 'false', + CLAUDE_CONTAINER_IMAGE: 'claude-code-runner:latest', + ALLOWED_TOOLS: 'Read,GitHub,Bash,Edit,Write' + }; + + // Mock secureCredentials + jest.spyOn(secureCredentials, 'get').mockImplementation(key => { + if (key === 'GITHUB_TOKEN') return 'github-test-token'; + if (key === 'ANTHROPIC_API_KEY') return 'claude-test-key'; + return null; + }); + }); + + afterEach(() => { + // Restore environment variables + process.env = { ...originalEnv }; + }); + + test('should build Docker command correctly for standard execution', async () => { + // Execute Claude command + const result = await claudeService.processCommand({ + repoFullName: 'test/repo', + issueNumber: 123, + command: 'Test command', + isPullRequest: false, + branchName: null + }); + + // Verify result + expect(result).toBe('Claude container execution result'); + + // Verify Docker execution + expect(childProcess.execFile).toHaveBeenCalledTimes(1); + + // Extract args from call + const callArgs = childProcess.execFile.mock.calls[0]; + const [cmd, args] = callArgs; + + // Verify basic Docker command + expect(cmd).toBe('docker'); + expect(args[0]).toBe('run'); + expect(args).toContain('--rm'); // Container is removed after execution + + // Verify environment variables + expect(args).toContain('-e'); + expect(args).toContain('GITHUB_TOKEN=github-test-token'); + expect(args).toContain('ANTHROPIC_API_KEY=claude-test-key'); + expect(args).toContain('REPO_FULL_NAME=test/repo'); + expect(args).toContain('ISSUE_NUMBER=123'); + expect(args).toContain('IS_PULL_REQUEST=false'); + + // Verify command is passed correctly + expect(args).toContain('Test command'); + + // Verify entrypoint + const entrypointIndex = args.indexOf('--entrypoint'); + expect(entrypointIndex).not.toBe(-1); + expect(args[entrypointIndex + 1]).toContain('claudecode-entrypoint.sh'); + + // Verify allowed tools + expect(args).toContain('--allowedTools'); + expect(args).toContain('Read,GitHub,Bash,Edit,Write'); + }); + + test('should build Docker command correctly for PR review', async () => { + // Execute Claude command for PR + const result = await claudeService.processCommand({ + repoFullName: 'test/repo', + issueNumber: 456, + command: 'Review PR', + isPullRequest: true, + branchName: 'feature-branch' + }); + + // Verify result + expect(result).toBe('Claude container execution result'); + + // Verify Docker execution + expect(childProcess.execFile).toHaveBeenCalledTimes(1); + + // Extract args from call + const callArgs = childProcess.execFile.mock.calls[0]; + const [cmd, args] = callArgs; + + // Verify PR-specific variables + expect(args).toContain('-e'); + expect(args).toContain('IS_PULL_REQUEST=true'); + expect(args).toContain('BRANCH_NAME=feature-branch'); + }); + + test('should build Docker command correctly for auto-tagging', async () => { + // Execute Claude command for auto-tagging + const result = await claudeService.processCommand({ + repoFullName: 'test/repo', + issueNumber: 789, + command: 'Auto-tag this issue', + isPullRequest: false, + branchName: null, + operationType: 'auto-tagging' + }); + + // Verify result + expect(result).toBe('Claude container execution result'); + + // Verify Docker execution + expect(childProcess.execFile).toHaveBeenCalledTimes(1); + + // Extract args from call + const callArgs = childProcess.execFile.mock.calls[0]; + const [cmd, args] = callArgs; + + // Verify auto-tagging specific settings + expect(args).toContain('-e'); + expect(args).toContain('OPERATION_TYPE=auto-tagging'); + + // Verify entrypoint is specific to tagging + const entrypointIndex = args.indexOf('--entrypoint'); + expect(entrypointIndex).not.toBe(-1); + expect(args[entrypointIndex + 1]).toContain('claudecode-tagging-entrypoint.sh'); + + // Auto-tagging only allows Read and GitHub tools + expect(args).toContain('--allowedTools'); + expect(args).toContain('Read,GitHub'); + }); + + test('should handle Docker container errors', async () => { + // Mock Docker execution to fail + childProcess.execFile.mockImplementation((cmd, args, options, callback) => { + callback(new Error('Docker execution failed'), { + stdout: '', + stderr: 'Container error: command failed' + }); + }); + + // Expect promise rejection + await expect(claudeService.processCommand({ + repoFullName: 'test/repo', + issueNumber: 123, + command: 'Test command', + isPullRequest: false, + branchName: null + })).rejects.toThrow('Docker execution failed'); + }); + + test('should handle missing Docker image and try to build it', async () => { + // Mock Docker inspect to not find the image first time, then find it + let inspectCallCount = 0; + childProcess.execFileSync.mockImplementation((cmd, args) => { + if (cmd === 'docker' && args[0] === 'inspect') { + inspectCallCount++; + if (inspectCallCount === 1) { + // First call - image not found + throw new Error('No such image'); + } else { + // Second call - image found after build + return JSON.stringify([{ Id: 'mock-container-id' }]); + } + } + // Return success for other commands (like build) + return 'Success'; + }); + + // Execute Claude command + const result = await claudeService.processCommand({ + repoFullName: 'test/repo', + issueNumber: 123, + command: 'Test command', + isPullRequest: false, + branchName: null + }); + + // Verify result + expect(result).toBe('Claude container execution result'); + + // Verify Docker build was attempted + expect(childProcess.execFileSync).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining(['build']), + expect.anything() + ); + }); + + test('should use test mode in non-production environments', async () => { + // Set test environment + process.env.NODE_ENV = 'test'; + + // Mock test mode response + jest.spyOn(claudeService, '_getTestModeResponse').mockReturnValue('Test mode response'); + + // Execute Claude command + const result = await claudeService.processCommand({ + repoFullName: 'test/repo', + issueNumber: 123, + command: 'Test command', + isPullRequest: false, + branchName: null + }); + + // Verify test mode response + expect(result).toBe('Test mode response'); + + // Verify Docker was not called + expect(childProcess.execFile).not.toHaveBeenCalled(); + }); + + test('should sanitize command input before passing to container', async () => { + // Test with command containing shell-unsafe characters + const unsafeCommand = 'Test command with $(dangerous) `characters` && injection;'; + + // Execute Claude command + await claudeService.processCommand({ + repoFullName: 'test/repo', + issueNumber: 123, + command: unsafeCommand, + isPullRequest: false, + branchName: null + }); + + // Extract args from call + const callArgs = childProcess.execFile.mock.calls[0]; + const [cmd, args] = callArgs; + + // Verify command was properly sanitized + const commandIndex = args.indexOf(unsafeCommand); + expect(commandIndex).toBe(-1); // Raw command should not be there + + // The command should be sanitized and passed as the last argument + const lastArg = args[args.length - 1]; + expect(lastArg).not.toContain('$(dangerous)'); + expect(lastArg).not.toContain('`characters`'); + }); +}); \ No newline at end of file diff --git a/test/integration/github/webhook-processing.test.js b/test/integration/github/webhook-processing.test.js new file mode 100644 index 0000000..e443b61 --- /dev/null +++ b/test/integration/github/webhook-processing.test.js @@ -0,0 +1,393 @@ +/** + * Integration test for GitHub webhook processing flow + * + * This test verifies the integration between githubController, claudeService, + * and githubService when processing GitHub webhook events. + */ + +const { jest: jestGlobal } = require('@jest/globals'); +const crypto = require('crypto'); +const express = require('express'); +const bodyParser = require('body-parser'); +const request = require('supertest'); + +// Services +const claudeService = require('../../../src/services/claudeService'); +const githubService = require('../../../src/services/githubService'); +const secureCredentials = require('../../../src/utils/secureCredentials'); + +// Controller +const githubController = require('../../../src/controllers/githubController'); + +// Mock dependencies +jest.mock('../../../src/services/claudeService'); +jest.mock('../../../src/services/githubService'); + +describe('GitHub Webhook Processing Integration', () => { + let app; + let originalEnv; + + beforeAll(() => { + // Save original environment + originalEnv = { ...process.env }; + + // Create express app for testing + app = express(); + app.use(bodyParser.json({ + verify: (req, res, buf) => { + req.rawBody = buf; + } + })); + + // Add webhook route + app.post('/api/webhooks/github', githubController.handleWebhook); + }); + + beforeEach(() => { + // Reset mocks + jest.clearAllMocks(); + + // Set test environment + process.env.NODE_ENV = 'test'; + process.env.BOT_USERNAME = '@TestBot'; + process.env.AUTHORIZED_USERS = 'testuser,admin'; + process.env.GITHUB_WEBHOOK_SECRET = 'test-webhook-secret'; + + // Mock secureCredentials + jest.spyOn(secureCredentials, 'get').mockImplementation(key => { + if (key === 'GITHUB_WEBHOOK_SECRET') return 'test-webhook-secret'; + if (key === 'GITHUB_TOKEN') return 'github-test-token'; + if (key === 'ANTHROPIC_API_KEY') return 'claude-test-key'; + return null; + }); + + // Mock claudeService + claudeService.processCommand.mockResolvedValue('Claude response for test command'); + + // Mock githubService + githubService.postComment.mockResolvedValue({ + id: 'test-comment-id', + body: 'Claude response', + created_at: new Date().toISOString() + }); + }); + + afterEach(() => { + // Restore environment variables + process.env = { ...originalEnv }; + }); + + test('should process issue comment webhook with bot mention', async () => { + // Create webhook payload for issue comment with bot mention + const payload = { + action: 'created', + issue: { + number: 123, + title: 'Test Issue', + body: 'This is a test issue', + user: { login: 'testuser' } + }, + comment: { + id: 456, + body: '@TestBot help me with this issue', + user: { login: 'testuser' } + }, + repository: { + full_name: 'test/repo', + owner: { login: 'test' }, + name: 'repo' + }, + sender: { login: 'testuser' } + }; + + // Calculate signature + const payloadString = JSON.stringify(payload); + const signature = 'sha256=' + + crypto.createHmac('sha256', 'test-webhook-secret') + .update(payloadString) + .digest('hex'); + + // Send request to webhook endpoint + const response = await request(app) + .post('/api/webhooks/github') + .set('X-GitHub-Event', 'issue_comment') + .set('X-GitHub-Delivery', 'test-delivery-id') + .set('X-Hub-Signature-256', signature) + .send(payload); + + // Verify response + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + + // Verify service calls + expect(claudeService.processCommand).toHaveBeenCalledWith({ + repoFullName: 'test/repo', + issueNumber: 123, + command: 'help me with this issue', + isPullRequest: false, + branchName: null + }); + + expect(githubService.postComment).toHaveBeenCalledWith({ + repoOwner: 'test', + repoName: 'repo', + issueNumber: 123, + body: 'Claude response for test command' + }); + }); + + test('should process pull request comment webhook', async () => { + // Create webhook payload for PR comment with bot mention + const payload = { + action: 'created', + issue: { + number: 456, + title: 'Test PR', + body: 'This is a test PR', + user: { login: 'testuser' }, + pull_request: { url: 'https://api.github.com/repos/test/repo/pulls/456' } + }, + comment: { + id: 789, + body: '@TestBot review this PR', + user: { login: 'testuser' } + }, + repository: { + full_name: 'test/repo', + owner: { login: 'test' }, + name: 'repo' + }, + sender: { login: 'testuser' } + }; + + // Calculate signature + const payloadString = JSON.stringify(payload); + const signature = 'sha256=' + + crypto.createHmac('sha256', 'test-webhook-secret') + .update(payloadString) + .digest('hex'); + + // Mock PR-specific GitHub service calls + githubService.getPullRequestDetails.mockResolvedValue({ + number: 456, + head: { ref: 'feature-branch' } + }); + + // Send request to webhook endpoint + const response = await request(app) + .post('/api/webhooks/github') + .set('X-GitHub-Event', 'issue_comment') + .set('X-GitHub-Delivery', 'test-delivery-id') + .set('X-Hub-Signature-256', signature) + .send(payload); + + // Verify response + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + + // Verify PR details were retrieved + expect(githubService.getPullRequestDetails).toHaveBeenCalledWith({ + repoOwner: 'test', + repoName: 'repo', + prNumber: 456 + }); + + // Verify service calls with PR information + expect(claudeService.processCommand).toHaveBeenCalledWith({ + repoFullName: 'test/repo', + issueNumber: 456, + command: 'review this PR', + isPullRequest: true, + branchName: 'feature-branch' + }); + + expect(githubService.postComment).toHaveBeenCalledWith({ + repoOwner: 'test', + repoName: 'repo', + issueNumber: 456, + body: 'Claude response for test command' + }); + }); + + test('should reject webhook with invalid signature', async () => { + // Create webhook payload + const payload = { + action: 'created', + issue: { number: 123 }, + comment: { + body: '@TestBot help me', + user: { login: 'testuser' } + }, + repository: { + full_name: 'test/repo', + owner: { login: 'test' }, + name: 'repo' + }, + sender: { login: 'testuser' } + }; + + // Use invalid signature + const invalidSignature = 'sha256=invalid_signature_value'; + + // Send request with invalid signature + const response = await request(app) + .post('/api/webhooks/github') + .set('X-GitHub-Event', 'issue_comment') + .set('X-GitHub-Delivery', 'test-delivery-id') + .set('X-Hub-Signature-256', invalidSignature) + .send(payload); + + // Verify rejection + expect(response.status).toBe(401); + expect(response.body.success).toBe(false); + expect(response.body.error).toBe('Invalid webhook signature'); + + // Verify services were not called + expect(claudeService.processCommand).not.toHaveBeenCalled(); + expect(githubService.postComment).not.toHaveBeenCalled(); + }); + + test('should ignore comments without bot mention', async () => { + // Create webhook payload without bot mention + const payload = { + action: 'created', + issue: { number: 123 }, + comment: { + body: 'This is a regular comment without bot mention', + user: { login: 'testuser' } + }, + repository: { + full_name: 'test/repo', + owner: { login: 'test' }, + name: 'repo' + }, + sender: { login: 'testuser' } + }; + + // Calculate signature + const payloadString = JSON.stringify(payload); + const signature = 'sha256=' + + crypto.createHmac('sha256', 'test-webhook-secret') + .update(payloadString) + .digest('hex'); + + // Send request to webhook endpoint + const response = await request(app) + .post('/api/webhooks/github') + .set('X-GitHub-Event', 'issue_comment') + .set('X-GitHub-Delivery', 'test-delivery-id') + .set('X-Hub-Signature-256', signature) + .send(payload); + + // Verify response + expect(response.status).toBe(200); + + // Verify services were not called + expect(claudeService.processCommand).not.toHaveBeenCalled(); + expect(githubService.postComment).not.toHaveBeenCalled(); + }); + + test('should handle auto-tagging on new issue', async () => { + // Create issue opened payload + const payload = { + action: 'opened', + issue: { + number: 789, + title: 'Bug in API endpoint', + body: 'The /api/data endpoint returns a 500 error', + user: { login: 'testuser' } + }, + repository: { + full_name: 'test/repo', + owner: { login: 'test' }, + name: 'repo' + }, + sender: { login: 'testuser' } + }; + + // Calculate signature + const payloadString = JSON.stringify(payload); + const signature = 'sha256=' + + crypto.createHmac('sha256', 'test-webhook-secret') + .update(payloadString) + .digest('hex'); + + // Mock Claude service for auto-tagging + claudeService.processCommand.mockResolvedValue('Added labels: bug, api, high-priority'); + + // Mock GitHub service + githubService.getFallbackLabels.mockReturnValue(['type:bug', 'priority:high', 'component:api']); + githubService.addLabelsToIssue.mockResolvedValue([ + { name: 'type:bug' }, + { name: 'priority:high' }, + { name: 'component:api' } + ]); + + // Send request to webhook endpoint + const response = await request(app) + .post('/api/webhooks/github') + .set('X-GitHub-Event', 'issues') + .set('X-GitHub-Delivery', 'test-delivery-id') + .set('X-Hub-Signature-256', signature) + .send(payload); + + // Verify response + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + + // Verify Claude auto-tagging was called + expect(claudeService.processCommand).toHaveBeenCalledWith(expect.objectContaining({ + repoFullName: 'test/repo', + issueNumber: 789, + operationType: 'auto-tagging' + })); + }); + + test('should handle Claude service errors gracefully', async () => { + // Create webhook payload + const payload = { + action: 'created', + issue: { number: 123 }, + comment: { + body: '@TestBot help me with this issue', + user: { login: 'testuser' } + }, + repository: { + full_name: 'test/repo', + owner: { login: 'test' }, + name: 'repo' + }, + sender: { login: 'testuser' } + }; + + // Calculate signature + const payloadString = JSON.stringify(payload); + const signature = 'sha256=' + + crypto.createHmac('sha256', 'test-webhook-secret') + .update(payloadString) + .digest('hex'); + + // Mock Claude service error + claudeService.processCommand.mockRejectedValue(new Error('Claude service error')); + + // Send request to webhook endpoint + const response = await request(app) + .post('/api/webhooks/github') + .set('X-GitHub-Event', 'issue_comment') + .set('X-GitHub-Delivery', 'test-delivery-id') + .set('X-Hub-Signature-256', signature) + .send(payload); + + // Verify response + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + + // Verify error was posted as comment + expect(githubService.postComment).toHaveBeenCalledWith(expect.objectContaining({ + repoOwner: 'test', + repoName: 'repo', + issueNumber: 123, + body: expect.stringContaining('Error processing command') + })); + }); +}); \ No newline at end of file