forked from claude-did-this/claude-hub
feat: add comprehensive integration tests
- 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 <noreply@anthropic.com>
This commit is contained in:
251
test/integration/aws/credential-provider.test.js
Normal file
251
test/integration/aws/credential-provider.test.js
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
293
test/integration/claude/service-execution.test.js
Normal file
293
test/integration/claude/service-execution.test.js
Normal file
@@ -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`');
|
||||
});
|
||||
});
|
||||
393
test/integration/github/webhook-processing.test.js
Normal file
393
test/integration/github/webhook-processing.test.js
Normal file
@@ -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')
|
||||
}));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user