Files
telegram-bot-workers/tests/security.test.ts
kappa 18e7d3ca6e test: add comprehensive unit tests for utils and security
- Add security.test.ts: 36 tests for webhook validation, rate limiting
- Add circuit-breaker.test.ts: 31 tests for state transitions
- Add retry.test.ts: 25 tests for exponential backoff
- Add api-helper.test.ts: 25 tests for API abstraction
- Add optimistic-lock.test.ts: 11 tests for concurrency control
- Add summary-service.test.ts: 29 tests for profile system

Total: 157 new test cases (222 passing overall)

- Fix setup.ts D1 schema initialization for Miniflare
- Update vitest.config.ts to exclude demo files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 11:38:49 +09:00

699 lines
20 KiB
TypeScript

/**
* 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<string, { value: string; expiration?: number }>();
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<string, string>;
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<Env> => ({
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<typeof createMockKV>;
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);
});
});
});