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>
This commit is contained in:
@@ -4,10 +4,18 @@
|
||||
* 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
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
@@ -15,7 +23,23 @@
|
||||
// ============================================================
|
||||
|
||||
const API_URL = process.env.API_URL || 'https://cloud-instances-api.kappa-d8e.workers.dev';
|
||||
const API_KEY = process.env.API_KEY || '0f955192075f7d36b1432ec985713ac6aba7fe82ffa556e6f45381c5530ca042';
|
||||
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);
|
||||
@@ -585,7 +609,10 @@ async function runTests(): Promise<TestReport> {
|
||||
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)}...`);
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -3,7 +3,17 @@
|
||||
* E2E Scenario Tester for Cloud Instances API
|
||||
*
|
||||
* Tests complete user workflows against the deployed API
|
||||
* Run: npx tsx scripts/e2e-tester.ts [--scenario <name>] [--dry-run]
|
||||
*
|
||||
* Requirements:
|
||||
* API_KEY environment variable must be set
|
||||
*
|
||||
* Usage:
|
||||
* export API_KEY=your-api-key-here
|
||||
* npx tsx scripts/e2e-tester.ts [--scenario <name>] [--dry-run]
|
||||
*
|
||||
* Or use npm scripts:
|
||||
* npm run test:e2e
|
||||
* npm run test:e2e:dry
|
||||
*/
|
||||
|
||||
import process from 'process';
|
||||
@@ -12,8 +22,17 @@ import process from 'process';
|
||||
// Configuration
|
||||
// ============================================================
|
||||
|
||||
const API_URL = 'https://cloud-instances-api.kappa-d8e.workers.dev';
|
||||
const API_KEY = '0f955192075f7d36b1432ec985713ac6aba7fe82ffa556e6f45381c5530ca042';
|
||||
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 E2E tests:');
|
||||
console.error(' export API_KEY=your-api-key-here');
|
||||
console.error(' npm run test:e2e');
|
||||
console.error('\nOr create a .env file (see .env.example for reference)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
interface TestContext {
|
||||
recommendedInstanceId?: string;
|
||||
@@ -49,9 +68,14 @@ async function apiRequest(
|
||||
let data: unknown;
|
||||
|
||||
try {
|
||||
data = await response.json();
|
||||
const text = await response.text();
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch (err) {
|
||||
data = { error: 'Failed to parse JSON response', rawText: text };
|
||||
}
|
||||
} catch (err) {
|
||||
data = { error: 'Failed to parse JSON response', rawText: await response.text() };
|
||||
data = { error: 'Failed to read response body' };
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
404
scripts/migrate.ts
Normal file
404
scripts/migrate.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Database Migration Runner
|
||||
* Automatically detects and executes unapplied SQL migrations
|
||||
*
|
||||
* Usage:
|
||||
* npm run db:migrate # Run on local database
|
||||
* npm run db:migrate:remote # Run on remote database
|
||||
* npm run db:migrate:status # Show migration status
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'child_process';
|
||||
import { readdirSync, readFileSync, existsSync } from 'fs';
|
||||
import { join, basename, resolve } from 'path';
|
||||
|
||||
// ============================================================
|
||||
// Configuration
|
||||
// ============================================================
|
||||
|
||||
const DB_NAME = 'cloud-instances-db';
|
||||
const MIGRATIONS_DIR = join(process.cwd(), 'migrations');
|
||||
const MIGRATION_HISTORY_TABLE = 'migration_history';
|
||||
|
||||
type Environment = 'local' | 'remote';
|
||||
type Command = 'migrate' | 'status';
|
||||
|
||||
// ============================================================
|
||||
// Utility Functions
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Sanitize migration name (prevent SQL injection)
|
||||
* Only allows alphanumeric characters, underscores, and hyphens
|
||||
*/
|
||||
function sanitizeMigrationName(name: string): string {
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||||
throw new Error(`Invalid migration name: ${name} (only alphanumeric, underscore, and hyphen allowed)`);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file path (prevent path traversal)
|
||||
* Ensures the file is within the migrations directory
|
||||
*/
|
||||
function validateFilePath(filename: string): string {
|
||||
// Check filename format
|
||||
if (!/^[a-zA-Z0-9_-]+\.sql$/.test(filename)) {
|
||||
throw new Error(`Invalid filename format: ${filename}`);
|
||||
}
|
||||
|
||||
const filePath = join(MIGRATIONS_DIR, filename);
|
||||
const resolvedPath = resolve(filePath);
|
||||
const resolvedMigrationsDir = resolve(MIGRATIONS_DIR);
|
||||
|
||||
// Ensure resolved path is within migrations directory
|
||||
if (!resolvedPath.startsWith(resolvedMigrationsDir + '/') && resolvedPath !== resolvedMigrationsDir) {
|
||||
throw new Error(`Path traversal detected: ${filename}`);
|
||||
}
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute wrangler d1 command and return output
|
||||
* Uses spawnSync to prevent command injection
|
||||
*/
|
||||
function executeD1Command(sql: string, env: Environment): string {
|
||||
const envFlag = env === 'remote' ? '--remote' : '--local';
|
||||
const args = ['d1', 'execute', DB_NAME, envFlag, '--command', sql];
|
||||
|
||||
try {
|
||||
const result = spawnSync('wrangler', args, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(`Failed to execute wrangler: ${result.error.message}`);
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`D1 command failed with exit code ${result.status}: ${result.stderr}`);
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
} catch (error: any) {
|
||||
throw new Error(`D1 command failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute SQL file using wrangler d1
|
||||
* Uses spawnSync to prevent command injection
|
||||
*/
|
||||
function executeD1File(filePath: string, env: Environment): void {
|
||||
const envFlag = env === 'remote' ? '--remote' : '--local';
|
||||
const args = ['d1', 'execute', DB_NAME, envFlag, '--file', filePath];
|
||||
|
||||
try {
|
||||
const result = spawnSync('wrangler', args, {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'inherit'
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(`Failed to execute wrangler: ${result.error.message}`);
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Migration file execution failed with exit code ${result.status}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw new Error(`Migration file execution failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of applied migrations from database
|
||||
*/
|
||||
function getAppliedMigrations(env: Environment): Set<string> {
|
||||
try {
|
||||
const sql = `SELECT migration_name FROM ${MIGRATION_HISTORY_TABLE} WHERE success = 1`;
|
||||
const output = executeD1Command(sql, env);
|
||||
|
||||
const migrations = new Set<string>();
|
||||
const lines = output.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.includes('migration_name') && !trimmed.includes('─')) {
|
||||
migrations.add(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return migrations;
|
||||
} catch (error: any) {
|
||||
// If table doesn't exist, return empty set
|
||||
if (error.message.includes('no such table')) {
|
||||
return new Set<string>();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of migration files from migrations directory
|
||||
*/
|
||||
function getMigrationFiles(): string[] {
|
||||
if (!existsSync(MIGRATIONS_DIR)) {
|
||||
throw new Error(`Migrations directory not found: ${MIGRATIONS_DIR}`);
|
||||
}
|
||||
|
||||
const files = readdirSync(MIGRATIONS_DIR)
|
||||
.filter(f => f.endsWith('.sql'))
|
||||
.filter(f => f !== 'README.md')
|
||||
.sort(); // Alphabetical sort ensures numeric order (000, 002, 003, 004)
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract migration name from filename (without .sql extension)
|
||||
*/
|
||||
function getMigrationName(filename: string): string {
|
||||
return basename(filename, '.sql');
|
||||
}
|
||||
|
||||
/**
|
||||
* Record migration execution in history table
|
||||
*/
|
||||
function recordMigration(
|
||||
migrationName: string,
|
||||
executionTimeMs: number,
|
||||
success: boolean,
|
||||
errorMessage: string | null,
|
||||
env: Environment
|
||||
): void {
|
||||
// Sanitize migration name to prevent SQL injection
|
||||
const safeMigrationName = sanitizeMigrationName(migrationName);
|
||||
|
||||
// Escape error message (if any) to prevent SQL injection
|
||||
const safeErrorMessage = errorMessage
|
||||
? `'${errorMessage.replace(/'/g, "''")}'`
|
||||
: 'NULL';
|
||||
|
||||
const sql = `
|
||||
INSERT INTO ${MIGRATION_HISTORY_TABLE}
|
||||
(migration_name, execution_time_ms, success, error_message)
|
||||
VALUES
|
||||
('${safeMigrationName}', ${executionTimeMs}, ${success ? 1 : 0}, ${safeErrorMessage})
|
||||
`;
|
||||
|
||||
try {
|
||||
executeD1Command(sql, env);
|
||||
} catch (error: any) {
|
||||
console.error(`[ERROR] Failed to record migration: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single migration file
|
||||
*/
|
||||
function executeMigration(filename: string, env: Environment): boolean {
|
||||
const migrationName = getMigrationName(filename);
|
||||
|
||||
// Validate file path to prevent path traversal attacks
|
||||
const filePath = validateFilePath(filename);
|
||||
|
||||
console.log(`\n[Migrate] Executing: ${migrationName}`);
|
||||
console.log(`[Migrate] File: ${filename}`);
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
executeD1File(filePath, env);
|
||||
const executionTime = Date.now() - startTime;
|
||||
|
||||
console.log(`[Migrate] ✅ Success (${executionTime}ms)`);
|
||||
|
||||
// Record success (only if not 000_migration_history itself)
|
||||
if (migrationName !== '000_migration_history') {
|
||||
recordMigration(migrationName, executionTime, true, null, env);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
const executionTime = Date.now() - startTime;
|
||||
const errorMessage = error.message || 'Unknown error';
|
||||
|
||||
console.error(`[Migrate] ❌ Failed (${executionTime}ms)`);
|
||||
console.error(`[Migrate] Error: ${errorMessage}`);
|
||||
|
||||
// Record failure (only if not 000_migration_history itself)
|
||||
if (migrationName !== '000_migration_history') {
|
||||
recordMigration(migrationName, executionTime, false, errorMessage, env);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Main Migration Logic
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Run all pending migrations
|
||||
*/
|
||||
function runMigrations(env: Environment): void {
|
||||
console.log(`\n${'='.repeat(60)}`);
|
||||
console.log(`[Migrate] Database Migration Runner`);
|
||||
console.log(`[Migrate] Environment: ${env}`);
|
||||
console.log(`[Migrate] Database: ${DB_NAME}`);
|
||||
console.log(`${'='.repeat(60)}\n`);
|
||||
|
||||
const allMigrations = getMigrationFiles();
|
||||
console.log(`[Migrate] Found ${allMigrations.length} migration files`);
|
||||
|
||||
// Ensure migration history table exists first
|
||||
const historyMigration = allMigrations.find(f => f.startsWith('000_migration_history'));
|
||||
if (!historyMigration) {
|
||||
console.error('[Migrate] ❌ Migration history table file (000_migration_history.sql) not found!');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Execute history table migration first
|
||||
console.log(`[Migrate] Ensuring migration tracking table exists...`);
|
||||
if (!executeMigration(historyMigration, env)) {
|
||||
console.error('[Migrate] ❌ Failed to create migration history table');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Get applied migrations
|
||||
const appliedMigrations = getAppliedMigrations(env);
|
||||
console.log(`[Migrate] ${appliedMigrations.size} migrations already applied`);
|
||||
|
||||
// Filter pending migrations (excluding 000_migration_history)
|
||||
const pendingMigrations = allMigrations
|
||||
.filter(f => !f.startsWith('000_migration_history'))
|
||||
.filter(f => !appliedMigrations.has(getMigrationName(f)));
|
||||
|
||||
if (pendingMigrations.length === 0) {
|
||||
console.log('\n[Migrate] ✅ All migrations are up to date. Nothing to do.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n[Migrate] ${pendingMigrations.length} pending migrations to execute:`);
|
||||
pendingMigrations.forEach(f => console.log(` - ${getMigrationName(f)}`));
|
||||
|
||||
// Execute each pending migration
|
||||
let successCount = 0;
|
||||
let failureCount = 0;
|
||||
|
||||
for (const filename of pendingMigrations) {
|
||||
const success = executeMigration(filename, env);
|
||||
|
||||
if (success) {
|
||||
successCount++;
|
||||
} else {
|
||||
failureCount++;
|
||||
console.error(`\n[Migrate] ❌ Migration failed: ${filename}`);
|
||||
console.error(`[Migrate] Stopping migration process.`);
|
||||
break; // Stop on first failure
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log(`\n${'='.repeat(60)}`);
|
||||
console.log(`[Migrate] Migration Summary:`);
|
||||
console.log(`[Migrate] ✅ Successful: ${successCount}`);
|
||||
if (failureCount > 0) {
|
||||
console.log(`[Migrate] ❌ Failed: ${failureCount}`);
|
||||
}
|
||||
console.log(`${'='.repeat(60)}\n`);
|
||||
|
||||
if (failureCount > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show migration status
|
||||
*/
|
||||
function showStatus(env: Environment): void {
|
||||
console.log(`\n${'='.repeat(60)}`);
|
||||
console.log(`[Migrate] Migration Status`);
|
||||
console.log(`[Migrate] Environment: ${env}`);
|
||||
console.log(`[Migrate] Database: ${DB_NAME}`);
|
||||
console.log(`${'='.repeat(60)}\n`);
|
||||
|
||||
const allMigrations = getMigrationFiles();
|
||||
console.log(`[Migrate] Total migration files: ${allMigrations.length}`);
|
||||
|
||||
try {
|
||||
const appliedMigrations = getAppliedMigrations(env);
|
||||
console.log(`[Migrate] Applied migrations: ${appliedMigrations.size}`);
|
||||
|
||||
// Show detailed status
|
||||
console.log(`\n[Migrate] Detailed Status:\n`);
|
||||
|
||||
for (const filename of allMigrations) {
|
||||
const migrationName = getMigrationName(filename);
|
||||
const isApplied = appliedMigrations.has(migrationName);
|
||||
const status = isApplied ? '✅' : '⏳';
|
||||
const label = isApplied ? 'Applied' : 'Pending';
|
||||
|
||||
console.log(` ${status} ${migrationName.padEnd(40)} [${label}]`);
|
||||
}
|
||||
|
||||
const pendingCount = allMigrations.length - appliedMigrations.size;
|
||||
if (pendingCount > 0) {
|
||||
console.log(`\n[Migrate] ⚠️ ${pendingCount} migrations pending execution`);
|
||||
console.log(`[Migrate] Run 'npm run db:migrate' to apply pending migrations`);
|
||||
} else {
|
||||
console.log(`\n[Migrate] ✅ All migrations are up to date`);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`[Migrate] ❌ Error reading migration status: ${error.message}`);
|
||||
console.error(`[Migrate] The migration_history table may not exist yet.`);
|
||||
console.error(`[Migrate] Run 'npm run db:migrate' to initialize the system.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n${'='.repeat(60)}\n`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CLI Entry Point
|
||||
// ============================================================
|
||||
|
||||
function main(): void {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
// Parse command
|
||||
let command: Command = 'migrate';
|
||||
if (args.includes('--status')) {
|
||||
command = 'status';
|
||||
}
|
||||
|
||||
// Parse environment
|
||||
let env: Environment = 'local';
|
||||
if (args.includes('--remote')) {
|
||||
env = 'remote';
|
||||
}
|
||||
|
||||
// Execute command
|
||||
try {
|
||||
if (command === 'status') {
|
||||
showStatus(env);
|
||||
} else {
|
||||
runMigrations(env);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`\n[Migrate] ❌ Fatal error: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run if executed directly
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
Reference in New Issue
Block a user