mcp-security-framework 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcp_security_framework/__init__.py +96 -0
- mcp_security_framework/cli/__init__.py +18 -0
- mcp_security_framework/cli/cert_cli.py +511 -0
- mcp_security_framework/cli/security_cli.py +791 -0
- mcp_security_framework/constants.py +209 -0
- mcp_security_framework/core/__init__.py +61 -0
- mcp_security_framework/core/auth_manager.py +1011 -0
- mcp_security_framework/core/cert_manager.py +1663 -0
- mcp_security_framework/core/permission_manager.py +735 -0
- mcp_security_framework/core/rate_limiter.py +602 -0
- mcp_security_framework/core/security_manager.py +943 -0
- mcp_security_framework/core/ssl_manager.py +735 -0
- mcp_security_framework/examples/__init__.py +75 -0
- mcp_security_framework/examples/django_example.py +615 -0
- mcp_security_framework/examples/fastapi_example.py +472 -0
- mcp_security_framework/examples/flask_example.py +506 -0
- mcp_security_framework/examples/gateway_example.py +803 -0
- mcp_security_framework/examples/microservice_example.py +690 -0
- mcp_security_framework/examples/standalone_example.py +576 -0
- mcp_security_framework/middleware/__init__.py +250 -0
- mcp_security_framework/middleware/auth_middleware.py +292 -0
- mcp_security_framework/middleware/fastapi_auth_middleware.py +447 -0
- mcp_security_framework/middleware/fastapi_middleware.py +757 -0
- mcp_security_framework/middleware/flask_auth_middleware.py +465 -0
- mcp_security_framework/middleware/flask_middleware.py +591 -0
- mcp_security_framework/middleware/mtls_middleware.py +439 -0
- mcp_security_framework/middleware/rate_limit_middleware.py +403 -0
- mcp_security_framework/middleware/security_middleware.py +507 -0
- mcp_security_framework/schemas/__init__.py +109 -0
- mcp_security_framework/schemas/config.py +694 -0
- mcp_security_framework/schemas/models.py +709 -0
- mcp_security_framework/schemas/responses.py +686 -0
- mcp_security_framework/tests/__init__.py +0 -0
- mcp_security_framework/utils/__init__.py +121 -0
- mcp_security_framework/utils/cert_utils.py +525 -0
- mcp_security_framework/utils/crypto_utils.py +475 -0
- mcp_security_framework/utils/validation_utils.py +571 -0
- mcp_security_framework-0.1.0.dist-info/METADATA +411 -0
- mcp_security_framework-0.1.0.dist-info/RECORD +76 -0
- mcp_security_framework-0.1.0.dist-info/WHEEL +5 -0
- mcp_security_framework-0.1.0.dist-info/entry_points.txt +3 -0
- mcp_security_framework-0.1.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/test_cli/__init__.py +0 -0
- tests/test_cli/test_cert_cli.py +379 -0
- tests/test_cli/test_security_cli.py +657 -0
- tests/test_core/__init__.py +0 -0
- tests/test_core/test_auth_manager.py +582 -0
- tests/test_core/test_cert_manager.py +795 -0
- tests/test_core/test_permission_manager.py +395 -0
- tests/test_core/test_rate_limiter.py +626 -0
- tests/test_core/test_security_manager.py +841 -0
- tests/test_core/test_ssl_manager.py +532 -0
- tests/test_examples/__init__.py +8 -0
- tests/test_examples/test_fastapi_example.py +264 -0
- tests/test_examples/test_flask_example.py +238 -0
- tests/test_examples/test_standalone_example.py +292 -0
- tests/test_integration/__init__.py +0 -0
- tests/test_integration/test_auth_flow.py +502 -0
- tests/test_integration/test_certificate_flow.py +527 -0
- tests/test_integration/test_fastapi_integration.py +341 -0
- tests/test_integration/test_flask_integration.py +398 -0
- tests/test_integration/test_standalone_integration.py +493 -0
- tests/test_middleware/__init__.py +0 -0
- tests/test_middleware/test_fastapi_middleware.py +523 -0
- tests/test_middleware/test_flask_middleware.py +582 -0
- tests/test_middleware/test_security_middleware.py +493 -0
- tests/test_schemas/__init__.py +0 -0
- tests/test_schemas/test_config.py +811 -0
- tests/test_schemas/test_models.py +879 -0
- tests/test_schemas/test_responses.py +1054 -0
- tests/test_schemas/test_serialization.py +493 -0
- tests/test_utils/__init__.py +0 -0
- tests/test_utils/test_cert_utils.py +510 -0
- tests/test_utils/test_crypto_utils.py +603 -0
- tests/test_utils/test_validation_utils.py +477 -0
@@ -0,0 +1,626 @@
|
|
1
|
+
"""
|
2
|
+
Rate Limiter Tests Module
|
3
|
+
|
4
|
+
This module provides comprehensive tests for the RateLimiter class,
|
5
|
+
covering all functionality including rate limiting, window management,
|
6
|
+
cleanup, and edge cases.
|
7
|
+
|
8
|
+
Test Coverage:
|
9
|
+
- Basic rate limiting functionality
|
10
|
+
- Window reset behavior
|
11
|
+
- Multiple identifiers
|
12
|
+
- Exempt paths and roles
|
13
|
+
- Cleanup functionality
|
14
|
+
- Thread safety
|
15
|
+
- Edge cases and error conditions
|
16
|
+
|
17
|
+
Author: MCP Security Team
|
18
|
+
Version: 1.0.0
|
19
|
+
License: MIT
|
20
|
+
"""
|
21
|
+
|
22
|
+
import time
|
23
|
+
from datetime import datetime, timedelta, timezone
|
24
|
+
from unittest.mock import Mock, patch
|
25
|
+
|
26
|
+
import pytest
|
27
|
+
|
28
|
+
from mcp_security_framework.core.rate_limiter import RateLimitEntry, RateLimiter
|
29
|
+
from mcp_security_framework.schemas.config import RateLimitConfig
|
30
|
+
from mcp_security_framework.schemas.models import RateLimitStatus
|
31
|
+
|
32
|
+
|
33
|
+
class TestRateLimitEntry:
|
34
|
+
"""Test suite for RateLimitEntry class."""
|
35
|
+
|
36
|
+
def setup_method(self):
|
37
|
+
"""Set up test fixtures before each test method."""
|
38
|
+
self.identifier = "test_identifier"
|
39
|
+
self.limit = 10
|
40
|
+
self.window_size = 60
|
41
|
+
self.entry = RateLimitEntry(self.identifier, self.limit, self.window_size)
|
42
|
+
|
43
|
+
def test_rate_limit_entry_initialization(self):
|
44
|
+
"""Test RateLimitEntry initialization."""
|
45
|
+
assert self.entry.identifier == self.identifier
|
46
|
+
assert self.entry.count == 0
|
47
|
+
assert self.entry.limit == self.limit
|
48
|
+
assert self.entry.window_size == self.window_size
|
49
|
+
assert isinstance(self.entry.window_start, datetime)
|
50
|
+
|
51
|
+
def test_rate_limit_entry_increment(self):
|
52
|
+
"""Test request count increment."""
|
53
|
+
initial_count = self.entry.count
|
54
|
+
new_count = self.entry.increment()
|
55
|
+
|
56
|
+
assert new_count == initial_count + 1
|
57
|
+
assert self.entry.count == new_count
|
58
|
+
|
59
|
+
def test_rate_limit_entry_is_expired_false(self):
|
60
|
+
"""Test is_expired returns False for non-expired entry."""
|
61
|
+
assert not self.entry.is_expired()
|
62
|
+
|
63
|
+
def test_rate_limit_entry_is_expired_true(self):
|
64
|
+
"""Test is_expired returns True for expired entry."""
|
65
|
+
# Manually set window start to past
|
66
|
+
self.entry.window_start = datetime.now(timezone.utc) - timedelta(
|
67
|
+
seconds=self.window_size + 1
|
68
|
+
)
|
69
|
+
assert self.entry.is_expired()
|
70
|
+
|
71
|
+
def test_rate_limit_entry_reset_window(self):
|
72
|
+
"""Test window reset functionality."""
|
73
|
+
# Increment count first
|
74
|
+
self.entry.increment()
|
75
|
+
self.entry.increment()
|
76
|
+
assert self.entry.count == 2
|
77
|
+
|
78
|
+
# Store original window start
|
79
|
+
original_start = self.entry.window_start
|
80
|
+
|
81
|
+
# Reset window
|
82
|
+
self.entry.reset_window()
|
83
|
+
|
84
|
+
assert self.entry.count == 0
|
85
|
+
assert self.entry.window_start > original_start
|
86
|
+
|
87
|
+
def test_rate_limit_entry_get_status(self):
|
88
|
+
"""Test get_status method."""
|
89
|
+
# Increment count
|
90
|
+
self.entry.increment()
|
91
|
+
self.entry.increment()
|
92
|
+
|
93
|
+
status = self.entry.get_status()
|
94
|
+
|
95
|
+
assert isinstance(status, RateLimitStatus)
|
96
|
+
assert status.identifier == self.identifier
|
97
|
+
assert status.current_count == 2
|
98
|
+
assert status.limit == self.limit
|
99
|
+
assert status.is_exceeded is False
|
100
|
+
assert status.remaining_requests == self.limit - 2
|
101
|
+
assert status.window_size_seconds == self.window_size
|
102
|
+
|
103
|
+
def test_rate_limit_entry_get_status_exceeded(self):
|
104
|
+
"""Test get_status when limit is exceeded."""
|
105
|
+
# Increment beyond limit
|
106
|
+
for _ in range(self.limit + 1):
|
107
|
+
self.entry.increment()
|
108
|
+
|
109
|
+
status = self.entry.get_status()
|
110
|
+
|
111
|
+
assert status.is_exceeded is True
|
112
|
+
assert status.remaining_requests == 0
|
113
|
+
assert status.current_count > self.limit
|
114
|
+
|
115
|
+
|
116
|
+
class TestRateLimiter:
|
117
|
+
"""Test suite for RateLimiter class."""
|
118
|
+
|
119
|
+
def setup_method(self):
|
120
|
+
"""Set up test fixtures before each test method."""
|
121
|
+
self.config = RateLimitConfig(
|
122
|
+
enabled=True,
|
123
|
+
default_requests_per_minute=10,
|
124
|
+
window_size_seconds=60,
|
125
|
+
cleanup_interval=300,
|
126
|
+
exempt_paths=["/health", "/metrics"],
|
127
|
+
exempt_roles=["admin", "monitor"],
|
128
|
+
)
|
129
|
+
self.rate_limiter = RateLimiter(self.config)
|
130
|
+
|
131
|
+
def teardown_method(self):
|
132
|
+
"""Clean up after each test method."""
|
133
|
+
if hasattr(self, "rate_limiter"):
|
134
|
+
self.rate_limiter.stop_cleanup()
|
135
|
+
|
136
|
+
def test_rate_limiter_initialization(self):
|
137
|
+
"""Test RateLimiter initialization."""
|
138
|
+
assert self.rate_limiter.config == self.config
|
139
|
+
assert self.rate_limiter.config.enabled is True
|
140
|
+
assert self.rate_limiter.config.default_requests_per_minute == 10
|
141
|
+
assert self.rate_limiter.config.window_size_seconds == 60
|
142
|
+
|
143
|
+
def test_rate_limiter_initialization_disabled(self):
|
144
|
+
"""Test RateLimiter initialization when disabled."""
|
145
|
+
config = RateLimitConfig(enabled=False)
|
146
|
+
rate_limiter = RateLimiter(config)
|
147
|
+
|
148
|
+
assert rate_limiter.config.enabled is False
|
149
|
+
rate_limiter.stop_cleanup()
|
150
|
+
|
151
|
+
def test_check_rate_limit_basic(self):
|
152
|
+
"""Test basic rate limit checking."""
|
153
|
+
identifier = "test_ip"
|
154
|
+
|
155
|
+
# First 20 requests should pass (10 * burst_limit = 20)
|
156
|
+
for i in range(20):
|
157
|
+
assert self.rate_limiter.check_rate_limit(identifier) is True
|
158
|
+
self.rate_limiter.increment_request_count(identifier)
|
159
|
+
|
160
|
+
# 21st request should fail
|
161
|
+
assert self.rate_limiter.check_rate_limit(identifier) is False
|
162
|
+
|
163
|
+
def test_check_rate_limit_custom_limit(self):
|
164
|
+
"""Test rate limit checking with custom limit."""
|
165
|
+
identifier = "test_ip"
|
166
|
+
custom_limit = 5
|
167
|
+
|
168
|
+
# First 5 requests should pass (custom limit overrides burst_limit)
|
169
|
+
for i in range(5):
|
170
|
+
assert self.rate_limiter.check_rate_limit(identifier, custom_limit) is True
|
171
|
+
self.rate_limiter.increment_request_count(identifier, custom_limit)
|
172
|
+
|
173
|
+
# 6th request should fail
|
174
|
+
assert self.rate_limiter.check_rate_limit(identifier, custom_limit) is False
|
175
|
+
|
176
|
+
def test_check_rate_limit_disabled(self):
|
177
|
+
"""Test rate limit checking when disabled."""
|
178
|
+
config = RateLimitConfig(enabled=False)
|
179
|
+
rate_limiter = RateLimiter(config)
|
180
|
+
|
181
|
+
identifier = "test_ip"
|
182
|
+
|
183
|
+
# All requests should pass when disabled
|
184
|
+
for i in range(20):
|
185
|
+
assert rate_limiter.check_rate_limit(identifier) is True
|
186
|
+
|
187
|
+
rate_limiter.stop_cleanup()
|
188
|
+
|
189
|
+
def test_check_rate_limit_empty_identifier(self):
|
190
|
+
"""Test rate limit checking with empty identifier."""
|
191
|
+
with pytest.raises(ValueError, match="Identifier cannot be empty"):
|
192
|
+
self.rate_limiter.check_rate_limit("")
|
193
|
+
|
194
|
+
def test_check_rate_limit_invalid_limit(self):
|
195
|
+
"""Test rate limit checking with invalid limit."""
|
196
|
+
with pytest.raises(ValueError, match="Limit must be a positive integer"):
|
197
|
+
self.rate_limiter.check_rate_limit("test_ip", 0)
|
198
|
+
|
199
|
+
with pytest.raises(ValueError, match="Limit must be a positive integer"):
|
200
|
+
self.rate_limiter.check_rate_limit("test_ip", -1)
|
201
|
+
|
202
|
+
def test_increment_request_count_basic(self):
|
203
|
+
"""Test basic request count increment."""
|
204
|
+
identifier = "test_ip"
|
205
|
+
|
206
|
+
# Increment count
|
207
|
+
count = self.rate_limiter.increment_request_count(identifier)
|
208
|
+
assert count == 1
|
209
|
+
|
210
|
+
count = self.rate_limiter.increment_request_count(identifier)
|
211
|
+
assert count == 2
|
212
|
+
|
213
|
+
def test_increment_request_count_custom_limit(self):
|
214
|
+
"""Test request count increment with custom limit."""
|
215
|
+
identifier = "test_ip"
|
216
|
+
custom_limit = 5
|
217
|
+
|
218
|
+
count = self.rate_limiter.increment_request_count(identifier, custom_limit)
|
219
|
+
assert count == 1
|
220
|
+
|
221
|
+
def test_increment_request_count_disabled(self):
|
222
|
+
"""Test request count increment when disabled."""
|
223
|
+
config = RateLimitConfig(enabled=False)
|
224
|
+
rate_limiter = RateLimiter(config)
|
225
|
+
|
226
|
+
identifier = "test_ip"
|
227
|
+
count = rate_limiter.increment_request_count(identifier)
|
228
|
+
assert count == 0
|
229
|
+
|
230
|
+
rate_limiter.stop_cleanup()
|
231
|
+
|
232
|
+
def test_increment_request_count_empty_identifier(self):
|
233
|
+
"""Test request count increment with empty identifier."""
|
234
|
+
with pytest.raises(ValueError, match="Identifier cannot be empty"):
|
235
|
+
self.rate_limiter.increment_request_count("")
|
236
|
+
|
237
|
+
def test_increment_request_count_invalid_limit(self):
|
238
|
+
"""Test request count increment with invalid limit."""
|
239
|
+
with pytest.raises(ValueError, match="Limit must be a positive integer"):
|
240
|
+
self.rate_limiter.increment_request_count("test_ip", 0)
|
241
|
+
|
242
|
+
def test_reset_rate_limit(self):
|
243
|
+
"""Test rate limit reset functionality."""
|
244
|
+
identifier = "test_ip"
|
245
|
+
|
246
|
+
# Increment count
|
247
|
+
self.rate_limiter.increment_request_count(identifier)
|
248
|
+
self.rate_limiter.increment_request_count(identifier)
|
249
|
+
|
250
|
+
# Check that limit is approaching
|
251
|
+
assert self.rate_limiter.check_rate_limit(identifier) is True
|
252
|
+
|
253
|
+
# Reset rate limit
|
254
|
+
self.rate_limiter.reset_rate_limit(identifier)
|
255
|
+
|
256
|
+
# Should be able to make requests again
|
257
|
+
for i in range(20): # 10 * burst_limit = 20
|
258
|
+
assert self.rate_limiter.check_rate_limit(identifier) is True
|
259
|
+
self.rate_limiter.increment_request_count(identifier)
|
260
|
+
|
261
|
+
def test_reset_rate_limit_empty_identifier(self):
|
262
|
+
"""Test rate limit reset with empty identifier."""
|
263
|
+
with pytest.raises(ValueError, match="Identifier cannot be empty"):
|
264
|
+
self.rate_limiter.reset_rate_limit("")
|
265
|
+
|
266
|
+
def test_get_rate_limit_status_basic(self):
|
267
|
+
"""Test basic rate limit status retrieval."""
|
268
|
+
identifier = "test_ip"
|
269
|
+
|
270
|
+
# Get initial status
|
271
|
+
status = self.rate_limiter.get_rate_limit_status(identifier)
|
272
|
+
|
273
|
+
assert isinstance(status, RateLimitStatus)
|
274
|
+
assert status.identifier == identifier
|
275
|
+
assert status.current_count == 0
|
276
|
+
assert (
|
277
|
+
status.limit
|
278
|
+
== self.config.default_requests_per_minute * self.config.burst_limit
|
279
|
+
)
|
280
|
+
assert status.is_exceeded is False
|
281
|
+
assert (
|
282
|
+
status.remaining_requests
|
283
|
+
== self.config.default_requests_per_minute * self.config.burst_limit
|
284
|
+
)
|
285
|
+
|
286
|
+
def test_get_rate_limit_status_with_increments(self):
|
287
|
+
"""Test rate limit status after increments."""
|
288
|
+
identifier = "test_ip"
|
289
|
+
|
290
|
+
# Increment count
|
291
|
+
self.rate_limiter.increment_request_count(identifier)
|
292
|
+
self.rate_limiter.increment_request_count(identifier)
|
293
|
+
|
294
|
+
status = self.rate_limiter.get_rate_limit_status(identifier)
|
295
|
+
|
296
|
+
assert status.current_count == 2
|
297
|
+
assert status.is_exceeded is False
|
298
|
+
assert (
|
299
|
+
status.remaining_requests
|
300
|
+
== (self.config.default_requests_per_minute * self.config.burst_limit) - 2
|
301
|
+
)
|
302
|
+
|
303
|
+
def test_get_rate_limit_status_exceeded(self):
|
304
|
+
"""Test rate limit status when exceeded."""
|
305
|
+
identifier = "test_ip"
|
306
|
+
|
307
|
+
# Increment beyond limit
|
308
|
+
for _ in range(
|
309
|
+
(self.config.default_requests_per_minute * self.config.burst_limit) + 1
|
310
|
+
):
|
311
|
+
self.rate_limiter.increment_request_count(identifier)
|
312
|
+
|
313
|
+
status = self.rate_limiter.get_rate_limit_status(identifier)
|
314
|
+
|
315
|
+
assert status.is_exceeded is True
|
316
|
+
assert status.remaining_requests == 0
|
317
|
+
|
318
|
+
def test_get_rate_limit_status_disabled(self):
|
319
|
+
"""Test rate limit status when disabled."""
|
320
|
+
config = RateLimitConfig(enabled=False)
|
321
|
+
rate_limiter = RateLimiter(config)
|
322
|
+
|
323
|
+
identifier = "test_ip"
|
324
|
+
status = rate_limiter.get_rate_limit_status(identifier)
|
325
|
+
|
326
|
+
assert status.current_count == 0
|
327
|
+
assert status.is_exceeded is False
|
328
|
+
assert status.remaining_requests == config.default_requests_per_minute
|
329
|
+
|
330
|
+
rate_limiter.stop_cleanup()
|
331
|
+
|
332
|
+
def test_get_rate_limit_status_empty_identifier(self):
|
333
|
+
"""Test rate limit status with empty identifier."""
|
334
|
+
with pytest.raises(ValueError, match="Identifier cannot be empty"):
|
335
|
+
self.rate_limiter.get_rate_limit_status("")
|
336
|
+
|
337
|
+
def test_get_rate_limit_status_invalid_limit(self):
|
338
|
+
"""Test rate limit status with invalid limit."""
|
339
|
+
with pytest.raises(ValueError, match="Limit must be a positive integer"):
|
340
|
+
self.rate_limiter.get_rate_limit_status("test_ip", 0)
|
341
|
+
|
342
|
+
def test_is_exempt_path(self):
|
343
|
+
"""Test exemption based on path."""
|
344
|
+
identifier = "test_ip"
|
345
|
+
exempt_path = "/health"
|
346
|
+
non_exempt_path = "/api/data"
|
347
|
+
|
348
|
+
assert self.rate_limiter.is_exempt(identifier, path=exempt_path) is True
|
349
|
+
assert self.rate_limiter.is_exempt(identifier, path=non_exempt_path) is False
|
350
|
+
|
351
|
+
def test_is_exempt_roles(self):
|
352
|
+
"""Test exemption based on roles."""
|
353
|
+
identifier = "test_ip"
|
354
|
+
exempt_roles = {"admin"}
|
355
|
+
non_exempt_roles = {"user"}
|
356
|
+
|
357
|
+
assert self.rate_limiter.is_exempt(identifier, roles=exempt_roles) is True
|
358
|
+
assert self.rate_limiter.is_exempt(identifier, roles=non_exempt_roles) is False
|
359
|
+
|
360
|
+
def test_is_exempt_empty_identifier(self):
|
361
|
+
"""Test exemption with empty identifier."""
|
362
|
+
assert self.rate_limiter.is_exempt("", path="/health") is False
|
363
|
+
assert self.rate_limiter.is_exempt("", roles={"admin"}) is False
|
364
|
+
|
365
|
+
def test_cleanup_expired_entries(self):
|
366
|
+
"""Test cleanup of expired entries."""
|
367
|
+
identifier = "test_ip"
|
368
|
+
|
369
|
+
# Create an entry
|
370
|
+
self.rate_limiter.increment_request_count(identifier)
|
371
|
+
|
372
|
+
# Manually expire the entry
|
373
|
+
entry = self.rate_limiter._entries[identifier]
|
374
|
+
entry.window_start = datetime.now(timezone.utc) - timedelta(
|
375
|
+
seconds=self.config.window_size_seconds + 1
|
376
|
+
)
|
377
|
+
|
378
|
+
# Cleanup should remove expired entry
|
379
|
+
cleaned_count = self.rate_limiter.cleanup_expired_entries()
|
380
|
+
|
381
|
+
assert cleaned_count == 1
|
382
|
+
assert identifier not in self.rate_limiter._entries
|
383
|
+
|
384
|
+
def test_cleanup_expired_entries_disabled(self):
|
385
|
+
"""Test cleanup when rate limiting is disabled."""
|
386
|
+
config = RateLimitConfig(enabled=False)
|
387
|
+
rate_limiter = RateLimiter(config)
|
388
|
+
|
389
|
+
cleaned_count = rate_limiter.cleanup_expired_entries()
|
390
|
+
assert cleaned_count == 0
|
391
|
+
|
392
|
+
rate_limiter.stop_cleanup()
|
393
|
+
|
394
|
+
def test_get_statistics(self):
|
395
|
+
"""Test statistics retrieval."""
|
396
|
+
identifier = "test_ip"
|
397
|
+
|
398
|
+
# Create some entries
|
399
|
+
self.rate_limiter.increment_request_count(identifier)
|
400
|
+
self.rate_limiter.increment_request_count("another_ip")
|
401
|
+
|
402
|
+
stats = self.rate_limiter.get_statistics()
|
403
|
+
|
404
|
+
assert stats["enabled"] is True
|
405
|
+
assert stats["active_entries"] == 2
|
406
|
+
assert (
|
407
|
+
stats["default_requests_per_minute"]
|
408
|
+
== self.config.default_requests_per_minute
|
409
|
+
)
|
410
|
+
assert stats["window_size_seconds"] == self.config.window_size_seconds
|
411
|
+
assert stats["storage_backend"] == self.config.storage_backend
|
412
|
+
assert stats["cleanup_interval"] == self.config.cleanup_interval
|
413
|
+
|
414
|
+
def test_window_reset_behavior(self):
|
415
|
+
"""Test window reset behavior."""
|
416
|
+
identifier = "test_ip"
|
417
|
+
|
418
|
+
# Use a short window for testing
|
419
|
+
config = RateLimitConfig(
|
420
|
+
enabled=True,
|
421
|
+
default_requests_per_minute=5,
|
422
|
+
burst_limit=1, # Set burst_limit to 1 for this test
|
423
|
+
window_size_seconds=1, # 1 second window
|
424
|
+
)
|
425
|
+
rate_limiter = RateLimiter(config)
|
426
|
+
|
427
|
+
# Make requests up to limit
|
428
|
+
for i in range(5):
|
429
|
+
assert rate_limiter.check_rate_limit(identifier) is True
|
430
|
+
rate_limiter.increment_request_count(identifier)
|
431
|
+
|
432
|
+
# Next request should be blocked
|
433
|
+
assert rate_limiter.check_rate_limit(identifier) is False
|
434
|
+
|
435
|
+
# Wait for window to expire
|
436
|
+
time.sleep(1.1)
|
437
|
+
|
438
|
+
# Should be able to make requests again
|
439
|
+
assert rate_limiter.check_rate_limit(identifier) is True
|
440
|
+
|
441
|
+
rate_limiter.stop_cleanup()
|
442
|
+
|
443
|
+
def test_multiple_identifiers(self):
|
444
|
+
"""Test rate limiting with multiple identifiers."""
|
445
|
+
identifier1 = "ip_1"
|
446
|
+
identifier2 = "ip_2"
|
447
|
+
|
448
|
+
# Both should be able to make requests independently
|
449
|
+
for i in range(5):
|
450
|
+
assert self.rate_limiter.check_rate_limit(identifier1) is True
|
451
|
+
assert self.rate_limiter.check_rate_limit(identifier2) is True
|
452
|
+
self.rate_limiter.increment_request_count(identifier1)
|
453
|
+
self.rate_limiter.increment_request_count(identifier2)
|
454
|
+
|
455
|
+
# Both should still have remaining requests
|
456
|
+
status1 = self.rate_limiter.get_rate_limit_status(identifier1)
|
457
|
+
status2 = self.rate_limiter.get_rate_limit_status(identifier2)
|
458
|
+
|
459
|
+
assert status1.remaining_requests == 15 # 20 - 5 = 15
|
460
|
+
assert status2.remaining_requests == 15 # 20 - 5 = 15
|
461
|
+
|
462
|
+
def test_thread_safety(self):
|
463
|
+
"""Test thread safety of rate limiter."""
|
464
|
+
import threading
|
465
|
+
|
466
|
+
identifier = "test_ip"
|
467
|
+
results = []
|
468
|
+
errors = []
|
469
|
+
|
470
|
+
def make_requests():
|
471
|
+
try:
|
472
|
+
for i in range(5):
|
473
|
+
result = self.rate_limiter.check_rate_limit(identifier)
|
474
|
+
if result:
|
475
|
+
self.rate_limiter.increment_request_count(identifier)
|
476
|
+
results.append(result)
|
477
|
+
except Exception as e:
|
478
|
+
errors.append(e)
|
479
|
+
|
480
|
+
# Create multiple threads
|
481
|
+
threads = []
|
482
|
+
for _ in range(3):
|
483
|
+
thread = threading.Thread(target=make_requests)
|
484
|
+
threads.append(thread)
|
485
|
+
thread.start()
|
486
|
+
|
487
|
+
# Wait for all threads to complete
|
488
|
+
for thread in threads:
|
489
|
+
thread.join()
|
490
|
+
|
491
|
+
# Should not have any errors
|
492
|
+
assert len(errors) == 0
|
493
|
+
|
494
|
+
# Should have proper rate limiting
|
495
|
+
total_requests = sum(1 for r in results if r)
|
496
|
+
assert (
|
497
|
+
total_requests
|
498
|
+
<= self.config.default_requests_per_minute * self.config.burst_limit
|
499
|
+
)
|
500
|
+
|
501
|
+
def test_rate_limit_status_properties(self):
|
502
|
+
"""Test RateLimitStatus properties."""
|
503
|
+
identifier = "test_ip"
|
504
|
+
|
505
|
+
# Get status
|
506
|
+
status = self.rate_limiter.get_rate_limit_status(identifier)
|
507
|
+
|
508
|
+
# Test seconds_until_reset property
|
509
|
+
seconds_until_reset = status.seconds_until_reset
|
510
|
+
assert isinstance(seconds_until_reset, int)
|
511
|
+
assert seconds_until_reset >= 0
|
512
|
+
|
513
|
+
# Test utilization_percentage property
|
514
|
+
utilization = status.utilization_percentage
|
515
|
+
assert isinstance(utilization, float)
|
516
|
+
assert utilization == 0.0 # No requests made yet
|
517
|
+
|
518
|
+
# Make some requests and check utilization
|
519
|
+
self.rate_limiter.increment_request_count(identifier)
|
520
|
+
self.rate_limiter.increment_request_count(identifier)
|
521
|
+
|
522
|
+
status = self.rate_limiter.get_rate_limit_status(identifier)
|
523
|
+
utilization = status.utilization_percentage
|
524
|
+
assert utilization == 10.0 # 2 out of 20 requests = 10%
|
525
|
+
|
526
|
+
@pytest.mark.slow
|
527
|
+
def test_cleanup_thread_functionality(self):
|
528
|
+
"""Test background cleanup thread functionality."""
|
529
|
+
identifier = "test_ip"
|
530
|
+
|
531
|
+
# Create an entry
|
532
|
+
self.rate_limiter.increment_request_count(identifier)
|
533
|
+
|
534
|
+
# Manually expire the entry
|
535
|
+
entry = self.rate_limiter._entries[identifier]
|
536
|
+
entry.window_start = datetime.now(timezone.utc) - timedelta(
|
537
|
+
seconds=self.config.window_size_seconds + 1
|
538
|
+
)
|
539
|
+
|
540
|
+
# Manually trigger cleanup
|
541
|
+
cleaned_count = self.rate_limiter.cleanup_expired_entries()
|
542
|
+
|
543
|
+
# Entry should be cleaned up
|
544
|
+
assert cleaned_count == 1
|
545
|
+
assert identifier not in self.rate_limiter._entries
|
546
|
+
|
547
|
+
def test_rate_limiter_destruction(self):
|
548
|
+
"""Test rate limiter destruction cleanup."""
|
549
|
+
config = RateLimitConfig(enabled=True, cleanup_interval=1)
|
550
|
+
rate_limiter = RateLimiter(config)
|
551
|
+
|
552
|
+
# Verify cleanup thread is running
|
553
|
+
assert rate_limiter._cleanup_thread is not None
|
554
|
+
assert rate_limiter._cleanup_thread.is_alive()
|
555
|
+
|
556
|
+
# Destroy rate limiter
|
557
|
+
rate_limiter.stop_cleanup()
|
558
|
+
|
559
|
+
# Thread should be stopped
|
560
|
+
assert rate_limiter._stop_cleanup is True
|
561
|
+
|
562
|
+
def test_rate_limiter_with_burst_limit(self):
|
563
|
+
"""Test rate limiter with burst limit configuration."""
|
564
|
+
config = RateLimitConfig(
|
565
|
+
enabled=True,
|
566
|
+
default_requests_per_minute=10,
|
567
|
+
burst_limit=3,
|
568
|
+
window_size_seconds=60,
|
569
|
+
)
|
570
|
+
rate_limiter = RateLimiter(config)
|
571
|
+
|
572
|
+
identifier = "test_ip"
|
573
|
+
|
574
|
+
# Should be able to make burst_limit * default_requests_per_minute requests
|
575
|
+
burst_limit_total = config.burst_limit * config.default_requests_per_minute
|
576
|
+
|
577
|
+
# Check that we can make exactly burst_limit_total requests
|
578
|
+
for i in range(burst_limit_total):
|
579
|
+
assert rate_limiter.check_rate_limit(identifier) is True
|
580
|
+
rate_limiter.increment_request_count(identifier)
|
581
|
+
|
582
|
+
# Next request should be blocked
|
583
|
+
assert rate_limiter.check_rate_limit(identifier) is False
|
584
|
+
|
585
|
+
rate_limiter.stop_cleanup()
|
586
|
+
|
587
|
+
def test_rate_limiter_edge_cases(self):
|
588
|
+
"""Test rate limiter edge cases."""
|
589
|
+
# Test with very high limits
|
590
|
+
config = RateLimitConfig(
|
591
|
+
enabled=True, default_requests_per_minute=10000, window_size_seconds=3600
|
592
|
+
)
|
593
|
+
rate_limiter = RateLimiter(config)
|
594
|
+
|
595
|
+
identifier = "test_ip"
|
596
|
+
|
597
|
+
# Should handle high limits correctly
|
598
|
+
for i in range(100):
|
599
|
+
assert rate_limiter.check_rate_limit(identifier) is True
|
600
|
+
rate_limiter.increment_request_count(identifier)
|
601
|
+
|
602
|
+
status = rate_limiter.get_rate_limit_status(identifier)
|
603
|
+
assert status.current_count == 100
|
604
|
+
assert status.remaining_requests == 19900 # 20000 - 100 = 19900
|
605
|
+
|
606
|
+
rate_limiter.stop_cleanup()
|
607
|
+
|
608
|
+
# Test with very low limits
|
609
|
+
config = RateLimitConfig(
|
610
|
+
enabled=True,
|
611
|
+
default_requests_per_minute=1,
|
612
|
+
burst_limit=1, # Set burst_limit to 1 for this test
|
613
|
+
window_size_seconds=1,
|
614
|
+
)
|
615
|
+
rate_limiter = RateLimiter(config)
|
616
|
+
|
617
|
+
identifier = "test_ip"
|
618
|
+
|
619
|
+
# First request should pass
|
620
|
+
assert rate_limiter.check_rate_limit(identifier) is True
|
621
|
+
rate_limiter.increment_request_count(identifier)
|
622
|
+
|
623
|
+
# Second request should fail
|
624
|
+
assert rate_limiter.check_rate_limit(identifier) is False
|
625
|
+
|
626
|
+
rate_limiter.stop_cleanup()
|