- 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>
699 lines
20 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|