Files
cloud-server/scripts/api-tester.ts
kappa 5a9362bf43 feat: P1 보안/성능 개선 및 마이그레이션 자동화
Security fixes:
- migrate.ts: SQL/Command Injection 방지 (spawnSync 사용)
- migrate.ts: Path Traversal 검증 추가
- api-tester.ts: API 키 마스킹 (4자만 노출)
- api-tester.ts: 최소 16자 키 길이 검증
- cache.ts: ReDoS 방지 (패턴 길이/와일드카드 제한)

Performance improvements:
- cache.ts: 순차 삭제 → 병렬 배치 처리 (50개씩)
- cache.ts: KV 등록 fire-and-forget (non-blocking)
- cache.ts: 메모리 제한 (5000키)
- cache.ts: 25초 실행 시간 가드
- cache.ts: 패턴 매칭 prefix 최적화

New features:
- 마이그레이션 자동화 시스템 (scripts/migrate.ts)
- KV 기반 캐시 인덱스 (invalidatePattern, clearAll)
- 글로벌 CacheService 싱글톤

Other:
- .env.example 추가, API 키 환경변수 처리
- CACHE_TTL.RECOMMENDATIONS (10분) 분리
- e2e-tester.ts JSON 파싱 에러 핸들링 개선

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 00:23:13 +09:00

706 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Cloud Instances API Tester
*
* Comprehensive test suite for API endpoints with colorful console output.
* Tests all endpoints with various parameter combinations and validates responses.
*
* Requirements:
* API_KEY environment variable must be set
*
* Usage:
* export API_KEY=your-api-key-here
* npx tsx scripts/api-tester.ts
* npx tsx scripts/api-tester.ts --endpoint /health
* npx tsx scripts/api-tester.ts --verbose
*
* Or use npm scripts:
* npm run test:api
* npm run test:api:verbose
*/
// ============================================================
// Configuration
// ============================================================
const API_URL = process.env.API_URL || 'https://cloud-instances-api.kappa-d8e.workers.dev';
const API_KEY = process.env.API_KEY;
if (!API_KEY) {
console.error('\n❌ ERROR: API_KEY environment variable is required');
console.error('Please set API_KEY before running the tests:');
console.error(' export API_KEY=your-api-key-here');
console.error(' npm run test:api');
console.error('\nOr create a .env file (see .env.example for reference)');
process.exit(1);
}
if (API_KEY.length < 16) {
console.error('\n❌ ERROR: API_KEY must be at least 16 characters');
console.error('The provided API key is too short to be valid.');
console.error('Please check your API_KEY environment variable.');
process.exit(1);
}
// CLI flags
const args = process.argv.slice(2);
const VERBOSE = args.includes('--verbose');
const TARGET_ENDPOINT = args.find(arg => arg.startsWith('--endpoint='))?.split('=')[1];
// ============================================================
// Color Utilities
// ============================================================
const colors = {
reset: '\x1b[0m',
bold: '\x1b[1m',
dim: '\x1b[2m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
white: '\x1b[37m',
gray: '\x1b[90m',
};
function color(text: string, colorCode: string): string {
return `${colorCode}${text}${colors.reset}`;
}
function bold(text: string): string {
return `${colors.bold}${text}${colors.reset}`;
}
// ============================================================
// Test Result Types
// ============================================================
interface TestResult {
name: string;
endpoint: string;
method: string;
passed: boolean;
duration: number;
statusCode?: number;
error?: string;
details?: string;
}
interface TestReport {
total: number;
passed: number;
failed: number;
duration: number;
results: TestResult[];
}
// ============================================================
// API Test Helper
// ============================================================
// Delay utility to avoid rate limiting (100 req/min = 600ms minimum between requests)
async function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Helper to add delay between tests (delay BEFORE request to ensure rate limit compliance)
async function testWithDelay(
name: string,
endpoint: string,
options: Parameters<typeof testRequest>[2] = {}
): Promise<TestResult> {
await delay(800); // 800ms delay BEFORE request to avoid rate limiting (100 req/min)
const result = await testRequest(name, endpoint, options);
return result;
}
async function testRequest(
name: string,
endpoint: string,
options: {
method?: string;
headers?: Record<string, string>;
body?: unknown;
expectStatus?: number | number[];
validateResponse?: (data: unknown) => boolean | string;
} = {}
): Promise<TestResult> {
const {
method = 'GET',
headers = {},
body,
expectStatus = 200,
validateResponse,
} = options;
// Convert expectStatus to array for easier checking
const expectedStatuses = Array.isArray(expectStatus) ? expectStatus : [expectStatus];
const startTime = Date.now();
const url = `${API_URL}${endpoint}`;
try {
const requestHeaders: Record<string, string> = {
'Content-Type': 'application/json',
...headers,
};
const response = await fetch(url, {
method,
headers: requestHeaders,
body: body ? JSON.stringify(body) : undefined,
});
const duration = Date.now() - startTime;
const data = await response.json();
// Check status code (supports multiple expected statuses)
if (!expectedStatuses.includes(response.status)) {
return {
name,
endpoint,
method,
passed: false,
duration,
statusCode: response.status,
error: `Expected status ${expectedStatuses.join(' or ')}, got ${response.status}`,
details: JSON.stringify(data, null, 2),
};
}
// Validate response structure
if (validateResponse) {
const validationResult = validateResponse(data);
if (validationResult !== true) {
return {
name,
endpoint,
method,
passed: false,
duration,
statusCode: response.status,
error: typeof validationResult === 'string' ? validationResult : 'Response validation failed',
details: JSON.stringify(data, null, 2),
};
}
}
return {
name,
endpoint,
method,
passed: true,
duration,
statusCode: response.status,
details: VERBOSE ? JSON.stringify(data, null, 2) : undefined,
};
} catch (error) {
const duration = Date.now() - startTime;
return {
name,
endpoint,
method,
passed: false,
duration,
error: error instanceof Error ? error.message : String(error),
};
}
}
// ============================================================
// Response Validators
// ============================================================
function validateHealthResponse(data: unknown): boolean | string {
if (typeof data !== 'object' || data === null) {
return 'Response is not an object';
}
const response = data as Record<string, unknown>;
if (!response.status || typeof response.status !== 'string') {
return 'Missing or invalid status field';
}
// Accept both 'healthy' and 'degraded' status
if (response.status !== 'healthy' && response.status !== 'degraded') {
return `Invalid status value: ${response.status}`;
}
if (!response.timestamp || typeof response.timestamp !== 'string') {
return 'Missing or invalid timestamp field';
}
return true;
}
function validateInstancesResponse(data: unknown): boolean | string {
if (typeof data !== 'object' || data === null) {
return 'Response is not an object';
}
const response = data as Record<string, unknown>;
if (!response.success) {
return 'Response success field is false or missing';
}
if (!response.data || typeof response.data !== 'object') {
return 'Missing or invalid data field';
}
const responseData = response.data as Record<string, unknown>;
if (!Array.isArray(responseData.instances)) {
return 'Missing or invalid instances array';
}
if (!responseData.pagination || typeof responseData.pagination !== 'object') {
return 'Missing or invalid pagination field';
}
return true;
}
function validateSyncResponse(data: unknown): boolean | string {
if (typeof data !== 'object' || data === null) {
return 'Response is not an object';
}
const response = data as Record<string, unknown>;
if (typeof response.success !== 'boolean') {
return 'Missing or invalid success field';
}
if (response.success && (!response.data || typeof response.data !== 'object')) {
return 'Missing or invalid data field for successful sync';
}
return true;
}
function validateRecommendResponse(data: unknown): boolean | string {
if (typeof data !== 'object' || data === null) {
return 'Response is not an object';
}
const response = data as Record<string, unknown>;
if (!response.success) {
return 'Response success field is false or missing';
}
if (!response.data || typeof response.data !== 'object') {
return 'Missing or invalid data field';
}
const responseData = response.data as Record<string, unknown>;
if (!Array.isArray(responseData.recommendations)) {
return 'Missing or invalid recommendations array';
}
if (!responseData.requirements || typeof responseData.requirements !== 'object') {
return 'Missing or invalid requirements field';
}
if (!responseData.metadata || typeof responseData.metadata !== 'object') {
return 'Missing or invalid metadata field';
}
return true;
}
// ============================================================
// Test Suites
// ============================================================
async function testHealthEndpoint(): Promise<TestResult[]> {
console.log(color('\n📍 Testing /health', colors.cyan));
const tests: TestResult[] = [];
// Test without authentication (200 or 503 for degraded status)
tests.push(
await testWithDelay('GET /health (no auth)', '/health', {
expectStatus: [200, 503], // 503 is valid when system is degraded
validateResponse: validateHealthResponse,
})
);
// Test with authentication (200 or 503 for degraded status)
tests.push(
await testWithDelay('GET /health (with auth)', '/health', {
headers: { 'X-API-Key': API_KEY },
expectStatus: [200, 503], // 503 is valid when system is degraded
validateResponse: validateHealthResponse,
})
);
return tests;
}
async function testInstancesEndpoint(): Promise<TestResult[]> {
console.log(color('\n📍 Testing /instances', colors.cyan));
const tests: TestResult[] = [];
// Test basic query
tests.push(
await testWithDelay('GET /instances (basic)', '/instances', {
headers: { 'X-API-Key': API_KEY },
expectStatus: 200,
validateResponse: validateInstancesResponse,
})
);
// Test provider filter
tests.push(
await testWithDelay('GET /instances?provider=linode', '/instances?provider=linode', {
headers: { 'X-API-Key': API_KEY },
expectStatus: 200,
validateResponse: validateInstancesResponse,
})
);
// Test memory filter
tests.push(
await testWithDelay('GET /instances?min_memory_gb=4', '/instances?min_memory_gb=4', {
headers: { 'X-API-Key': API_KEY },
expectStatus: 200,
validateResponse: validateInstancesResponse,
})
);
// Test vCPU filter
tests.push(
await testWithDelay('GET /instances?min_vcpu=2&max_vcpu=8', '/instances?min_vcpu=2&max_vcpu=8', {
headers: { 'X-API-Key': API_KEY },
expectStatus: 200,
validateResponse: validateInstancesResponse,
})
);
// Test price filter
tests.push(
await testWithDelay('GET /instances?max_price=50', '/instances?max_price=50', {
headers: { 'X-API-Key': API_KEY },
expectStatus: 200,
validateResponse: validateInstancesResponse,
})
);
// Test GPU filter
tests.push(
await testWithDelay('GET /instances?has_gpu=true', '/instances?has_gpu=true', {
headers: { 'X-API-Key': API_KEY },
expectStatus: 200,
validateResponse: validateInstancesResponse,
})
);
// Test sorting
tests.push(
await testWithDelay('GET /instances?sort_by=price&order=asc', '/instances?sort_by=price&order=asc', {
headers: { 'X-API-Key': API_KEY },
expectStatus: 200,
validateResponse: validateInstancesResponse,
})
);
// Test pagination
tests.push(
await testWithDelay('GET /instances?limit=10&offset=0', '/instances?limit=10&offset=0', {
headers: { 'X-API-Key': API_KEY },
expectStatus: 200,
validateResponse: validateInstancesResponse,
})
);
// Test combined filters
tests.push(
await testWithDelay('GET /instances (combined)', '/instances?provider=linode&min_vcpu=2&max_price=100&sort_by=price&order=asc', {
headers: { 'X-API-Key': API_KEY },
expectStatus: 200,
validateResponse: validateInstancesResponse,
})
);
// Test invalid provider (should fail)
tests.push(
await testWithDelay('GET /instances?provider=invalid (error)', '/instances?provider=invalid', {
headers: { 'X-API-Key': API_KEY },
expectStatus: 400,
})
);
// Test without auth (should fail)
tests.push(
await testWithDelay('GET /instances (no auth - error)', '/instances', {
expectStatus: 401,
})
);
return tests;
}
async function testSyncEndpoint(): Promise<TestResult[]> {
console.log(color('\n📍 Testing /sync', colors.cyan));
const tests: TestResult[] = [];
// Test Linode sync
tests.push(
await testWithDelay('POST /sync (linode)', '/sync', {
method: 'POST',
headers: { 'X-API-Key': API_KEY },
body: { providers: ['linode'] },
expectStatus: 200,
validateResponse: validateSyncResponse,
})
);
// Test without auth (should fail)
tests.push(
await testWithDelay('POST /sync (no auth - error)', '/sync', {
method: 'POST',
body: { providers: ['linode'] },
expectStatus: 401,
})
);
// Test invalid provider (should fail)
tests.push(
await testWithDelay('POST /sync (invalid provider - error)', '/sync', {
method: 'POST',
headers: { 'X-API-Key': API_KEY },
body: { providers: ['invalid'] },
expectStatus: 400,
})
);
return tests;
}
async function testRecommendEndpoint(): Promise<TestResult[]> {
console.log(color('\n📍 Testing /recommend', colors.cyan));
const tests: TestResult[] = [];
// Test basic recommendation
tests.push(
await testWithDelay('POST /recommend (nginx+mysql)', '/recommend', {
method: 'POST',
headers: { 'X-API-Key': API_KEY },
body: {
stack: ['nginx', 'mysql'],
scale: 'small',
},
expectStatus: 200,
validateResponse: validateRecommendResponse,
})
);
// Test with budget constraint
tests.push(
await testWithDelay('POST /recommend (with budget)', '/recommend', {
method: 'POST',
headers: { 'X-API-Key': API_KEY },
body: {
stack: ['nginx', 'mysql', 'redis'],
scale: 'medium',
budget_max: 100,
},
expectStatus: 200,
validateResponse: validateRecommendResponse,
})
);
// Test large scale
tests.push(
await testWithDelay('POST /recommend (large scale)', '/recommend', {
method: 'POST',
headers: { 'X-API-Key': API_KEY },
body: {
stack: ['nginx', 'nodejs', 'postgresql', 'redis'],
scale: 'large',
},
expectStatus: 200,
validateResponse: validateRecommendResponse,
})
);
// Test invalid stack (should fail with 400)
tests.push(
await testWithDelay('POST /recommend (invalid stack - error)', '/recommend', {
method: 'POST',
headers: { 'X-API-Key': API_KEY },
body: {
stack: ['invalid-technology'],
scale: 'small',
},
expectStatus: 400, // Invalid stack returns 400 Bad Request
})
);
// Test invalid scale (should fail with 400)
tests.push(
await testWithDelay('POST /recommend (invalid scale - error)', '/recommend', {
method: 'POST',
headers: { 'X-API-Key': API_KEY },
body: {
stack: ['nginx'],
scale: 'invalid',
},
expectStatus: 400, // Invalid scale returns 400 Bad Request
})
);
// Test without auth (should fail)
tests.push(
await testWithDelay('POST /recommend (no auth - error)', '/recommend', {
method: 'POST',
body: {
stack: ['nginx'],
scale: 'small',
},
expectStatus: 401,
})
);
return tests;
}
// ============================================================
// Test Runner
// ============================================================
function printTestResult(result: TestResult): void {
const icon = result.passed ? color('✅', colors.green) : color('❌', colors.red);
const statusColor = result.passed ? colors.green : colors.red;
const statusText = result.statusCode ? `${result.statusCode}` : 'ERROR';
let output = ` ${icon} ${result.name} - ${color(statusText, statusColor)}`;
if (result.passed) {
output += ` ${color(`(${result.duration}ms)`, colors.gray)}`;
if (result.details && VERBOSE) {
output += `\n${color(' Response:', colors.gray)}\n${result.details.split('\n').map(line => ` ${line}`).join('\n')}`;
}
} else {
output += ` ${color(`(${result.duration}ms)`, colors.gray)}`;
if (result.error) {
output += `\n ${color('Error:', colors.red)} ${result.error}`;
}
if (result.details && VERBOSE) {
output += `\n${color(' Response:', colors.gray)}\n${result.details.split('\n').map(line => ` ${line}`).join('\n')}`;
}
}
console.log(output);
}
async function runTests(): Promise<TestReport> {
const startTime = Date.now();
const allResults: TestResult[] = [];
console.log(bold(color('\n🧪 Cloud Instances API Tester', colors.cyan)));
console.log(color('================================', colors.cyan));
console.log(`${color('Target:', colors.white)} ${API_URL}`);
const maskedKey = API_KEY.length > 4
? `${API_KEY.substring(0, 4)}${'*'.repeat(8)}`
: '****';
console.log(`${color('API Key:', colors.white)} ${maskedKey}`);
if (VERBOSE) {
console.log(color('Mode: VERBOSE', colors.yellow));
}
if (TARGET_ENDPOINT) {
console.log(color(`Filter: ${TARGET_ENDPOINT}`, colors.yellow));
}
// Run test suites
if (!TARGET_ENDPOINT || TARGET_ENDPOINT === '/health') {
const healthResults = await testHealthEndpoint();
healthResults.forEach(printTestResult);
allResults.push(...healthResults);
}
if (!TARGET_ENDPOINT || TARGET_ENDPOINT === '/instances') {
const instancesResults = await testInstancesEndpoint();
instancesResults.forEach(printTestResult);
allResults.push(...instancesResults);
}
if (!TARGET_ENDPOINT || TARGET_ENDPOINT === '/sync') {
const syncResults = await testSyncEndpoint();
syncResults.forEach(printTestResult);
allResults.push(...syncResults);
}
if (!TARGET_ENDPOINT || TARGET_ENDPOINT === '/recommend') {
const recommendResults = await testRecommendEndpoint();
recommendResults.forEach(printTestResult);
allResults.push(...recommendResults);
}
const duration = Date.now() - startTime;
const passed = allResults.filter(r => r.passed).length;
const failed = allResults.filter(r => !r.passed).length;
return {
total: allResults.length,
passed,
failed,
duration,
results: allResults,
};
}
function printReport(report: TestReport): void {
console.log(color('\n================================', colors.cyan));
console.log(bold(color('📊 Test Report', colors.cyan)));
console.log(` ${color('Total:', colors.white)} ${report.total} tests`);
console.log(` ${color('Passed:', colors.green)} ${report.passed} ${color('✅', colors.green)}`);
console.log(` ${color('Failed:', colors.red)} ${report.failed} ${color('❌', colors.red)}`);
console.log(` ${color('Duration:', colors.white)} ${(report.duration / 1000).toFixed(2)}s`);
if (report.failed > 0) {
console.log(color('\n⚠ Failed Tests:', colors.yellow));
report.results
.filter(r => !r.passed)
.forEach(r => {
console.log(` ${color('•', colors.red)} ${r.name}`);
if (r.error) {
console.log(` ${color(r.error, colors.red)}`);
}
});
}
console.log('');
}
// ============================================================
// Main
// ============================================================
async function main(): Promise<void> {
try {
// Initial delay to ensure rate limit window is clear
console.log(color('\n⏳ Waiting for rate limit window...', colors.gray));
await delay(2000);
const report = await runTests();
printReport(report);
// Exit with error code if any tests failed
process.exit(report.failed > 0 ? 1 : 0);
} catch (error) {
console.error(color('\n❌ Fatal error:', colors.red), error);
process.exit(1);
}
}
main();