juniper-data 0.4.2__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.
- juniper_data/__init__.py +88 -0
- juniper_data/__main__.py +78 -0
- juniper_data/api/__init__.py +10 -0
- juniper_data/api/app.py +111 -0
- juniper_data/api/middleware.py +95 -0
- juniper_data/api/routes/__init__.py +9 -0
- juniper_data/api/routes/datasets.py +414 -0
- juniper_data/api/routes/generators.py +125 -0
- juniper_data/api/routes/health.py +49 -0
- juniper_data/api/security.py +238 -0
- juniper_data/api/settings.py +109 -0
- juniper_data/core/__init__.py +32 -0
- juniper_data/core/artifacts.py +63 -0
- juniper_data/core/dataset_id.py +38 -0
- juniper_data/core/models.py +135 -0
- juniper_data/core/split.py +120 -0
- juniper_data/generators/__init__.py +15 -0
- juniper_data/generators/arc_agi/__init__.py +11 -0
- juniper_data/generators/arc_agi/generator.py +229 -0
- juniper_data/generators/arc_agi/params.py +56 -0
- juniper_data/generators/checkerboard/__init__.py +15 -0
- juniper_data/generators/checkerboard/generator.py +114 -0
- juniper_data/generators/checkerboard/params.py +32 -0
- juniper_data/generators/circles/__init__.py +11 -0
- juniper_data/generators/circles/generator.py +112 -0
- juniper_data/generators/circles/params.py +31 -0
- juniper_data/generators/csv_import/__init__.py +15 -0
- juniper_data/generators/csv_import/generator.py +198 -0
- juniper_data/generators/csv_import/params.py +48 -0
- juniper_data/generators/gaussian/__init__.py +11 -0
- juniper_data/generators/gaussian/generator.py +149 -0
- juniper_data/generators/gaussian/params.py +53 -0
- juniper_data/generators/mnist/__init__.py +11 -0
- juniper_data/generators/mnist/generator.py +124 -0
- juniper_data/generators/mnist/params.py +39 -0
- juniper_data/generators/spiral/__init__.py +57 -0
- juniper_data/generators/spiral/defaults.py +39 -0
- juniper_data/generators/spiral/generator.py +206 -0
- juniper_data/generators/spiral/params.py +148 -0
- juniper_data/generators/xor/__init__.py +11 -0
- juniper_data/generators/xor/generator.py +162 -0
- juniper_data/generators/xor/params.py +30 -0
- juniper_data/storage/__init__.py +120 -0
- juniper_data/storage/base.py +279 -0
- juniper_data/storage/cached.py +211 -0
- juniper_data/storage/hf_store.py +257 -0
- juniper_data/storage/kaggle_store.py +333 -0
- juniper_data/storage/local_fs.py +232 -0
- juniper_data/storage/memory.py +136 -0
- juniper_data/storage/postgres_store.py +373 -0
- juniper_data/storage/redis_store.py +264 -0
- juniper_data/tests/__init__.py +1 -0
- juniper_data/tests/conftest.py +68 -0
- juniper_data/tests/fixtures/generate_golden_datasets.py +199 -0
- juniper_data/tests/integration/__init__.py +1 -0
- juniper_data/tests/integration/test_api.py +283 -0
- juniper_data/tests/integration/test_e2e_workflow.py +378 -0
- juniper_data/tests/integration/test_lifecycle_api.py +304 -0
- juniper_data/tests/integration/test_security_integration.py +189 -0
- juniper_data/tests/integration/test_storage_workflow.py +259 -0
- juniper_data/tests/performance/__init__.py +1 -0
- juniper_data/tests/performance/test_generator_benchmarks.py +178 -0
- juniper_data/tests/performance/test_storage_benchmarks.py +257 -0
- juniper_data/tests/unit/__init__.py +1 -0
- juniper_data/tests/unit/test_api_app.py +206 -0
- juniper_data/tests/unit/test_api_routes.py +407 -0
- juniper_data/tests/unit/test_api_settings.py +100 -0
- juniper_data/tests/unit/test_arc_agi_generator.py +525 -0
- juniper_data/tests/unit/test_artifacts.py +145 -0
- juniper_data/tests/unit/test_cached_store.py +423 -0
- juniper_data/tests/unit/test_checkerboard_generator.py +232 -0
- juniper_data/tests/unit/test_circles_generator.py +256 -0
- juniper_data/tests/unit/test_csv_import_generator.py +345 -0
- juniper_data/tests/unit/test_dataset_id.py +181 -0
- juniper_data/tests/unit/test_gaussian_generator.py +333 -0
- juniper_data/tests/unit/test_hf_store.py +416 -0
- juniper_data/tests/unit/test_init.py +93 -0
- juniper_data/tests/unit/test_kaggle_store.py +469 -0
- juniper_data/tests/unit/test_lifecycle.py +394 -0
- juniper_data/tests/unit/test_main.py +127 -0
- juniper_data/tests/unit/test_middleware.py +79 -0
- juniper_data/tests/unit/test_mnist_generator.py +370 -0
- juniper_data/tests/unit/test_postgres_store.py +490 -0
- juniper_data/tests/unit/test_redis_store.py +500 -0
- juniper_data/tests/unit/test_security.py +281 -0
- juniper_data/tests/unit/test_security_boundaries.py +517 -0
- juniper_data/tests/unit/test_spiral_generator.py +566 -0
- juniper_data/tests/unit/test_split.py +245 -0
- juniper_data/tests/unit/test_storage.py +767 -0
- juniper_data/tests/unit/test_xor_generator.py +223 -0
- juniper_data-0.4.2.dist-info/METADATA +216 -0
- juniper_data-0.4.2.dist-info/RECORD +95 -0
- juniper_data-0.4.2.dist-info/WHEEL +5 -0
- juniper_data-0.4.2.dist-info/licenses/LICENSE +9 -0
- juniper_data-0.4.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Unit tests for API security: authentication and rate limiting."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from unittest.mock import MagicMock
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from fastapi import HTTPException
|
|
8
|
+
|
|
9
|
+
from juniper_data.api.security import APIKeyAuth, RateLimiter
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestAPIKeyAuth:
|
|
13
|
+
"""Tests for APIKeyAuth class."""
|
|
14
|
+
|
|
15
|
+
def test_disabled_when_no_keys(self) -> None:
|
|
16
|
+
"""Auth should be disabled when no keys are configured."""
|
|
17
|
+
auth = APIKeyAuth(None)
|
|
18
|
+
assert not auth.enabled
|
|
19
|
+
|
|
20
|
+
auth = APIKeyAuth([])
|
|
21
|
+
assert not auth.enabled
|
|
22
|
+
|
|
23
|
+
def test_enabled_when_keys_configured(self) -> None:
|
|
24
|
+
"""Auth should be enabled when keys are configured."""
|
|
25
|
+
auth = APIKeyAuth(["key1", "key2"])
|
|
26
|
+
assert auth.enabled
|
|
27
|
+
|
|
28
|
+
def test_validate_returns_true_when_disabled(self) -> None:
|
|
29
|
+
"""Validate should return True when auth is disabled."""
|
|
30
|
+
auth = APIKeyAuth(None)
|
|
31
|
+
assert auth.validate(None)
|
|
32
|
+
assert auth.validate("any-key")
|
|
33
|
+
|
|
34
|
+
def test_validate_valid_key(self) -> None:
|
|
35
|
+
"""Validate should return True for valid key."""
|
|
36
|
+
auth = APIKeyAuth(["valid-key"])
|
|
37
|
+
assert auth.validate("valid-key")
|
|
38
|
+
|
|
39
|
+
def test_validate_invalid_key(self) -> None:
|
|
40
|
+
"""Validate should return False for invalid key."""
|
|
41
|
+
auth = APIKeyAuth(["valid-key"])
|
|
42
|
+
assert not auth.validate("invalid-key")
|
|
43
|
+
assert not auth.validate(None)
|
|
44
|
+
|
|
45
|
+
@pytest.mark.asyncio
|
|
46
|
+
async def test_call_returns_none_when_disabled(self) -> None:
|
|
47
|
+
"""Dependency should return None when auth is disabled."""
|
|
48
|
+
auth = APIKeyAuth(None)
|
|
49
|
+
request = MagicMock()
|
|
50
|
+
request.headers.get.return_value = None
|
|
51
|
+
|
|
52
|
+
result = await auth(request)
|
|
53
|
+
assert result is None
|
|
54
|
+
|
|
55
|
+
@pytest.mark.asyncio
|
|
56
|
+
async def test_call_raises_401_when_missing_key(self) -> None:
|
|
57
|
+
"""Dependency should raise 401 when key is missing."""
|
|
58
|
+
auth = APIKeyAuth(["valid-key"])
|
|
59
|
+
request = MagicMock()
|
|
60
|
+
request.headers.get.return_value = None
|
|
61
|
+
|
|
62
|
+
with pytest.raises(HTTPException) as exc_info:
|
|
63
|
+
await auth(request)
|
|
64
|
+
assert exc_info.value.status_code == 401
|
|
65
|
+
assert "Missing API key" in exc_info.value.detail
|
|
66
|
+
|
|
67
|
+
@pytest.mark.asyncio
|
|
68
|
+
async def test_call_raises_401_when_invalid_key(self) -> None:
|
|
69
|
+
"""Dependency should raise 401 when key is invalid."""
|
|
70
|
+
auth = APIKeyAuth(["valid-key"])
|
|
71
|
+
request = MagicMock()
|
|
72
|
+
request.headers.get.return_value = "invalid-key"
|
|
73
|
+
|
|
74
|
+
with pytest.raises(HTTPException) as exc_info:
|
|
75
|
+
await auth(request)
|
|
76
|
+
assert exc_info.value.status_code == 401
|
|
77
|
+
assert "Invalid API key" in exc_info.value.detail
|
|
78
|
+
|
|
79
|
+
@pytest.mark.asyncio
|
|
80
|
+
async def test_call_returns_key_when_valid(self) -> None:
|
|
81
|
+
"""Dependency should return the key when valid."""
|
|
82
|
+
auth = APIKeyAuth(["valid-key"])
|
|
83
|
+
request = MagicMock()
|
|
84
|
+
request.headers.get.return_value = "valid-key"
|
|
85
|
+
|
|
86
|
+
result = await auth(request)
|
|
87
|
+
assert result == "valid-key"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class TestRateLimiter:
|
|
91
|
+
"""Tests for RateLimiter class."""
|
|
92
|
+
|
|
93
|
+
def test_disabled_allows_all(self) -> None:
|
|
94
|
+
"""Disabled limiter should allow all requests."""
|
|
95
|
+
limiter = RateLimiter(requests_per_minute=5, enabled=False)
|
|
96
|
+
|
|
97
|
+
for _ in range(100):
|
|
98
|
+
allowed, remaining, _ = limiter.check("key")
|
|
99
|
+
assert allowed
|
|
100
|
+
|
|
101
|
+
def test_allows_within_limit(self) -> None:
|
|
102
|
+
"""Limiter should allow requests within limit."""
|
|
103
|
+
limiter = RateLimiter(requests_per_minute=5, enabled=True)
|
|
104
|
+
|
|
105
|
+
for i in range(5):
|
|
106
|
+
allowed, remaining, _ = limiter.check("key")
|
|
107
|
+
assert allowed
|
|
108
|
+
assert remaining == 5 - i - 1
|
|
109
|
+
|
|
110
|
+
def test_blocks_over_limit(self) -> None:
|
|
111
|
+
"""Limiter should block requests over limit."""
|
|
112
|
+
limiter = RateLimiter(requests_per_minute=5, enabled=True)
|
|
113
|
+
|
|
114
|
+
for _ in range(5):
|
|
115
|
+
limiter.check("key")
|
|
116
|
+
|
|
117
|
+
allowed, remaining, reset_in = limiter.check("key")
|
|
118
|
+
assert not allowed
|
|
119
|
+
assert remaining == 0
|
|
120
|
+
assert reset_in > 0
|
|
121
|
+
|
|
122
|
+
def test_different_keys_tracked_separately(self) -> None:
|
|
123
|
+
"""Different keys should have separate limits."""
|
|
124
|
+
limiter = RateLimiter(requests_per_minute=5, enabled=True)
|
|
125
|
+
|
|
126
|
+
for _ in range(5):
|
|
127
|
+
limiter.check("key1")
|
|
128
|
+
|
|
129
|
+
allowed1, _, _ = limiter.check("key1")
|
|
130
|
+
allowed2, _, _ = limiter.check("key2")
|
|
131
|
+
|
|
132
|
+
assert not allowed1
|
|
133
|
+
assert allowed2
|
|
134
|
+
|
|
135
|
+
def test_window_reset(self) -> None:
|
|
136
|
+
"""Window should reset after time expires."""
|
|
137
|
+
limiter = RateLimiter(requests_per_minute=5, window_seconds=1, enabled=True)
|
|
138
|
+
|
|
139
|
+
for _ in range(5):
|
|
140
|
+
limiter.check("key")
|
|
141
|
+
|
|
142
|
+
allowed, _, _ = limiter.check("key")
|
|
143
|
+
assert not allowed
|
|
144
|
+
|
|
145
|
+
time.sleep(1.1)
|
|
146
|
+
|
|
147
|
+
allowed, remaining, _ = limiter.check("key")
|
|
148
|
+
assert allowed
|
|
149
|
+
assert remaining == 4
|
|
150
|
+
|
|
151
|
+
def test_reset_clears_counters(self) -> None:
|
|
152
|
+
"""Reset should clear all counters."""
|
|
153
|
+
limiter = RateLimiter(requests_per_minute=5, enabled=True)
|
|
154
|
+
|
|
155
|
+
for _ in range(5):
|
|
156
|
+
limiter.check("key")
|
|
157
|
+
|
|
158
|
+
allowed, _, _ = limiter.check("key")
|
|
159
|
+
assert not allowed
|
|
160
|
+
|
|
161
|
+
limiter.reset()
|
|
162
|
+
|
|
163
|
+
allowed, _, _ = limiter.check("key")
|
|
164
|
+
assert allowed
|
|
165
|
+
|
|
166
|
+
@pytest.mark.asyncio
|
|
167
|
+
async def test_call_allows_when_within_limit(self) -> None:
|
|
168
|
+
"""Dependency should allow requests within limit."""
|
|
169
|
+
limiter = RateLimiter(requests_per_minute=5, enabled=True)
|
|
170
|
+
request = MagicMock()
|
|
171
|
+
request.client.host = "127.0.0.1"
|
|
172
|
+
request.state = MagicMock()
|
|
173
|
+
|
|
174
|
+
for _ in range(5):
|
|
175
|
+
await limiter(request, api_key=None)
|
|
176
|
+
|
|
177
|
+
@pytest.mark.asyncio
|
|
178
|
+
async def test_call_raises_429_when_over_limit(self) -> None:
|
|
179
|
+
"""Dependency should raise 429 when over limit."""
|
|
180
|
+
limiter = RateLimiter(requests_per_minute=5, enabled=True)
|
|
181
|
+
request = MagicMock()
|
|
182
|
+
request.client.host = "127.0.0.1"
|
|
183
|
+
request.state = MagicMock()
|
|
184
|
+
|
|
185
|
+
for _ in range(5):
|
|
186
|
+
await limiter(request, api_key=None)
|
|
187
|
+
|
|
188
|
+
with pytest.raises(HTTPException) as exc_info:
|
|
189
|
+
await limiter(request, api_key=None)
|
|
190
|
+
assert exc_info.value.status_code == 429
|
|
191
|
+
assert "Rate limit exceeded" in exc_info.value.detail
|
|
192
|
+
|
|
193
|
+
@pytest.mark.asyncio
|
|
194
|
+
async def test_call_uses_api_key_for_limiting(self) -> None:
|
|
195
|
+
"""Dependency should use API key for rate limiting when provided."""
|
|
196
|
+
limiter = RateLimiter(requests_per_minute=5, enabled=True)
|
|
197
|
+
request = MagicMock()
|
|
198
|
+
request.client.host = "127.0.0.1"
|
|
199
|
+
request.state = MagicMock()
|
|
200
|
+
|
|
201
|
+
for _ in range(5):
|
|
202
|
+
await limiter(request, api_key="key1")
|
|
203
|
+
|
|
204
|
+
with pytest.raises(HTTPException):
|
|
205
|
+
await limiter(request, api_key="key1")
|
|
206
|
+
|
|
207
|
+
await limiter(request, api_key="key2")
|
|
208
|
+
|
|
209
|
+
def test_rate_limiter_window_property(self) -> None:
|
|
210
|
+
"""Window property should return configured window seconds."""
|
|
211
|
+
limiter = RateLimiter(requests_per_minute=10, window_seconds=30)
|
|
212
|
+
assert limiter.window == 30
|
|
213
|
+
|
|
214
|
+
def test_get_key_with_no_client(self) -> None:
|
|
215
|
+
"""_get_key should return 'ip:unknown' when request has no client."""
|
|
216
|
+
limiter = RateLimiter()
|
|
217
|
+
request = MagicMock()
|
|
218
|
+
request.client = None
|
|
219
|
+
key = limiter._get_key(request, None)
|
|
220
|
+
assert key == "ip:unknown"
|
|
221
|
+
|
|
222
|
+
@pytest.mark.asyncio
|
|
223
|
+
async def test_call_noop_when_disabled(self) -> None:
|
|
224
|
+
"""Dependency should do nothing when disabled."""
|
|
225
|
+
limiter = RateLimiter(requests_per_minute=5, enabled=False)
|
|
226
|
+
request = MagicMock()
|
|
227
|
+
request.client.host = "127.0.0.1"
|
|
228
|
+
|
|
229
|
+
for _ in range(100):
|
|
230
|
+
await limiter(request, api_key=None)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class TestSecurityModuleFunctions:
|
|
234
|
+
"""Tests for module-level security functions."""
|
|
235
|
+
|
|
236
|
+
def test_get_api_key_auth_returns_instance(self) -> None:
|
|
237
|
+
"""get_api_key_auth should return an APIKeyAuth instance."""
|
|
238
|
+
from juniper_data.api.security import get_api_key_auth, reset_security_state
|
|
239
|
+
|
|
240
|
+
reset_security_state()
|
|
241
|
+
auth = get_api_key_auth()
|
|
242
|
+
assert isinstance(auth, APIKeyAuth)
|
|
243
|
+
|
|
244
|
+
def test_get_api_key_auth_returns_same_instance(self) -> None:
|
|
245
|
+
"""get_api_key_auth should return same instance on second call."""
|
|
246
|
+
from juniper_data.api.security import get_api_key_auth, reset_security_state
|
|
247
|
+
|
|
248
|
+
reset_security_state()
|
|
249
|
+
auth1 = get_api_key_auth()
|
|
250
|
+
auth2 = get_api_key_auth()
|
|
251
|
+
assert auth1 is auth2
|
|
252
|
+
|
|
253
|
+
def test_get_rate_limiter_returns_instance(self) -> None:
|
|
254
|
+
"""get_rate_limiter should return a RateLimiter instance."""
|
|
255
|
+
from juniper_data.api.security import get_rate_limiter, reset_security_state
|
|
256
|
+
|
|
257
|
+
reset_security_state()
|
|
258
|
+
limiter = get_rate_limiter()
|
|
259
|
+
assert isinstance(limiter, RateLimiter)
|
|
260
|
+
|
|
261
|
+
def test_get_rate_limiter_returns_same_instance(self) -> None:
|
|
262
|
+
"""get_rate_limiter should return same instance on second call."""
|
|
263
|
+
from juniper_data.api.security import get_rate_limiter, reset_security_state
|
|
264
|
+
|
|
265
|
+
reset_security_state()
|
|
266
|
+
limiter1 = get_rate_limiter()
|
|
267
|
+
limiter2 = get_rate_limiter()
|
|
268
|
+
assert limiter1 is limiter2
|
|
269
|
+
|
|
270
|
+
def test_reset_security_state(self) -> None:
|
|
271
|
+
"""reset_security_state should clear cached instances."""
|
|
272
|
+
from juniper_data.api.security import get_api_key_auth, get_rate_limiter, reset_security_state
|
|
273
|
+
|
|
274
|
+
reset_security_state()
|
|
275
|
+
auth1 = get_api_key_auth()
|
|
276
|
+
limiter1 = get_rate_limiter()
|
|
277
|
+
reset_security_state()
|
|
278
|
+
auth2 = get_api_key_auth()
|
|
279
|
+
limiter2 = get_rate_limiter()
|
|
280
|
+
assert auth1 is not auth2
|
|
281
|
+
assert limiter1 is not limiter2
|