Files
cloud-server/scripts/api-tester.ts
kappa abe052b538 feat: 코드 품질 개선 및 추천 API 구현
## 주요 변경사항

### 신규 기능
- POST /recommend: 기술 스택 기반 인스턴스 추천 API
- 아시아 리전 필터링 (Seoul, Tokyo, Osaka, Singapore)
- 매칭 점수 알고리즘 (메모리 40%, vCPU 30%, 가격 20%, 스토리지 10%)

### 보안 강화 (Security 9.0/10)
- API Key 인증 + constant-time 비교 (타이밍 공격 방어)
- Rate Limiting: KV 기반 분산 처리, fail-closed 정책
- IP Spoofing 방지 (CF-Connecting-IP만 신뢰)
- 요청 본문 10KB 제한
- CORS + 보안 헤더 (CSP, HSTS, X-Frame-Options)

### 성능 최적화 (Performance 9.0/10)
- Generator 패턴: AWS pricing 메모리 95% 감소
- D1 batch 쿼리: N+1 문제 해결
- 복합 인덱스 추가 (migrations/002)

### 코드 품질 (QA 9.0/10)
- 127개 테스트 (vitest)
- 구조화된 로깅 (민감정보 마스킹)
- 상수 중앙화 (constants.ts)
- 입력 검증 유틸리티 (utils/validation.ts)

### Vultr 연동 수정
- relay 서버 헤더: Authorization: Bearer → X-API-Key

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 11:57:35 +09:00

679 lines
19 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.
*
* Usage:
* npx tsx scripts/api-tester.ts
* npx tsx scripts/api-tester.ts --endpoint /health
* npx tsx scripts/api-tester.ts --verbose
*/
// ============================================================
// Configuration
// ============================================================
const API_URL = process.env.API_URL || 'https://cloud-instances-api.kappa-d8e.workers.dev';
const API_KEY = process.env.API_KEY || '0f955192075f7d36b1432ec985713ac6aba7fe82ffa556e6f45381c5530ca042';
// 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}`);
console.log(`${color('API Key:', colors.white)} ${API_KEY.substring(0, 20)}...`);
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();