/** * Comprehensive Unit Tests for security.ts * * 테스트 범위: * 1. validateWebhookRequest() - 통합 보안 검증 * 2. checkRateLimit() - KV 기반 Rate Limiting * 3. timingSafeEqual() - Timing-safe 문자열 비교 * 4. Edge cases 및 에러 핸들링 * * NOTE: validateWebhookRequest는 통합 테스트이므로 개별 함수들은 간접 테스트 */ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { validateWebhookRequest, checkRateLimit, timingSafeEqual } from '../src/security'; import type { Env, TelegramUpdate } from '../src/types'; /** * Mock KVNamespace implementation * - In-memory Map으로 실제 KV 동작 시뮬레이션 * - TTL 자동 만료 로직 포함 */ const createMockKV = () => { const store = new Map(); return { get: vi.fn(async (key: string, type?: string) => { const entry = store.get(key); if (!entry) return null; // TTL 만료 확인 if (entry.expiration && entry.expiration < Date.now() / 1000) { store.delete(key); return null; } // type이 'json'이면 파싱해서 반환 if (type === 'json') { try { return JSON.parse(entry.value); } catch { return null; } } return entry.value; }), put: vi.fn(async (key: string, value: string, options?: { expirationTtl?: number }) => { const expiration = options?.expirationTtl ? Math.floor(Date.now() / 1000) + options.expirationTtl : undefined; store.set(key, { value, expiration }); }), delete: vi.fn(async (key: string) => { store.delete(key); }), _store: store, // 테스트용 직접 접근 }; }; /** * Mock Request 생성 헬퍼 */ const createMockRequest = (options: { method?: string; headers?: Record; body?: unknown; }): Request => { const { method = 'POST', headers = {}, body = {} } = options; const defaultHeaders = { 'Content-Type': 'application/json', ...headers, }; return { method, headers: { get: (name: string) => defaultHeaders[name] || null, }, json: async () => body, } as unknown as Request; }; /** * Mock Env 생성 */ const createMockEnv = (webhookSecret: string = 'test-secret'): Partial => ({ WEBHOOK_SECRET: webhookSecret, }); /** * 유효한 Telegram Update 샘플 */ const validUpdate: TelegramUpdate = { update_id: 123456789, message: { message_id: 1, date: Math.floor(Date.now() / 1000), // 현재 시간 (Unix timestamp) chat: { id: 123, type: 'private', }, from: { id: 123, is_bot: false, first_name: 'Test', }, text: '안녕하세요', }, }; describe('timingSafeEqual', () => { describe('기본 동작', () => { it('should return true for equal strings', () => { expect(timingSafeEqual('hello', 'hello')).toBe(true); expect(timingSafeEqual('test123', 'test123')).toBe(true); expect(timingSafeEqual('secret-token-123', 'secret-token-123')).toBe(true); }); it('should return false for different strings', () => { expect(timingSafeEqual('hello', 'world')).toBe(false); expect(timingSafeEqual('test123', 'test124')).toBe(false); }); it('should return false for different lengths', () => { expect(timingSafeEqual('hello', 'hello!')).toBe(false); expect(timingSafeEqual('short', 'verylongstring')).toBe(false); }); it('should return false for empty strings (security policy)', () => { // 빈 문자열은 유효한 Secret Token이 아니므로 false expect(timingSafeEqual('', '')).toBe(false); expect(timingSafeEqual('valid', '')).toBe(false); expect(timingSafeEqual('', 'valid')).toBe(false); }); }); describe('null/undefined 처리', () => { it('should return false for null values', () => { expect(timingSafeEqual(null, 'hello')).toBe(false); expect(timingSafeEqual('hello', null)).toBe(false); expect(timingSafeEqual(null, null)).toBe(false); }); it('should return false for undefined values', () => { expect(timingSafeEqual(undefined, 'hello')).toBe(false); expect(timingSafeEqual('hello', undefined)).toBe(false); expect(timingSafeEqual(undefined, undefined)).toBe(false); }); }); describe('타이밍 안전성', () => { it('should use constant-time comparison', () => { // 같은 길이의 문자열 비교 시간이 일정해야 함 const a = 'a'.repeat(100); const b = 'a'.repeat(99) + 'b'; // 마지막만 다름 const c = 'b' + 'a'.repeat(99); // 처음만 다름 // 모두 false를 반환해야 함 expect(timingSafeEqual(a, b)).toBe(false); expect(timingSafeEqual(a, c)).toBe(false); // 비트 연산(XOR)으로 모든 문자를 비교하므로 // 어느 위치가 달라도 동일한 연산 수행 }); it('should handle special characters', () => { expect(timingSafeEqual('hello!@#$', 'hello!@#$')).toBe(true); expect(timingSafeEqual('🔥🔥🔥', '🔥🔥🔥')).toBe(true); expect(timingSafeEqual('hello!@#$', 'hello!@#%')).toBe(false); }); }); }); describe('validateWebhookRequest', () => { describe('HTTP 메서드 검증', () => { it('should reject non-POST requests', async () => { const request = createMockRequest({ method: 'GET' }); const env = createMockEnv(); const result = await validateWebhookRequest(request, env as Env); expect(result.valid).toBe(false); expect(result.error).toBe('Method not allowed'); }); it('should accept POST requests', async () => { const request = createMockRequest({ method: 'POST', headers: { 'X-Telegram-Bot-Api-Secret-Token': 'test-secret' }, body: validUpdate, }); const env = createMockEnv(); const result = await validateWebhookRequest(request, env as Env); expect(result.valid).toBe(true); }); }); describe('Content-Type 검증', () => { it('should reject non-JSON content type', async () => { const request = createMockRequest({ headers: { 'Content-Type': 'text/plain', 'X-Telegram-Bot-Api-Secret-Token': 'test-secret', }, body: validUpdate, }); const env = createMockEnv(); const result = await validateWebhookRequest(request, env as Env); expect(result.valid).toBe(false); expect(result.error).toBe('Invalid content type'); }); it('should accept application/json', async () => { const request = createMockRequest({ headers: { 'Content-Type': 'application/json', 'X-Telegram-Bot-Api-Secret-Token': 'test-secret', }, body: validUpdate, }); const env = createMockEnv(); const result = await validateWebhookRequest(request, env as Env); expect(result.valid).toBe(true); }); it('should accept application/json with charset', async () => { const request = createMockRequest({ headers: { 'Content-Type': 'application/json; charset=utf-8', 'X-Telegram-Bot-Api-Secret-Token': 'test-secret', }, body: validUpdate, }); const env = createMockEnv(); const result = await validateWebhookRequest(request, env as Env); expect(result.valid).toBe(true); }); }); describe('Secret Token 검증', () => { it('should reject request with missing WEBHOOK_SECRET', async () => { const request = createMockRequest({ headers: { 'X-Telegram-Bot-Api-Secret-Token': 'any-secret' }, body: validUpdate, }); const env = { WEBHOOK_SECRET: '' }; // 빈 문자열 const result = await validateWebhookRequest(request, env as Env); expect(result.valid).toBe(false); expect(result.error).toBe('Security configuration error'); }); it('should reject request with invalid secret token', async () => { const request = createMockRequest({ headers: { 'X-Telegram-Bot-Api-Secret-Token': 'wrong-secret' }, body: validUpdate, }); const env = createMockEnv('correct-secret'); const result = await validateWebhookRequest(request, env as Env); expect(result.valid).toBe(false); expect(result.error).toBe('Invalid secret token'); }); it('should reject request with missing secret header', async () => { const request = createMockRequest({ headers: {}, // X-Telegram-Bot-Api-Secret-Token 없음 body: validUpdate, }); const env = createMockEnv(); const result = await validateWebhookRequest(request, env as Env); expect(result.valid).toBe(false); expect(result.error).toBe('Invalid secret token'); }); it('should accept request with valid secret token', async () => { const request = createMockRequest({ headers: { 'X-Telegram-Bot-Api-Secret-Token': 'test-secret' }, body: validUpdate, }); const env = createMockEnv('test-secret'); const result = await validateWebhookRequest(request, env as Env); expect(result.valid).toBe(true); }); it('should use timing-safe comparison for secrets', async () => { // 동일한 길이의 다른 시크릿 const correctSecret = 'a'.repeat(32); const wrongSecret = 'b'.repeat(32); const request = createMockRequest({ headers: { 'X-Telegram-Bot-Api-Secret-Token': wrongSecret }, body: validUpdate, }); const env = createMockEnv(correctSecret); const result = await validateWebhookRequest(request, env as Env); expect(result.valid).toBe(false); expect(result.error).toBe('Invalid secret token'); }); }); describe('Request Body 검증', () => { it('should reject invalid JSON', async () => { const request = { method: 'POST', headers: { get: (name: string) => { if (name === 'Content-Type') return 'application/json'; if (name === 'X-Telegram-Bot-Api-Secret-Token') return 'test-secret'; return null; }, }, json: async () => { throw new Error('Invalid JSON'); }, } as unknown as Request; const env = createMockEnv(); const result = await validateWebhookRequest(request, env as Env); expect(result.valid).toBe(false); expect(result.error).toBe('Invalid JSON body'); }); it('should reject body without update_id', async () => { const invalidBody = { message: validUpdate.message, // update_id 없음 }; const request = createMockRequest({ headers: { 'X-Telegram-Bot-Api-Secret-Token': 'test-secret' }, body: invalidBody, }); const env = createMockEnv(); const result = await validateWebhookRequest(request, env as Env); expect(result.valid).toBe(false); expect(result.error).toBe('Invalid request body structure'); }); it('should reject body with non-number update_id', async () => { const invalidBody = { update_id: '123', // 문자열 message: validUpdate.message, }; const request = createMockRequest({ headers: { 'X-Telegram-Bot-Api-Secret-Token': 'test-secret' }, body: invalidBody, }); const env = createMockEnv(); const result = await validateWebhookRequest(request, env as Env); expect(result.valid).toBe(false); expect(result.error).toBe('Invalid request body structure'); }); it('should accept valid Telegram update', async () => { const request = createMockRequest({ headers: { 'X-Telegram-Bot-Api-Secret-Token': 'test-secret' }, body: validUpdate, }); const env = createMockEnv(); const result = await validateWebhookRequest(request, env as Env); expect(result.valid).toBe(true); expect(result.update).toEqual(validUpdate); }); }); describe('타임스탬프 검증 (리플레이 공격 방지)', () => { it('should reject old messages (> 5 minutes)', async () => { const oldUpdate = { ...validUpdate, message: { ...validUpdate.message!, date: Math.floor(Date.now() / 1000) - (6 * 60), // 6분 전 }, }; const request = createMockRequest({ headers: { 'X-Telegram-Bot-Api-Secret-Token': 'test-secret' }, body: oldUpdate, }); const env = createMockEnv(); const result = await validateWebhookRequest(request, env as Env); expect(result.valid).toBe(false); expect(result.error).toBe('Message too old'); }); it('should accept recent messages (< 5 minutes)', async () => { const recentUpdate = { ...validUpdate, message: { ...validUpdate.message!, date: Math.floor(Date.now() / 1000) - (2 * 60), // 2분 전 }, }; const request = createMockRequest({ headers: { 'X-Telegram-Bot-Api-Secret-Token': 'test-secret' }, body: recentUpdate, }); const env = createMockEnv(); const result = await validateWebhookRequest(request, env as Env); expect(result.valid).toBe(true); }); it('should accept updates without message (callback_query)', async () => { // message가 없는 경우 (callback_query 등) const updateWithoutMessage = { update_id: 123456789, callback_query: { id: '123', from: validUpdate.message!.from, message: validUpdate.message, chat_instance: '123', data: 'test_callback', }, }; const request = createMockRequest({ headers: { 'X-Telegram-Bot-Api-Secret-Token': 'test-secret' }, body: updateWithoutMessage, }); const env = createMockEnv(); const result = await validateWebhookRequest(request, env as Env); expect(result.valid).toBe(true); }); }); describe('IP 화이트리스트 (선택적)', () => { it('should log warning for non-Telegram IP', async () => { // Note: IP 검증은 경고만 기록하고 차단하지 않음 const request = createMockRequest({ headers: { 'X-Telegram-Bot-Api-Secret-Token': 'test-secret', 'CF-Connecting-IP': '1.2.3.4', // Telegram IP 아님 }, body: validUpdate, }); const env = createMockEnv(); const result = await validateWebhookRequest(request, env as Env); // IP 검증은 차단하지 않으므로 valid=true expect(result.valid).toBe(true); }); it('should accept Telegram IP ranges', async () => { const request = createMockRequest({ headers: { 'X-Telegram-Bot-Api-Secret-Token': 'test-secret', 'CF-Connecting-IP': '149.154.160.1', // Telegram IP }, body: validUpdate, }); const env = createMockEnv(); const result = await validateWebhookRequest(request, env as Env); expect(result.valid).toBe(true); }); }); }); describe('checkRateLimit', () => { let mockKV: ReturnType; beforeEach(() => { mockKV = createMockKV(); vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); describe('첫 요청 (제한 내)', () => { it('should allow first request', async () => { const allowed = await checkRateLimit( mockKV as unknown as KVNamespace, 'user1', 30, 60000 ); expect(allowed).toBe(true); // rate:user1 키에 저장되었는지 확인 const stored = mockKV._store.get('rate:user1'); expect(stored).toBeDefined(); // count=1인지 확인 const data = JSON.parse(stored!.value); expect(data.count).toBe(1); }); }); describe('제한 내 연속 요청', () => { it('should allow multiple requests within limit', async () => { // 30번 요청 for (let i = 0; i < 30; i++) { const allowed = await checkRateLimit( mockKV as unknown as KVNamespace, 'user1', 30, 60000 ); expect(allowed).toBe(true); } // count=30인지 확인 const stored = mockKV._store.get('rate:user1'); const data = JSON.parse(stored!.value); expect(data.count).toBe(30); }); }); describe('제한 초과 시 차단', () => { it('should block requests over limit', async () => { // 30번 요청 (허용) for (let i = 0; i < 30; i++) { await checkRateLimit(mockKV as unknown as KVNamespace, 'user1', 30, 60000); } // 31번째 요청 (차단) const allowed = await checkRateLimit( mockKV as unknown as KVNamespace, 'user1', 30, 60000 ); expect(allowed).toBe(false); // count는 여전히 30 const stored = mockKV._store.get('rate:user1'); const data = JSON.parse(stored!.value); expect(data.count).toBe(30); }); }); describe('TTL 만료 후 리셋', () => { it('should reset window after TTL expiration', async () => { // 첫 요청 await checkRateLimit(mockKV as unknown as KVNamespace, 'user1', 30, 60000); // 61초 경과 (TTL 만료) vi.advanceTimersByTime(61000); // 새 윈도우 시작 const allowed = await checkRateLimit( mockKV as unknown as KVNamespace, 'user1', 30, 60000 ); expect(allowed).toBe(true); // count=1로 리셋되었는지 확인 const stored = mockKV._store.get('rate:user1'); if (stored) { const data = JSON.parse(stored.value); expect(data.count).toBe(1); } }); }); describe('사용자별 독립성', () => { it('should track different users independently', async () => { // user1: 30번 요청 for (let i = 0; i < 30; i++) { await checkRateLimit(mockKV as unknown as KVNamespace, 'user1', 30, 60000); } // user2: 첫 요청 (허용되어야 함) const allowed = await checkRateLimit( mockKV as unknown as KVNamespace, 'user2', 30, 60000 ); expect(allowed).toBe(true); // user1은 차단되어야 함 const user1Allowed = await checkRateLimit( mockKV as unknown as KVNamespace, 'user1', 30, 60000 ); expect(user1Allowed).toBe(false); }); }); describe('에지 케이스', () => { it('should handle maxRequests=1', async () => { const allowed1 = await checkRateLimit( mockKV as unknown as KVNamespace, 'user1', 1, 60000 ); expect(allowed1).toBe(true); const allowed2 = await checkRateLimit( mockKV as unknown as KVNamespace, 'user1', 1, 60000 ); expect(allowed2).toBe(false); }); it('should handle very short windows', async () => { // 1초 윈도우 const allowed1 = await checkRateLimit( mockKV as unknown as KVNamespace, 'user1', 30, 1000 ); expect(allowed1).toBe(true); // 2초 경과 vi.advanceTimersByTime(2000); // 새 윈도우 const allowed2 = await checkRateLimit( mockKV as unknown as KVNamespace, 'user1', 30, 1000 ); expect(allowed2).toBe(true); }); it('should handle KV errors gracefully', async () => { // KV.get() 에러 mockKV.get.mockRejectedValueOnce(new Error('KV get failed')); const allowed = await checkRateLimit( mockKV as unknown as KVNamespace, 'user1', 30, 60000 ); // 에러 시 허용 (안전한 실패) expect(allowed).toBe(true); }); it('should handle KV.put() errors gracefully', async () => { // KV.put() 에러 mockKV.put.mockRejectedValueOnce(new Error('KV put failed')); const allowed = await checkRateLimit( mockKV as unknown as KVNamespace, 'user1', 30, 60000 ); // 에러 시 허용 (안전한 실패) expect(allowed).toBe(true); }); }); });