-
Notifications
You must be signed in to change notification settings - Fork 9.9k
Expand file tree
/
Copy pathtest_login_rate_limiting.py
More file actions
279 lines (200 loc) · 10.6 KB
/
Copy pathtest_login_rate_limiting.py
File metadata and controls
279 lines (200 loc) · 10.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
"""Tests for login endpoint rate limiting functionality."""
from __future__ import annotations
from unittest.mock import Mock
import pytest
from starlette.datastructures import Headers
@pytest.fixture
def enable_rate_limiting(monkeypatch):
"""Enable rate limiting for tests that need to verify rate limit behavior."""
monkeypatch.setenv("LANGFLOW_RATE_LIMIT_ENABLED", "true")
@pytest.fixture
def limiter_snapshot():
"""Fixture to snapshot and restore the global limiter singleton."""
import langflow.services.rate_limit.service as rate_limit_module
original_limiter = rate_limit_module._limiter
# Force recreation of limiter for each test to ensure clean state
rate_limit_module._limiter = None
yield
rate_limit_module._limiter = original_limiter
class TestRateLimitService:
"""Test suite for rate limit service configuration."""
def test_rate_limiter_is_configured(self, enable_rate_limiting): # noqa: ARG002
"""Test that rate limiter singleton is properly configured."""
from langflow.services.rate_limit import get_rate_limiter
limiter = get_rate_limiter()
assert limiter is not None
assert limiter.enabled is True
assert limiter._storage_uri == "memory://" # Default storage
assert limiter._swallow_errors is False # Raise exceptions on rate limit
def test_rate_limiter_is_singleton(self, enable_rate_limiting): # noqa: ARG002
"""Test that get_rate_limiter returns the same instance."""
from langflow.services.rate_limit import get_rate_limiter
limiter1 = get_rate_limiter()
limiter2 = get_rate_limiter()
assert limiter1 is limiter2
def test_rate_limit_string_default(self, enable_rate_limiting): # noqa: ARG002
"""Test that default rate limit string is correct."""
from langflow.services.rate_limit.service import get_rate_limit_string
rate_limit = get_rate_limit_string()
assert rate_limit == "5/minute"
def test_rate_limiter_uses_remote_address_by_default(self, enable_rate_limiting): # noqa: ARG002
"""Test that rate limiter uses get_remote_address when trust_proxy is false."""
from langflow.services.rate_limit import get_rate_limiter
from slowapi.util import get_remote_address
limiter = get_rate_limiter()
# Default should use get_remote_address (not trust proxy)
assert limiter._key_func == get_remote_address
def test_rate_limiter_uses_client_ip_when_trust_proxy_enabled(
self,
enable_rate_limiting, # noqa: ARG002
limiter_snapshot, # noqa: ARG002
monkeypatch,
):
"""Test that rate limiter uses get_client_ip when trust_proxy is true."""
# Mock settings to enable trust_proxy
from unittest.mock import MagicMock
from langflow.services.rate_limit.service import get_client_ip
mock_settings = MagicMock()
mock_settings.rate_limit_trust_proxy = True
mock_settings.rate_limit_storage_uri = "memory://"
mock_settings_service = MagicMock()
mock_settings_service.settings = mock_settings
monkeypatch.setattr("langflow.services.rate_limit.service.get_settings_service", lambda: mock_settings_service)
from langflow.services.rate_limit import get_rate_limiter
limiter = get_rate_limiter()
# Should use get_client_ip when trust_proxy is enabled
assert limiter._key_func == get_client_ip
class TestIPExtraction:
"""Test suite for IP address extraction logic."""
def test_get_client_ip_from_x_forwarded_for_single(self):
"""Test IP extraction from X-Forwarded-For with single IP."""
from langflow.services.rate_limit.service import get_client_ip
request = Mock()
request.headers = Headers({"X-Forwarded-For": "203.0.113.1"})
request.client = Mock(host="127.0.0.1")
ip = get_client_ip(request)
assert ip == "203.0.113.1"
def test_get_client_ip_from_x_forwarded_for_chain_uses_rightmost(self):
"""Test IP extraction from X-Forwarded-For uses rightmost IP (trusted proxy)."""
from langflow.services.rate_limit.service import get_client_ip
request = Mock()
request.headers = Headers({"X-Forwarded-For": "203.0.113.1, 198.51.100.1, 192.0.2.1"})
request.client = Mock(host="127.0.0.1")
ip = get_client_ip(request)
# Should use rightmost IP (the trusted proxy before us)
assert ip == "192.0.2.1"
def test_get_client_ip_from_x_forwarded_for_with_spaces(self):
"""Test IP extraction handles spaces in X-Forwarded-For."""
from langflow.services.rate_limit.service import get_client_ip
request = Mock()
request.headers = Headers({"X-Forwarded-For": " 203.0.113.1 , 198.51.100.1 "})
request.client = Mock(host="127.0.0.1")
ip = get_client_ip(request)
# Should use rightmost IP, stripped of whitespace
assert ip == "198.51.100.1"
def test_get_client_ip_joins_repeated_x_forwarded_for_lines(self):
"""Repeated X-Forwarded-For lines must be joined before taking the rightmost entry.
Some proxies append their own header line instead of extending the client's. Reading only
the first line would key the rate limiter on an attacker-chosen value, letting a caller pin
or rotate their own bucket.
"""
from langflow.services.rate_limit.service import get_client_ip
request = Mock()
request.headers = Headers(
raw=[
(b"x-forwarded-for", b"203.0.113.1"), # client-supplied line
(b"x-forwarded-for", b"192.0.2.1"), # line appended by the trusted proxy
]
)
request.client = Mock(host="10.0.0.5")
ip = get_client_ip(request)
assert ip == "192.0.2.1"
def test_get_client_ip_ignores_blank_x_forwarded_for(self):
"""A blank header must fall back to the peer rather than returning an empty key."""
from langflow.services.rate_limit.service import get_client_ip
request = Mock()
request.headers = Headers({"X-Forwarded-For": " , "})
request.client = Mock(host="192.168.1.100")
ip = get_client_ip(request)
assert ip == "192.168.1.100"
def test_get_client_ip_from_direct_connection(self):
"""Test IP extraction from direct client connection (no proxy)."""
from langflow.services.rate_limit.service import get_client_ip
request = Mock()
request.headers = Headers({})
request.client = Mock(host="192.168.1.100")
ip = get_client_ip(request)
assert ip == "192.168.1.100"
def test_get_client_ip_fallback_to_unknown(self):
"""Test IP extraction returns 'unknown' when no client info available."""
from langflow.services.rate_limit.service import get_client_ip
request = Mock()
request.headers = Headers({})
request.client = None
ip = get_client_ip(request)
assert ip == "unknown"
def test_get_client_ip_prefers_x_forwarded_for(self):
"""Test that X-Forwarded-For takes precedence over direct client IP."""
from langflow.services.rate_limit.service import get_client_ip
request = Mock()
request.headers = Headers({"X-Forwarded-For": "203.0.113.1"})
request.client = Mock(host="127.0.0.1")
ip = get_client_ip(request)
# Should use X-Forwarded-For, not client.host
assert ip == "203.0.113.1"
assert ip != "127.0.0.1"
class TestRateLimitIntegration:
"""Integration tests verifying rate limiting is applied to login endpoint."""
def test_rate_limit_enforcement_returns_429(self, enable_rate_limiting, limiter_snapshot, active_user): # noqa: ARG002
"""Test that exceeding rate limit returns 429 status code.
Uses TestClient (synchronous) instead of AsyncClient to properly test SlowAPI rate limiting.
Requires limiter_snapshot fixture to ensure clean state and avoid interference from other tests.
"""
from fastapi.testclient import TestClient
from langflow.main import create_app
# Create a fresh app instance for this test
app = create_app()
sync_client = TestClient(app)
# Make 5 requests (the default limit)
status_codes = []
for i in range(5):
response = sync_client.post(
"/api/v1/login",
data={"username": active_user.username, "password": "testpassword"}, # pragma: allowlist secret
)
status_codes.append(response.status_code)
assert response.status_code == 200, f"Request {i + 1} should succeed, got {response.status_code}"
# 6th request should be rate limited
response = sync_client.post(
"/api/v1/login",
data={"username": active_user.username, "password": "testpassword"}, # pragma: allowlist secret
)
status_codes.append(response.status_code)
assert response.status_code == 429, f"Expected 429, got {response.status_code}. All codes: {status_codes}"
response_detail = response.json()["detail"].lower()
assert "too many requests" in response_detail or "rate limit" in response_detail
@pytest.mark.asyncio
async def test_login_endpoint_has_rate_limiter_applied(self, enable_rate_limiting): # noqa: ARG002
"""Test that the login endpoint has rate limiting applied via app.state.limiter."""
from langflow.api.v1.login import get_limiter_from_app, login_to_get_access_token
from langflow.main import create_app
# Create app to ensure limiter is attached to app.state
app = create_app()
# Verify limiter is attached to app.state
assert hasattr(app.state, "limiter")
assert app.state.limiter is not None
assert app.state.limiter.enabled is True
# Verify the endpoint function exists
assert login_to_get_access_token is not None
# Verify get_limiter_from_app helper exists
assert get_limiter_from_app is not None
@pytest.mark.asyncio
async def test_successful_login_within_reasonable_limit(self, enable_rate_limiting, client, active_user): # noqa: ARG002
"""Test that a single login request succeeds (well within any rate limit)."""
response = await client.post(
"/api/v1/login",
data={"username": active_user.username, "password": "testpassword"}, # pragma: allowlist secret
headers={"X-Forwarded-For": "10.0.0.1"}, # Unique IP to avoid conflicts
)
assert response.status_code == 200
assert "access_token" in response.json()