django-bolt 0.1.0__cp310-abi3-win_amd64.whl → 0.1.2__cp310-abi3-win_amd64.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.
Potentially problematic release.
This version of django-bolt might be problematic. Click here for more details.
- django_bolt/__init__.py +2 -2
- django_bolt/_core.pyd +0 -0
- django_bolt/_json.py +169 -0
- django_bolt/admin/static_routes.py +15 -21
- django_bolt/api.py +181 -61
- django_bolt/auth/__init__.py +2 -2
- django_bolt/decorators.py +15 -3
- django_bolt/dependencies.py +30 -24
- django_bolt/error_handlers.py +2 -1
- django_bolt/openapi/plugins.py +3 -2
- django_bolt/openapi/schema_generator.py +65 -20
- django_bolt/pagination.py +2 -1
- django_bolt/responses.py +3 -2
- django_bolt/serialization.py +5 -4
- {django_bolt-0.1.0.dist-info → django_bolt-0.1.2.dist-info}/METADATA +181 -201
- {django_bolt-0.1.0.dist-info → django_bolt-0.1.2.dist-info}/RECORD +18 -55
- django_bolt/auth/README.md +0 -464
- django_bolt/auth/REVOCATION_EXAMPLE.md +0 -391
- django_bolt/tests/__init__.py +0 -0
- django_bolt/tests/admin_tests/__init__.py +0 -1
- django_bolt/tests/admin_tests/conftest.py +0 -6
- django_bolt/tests/admin_tests/test_admin_with_django.py +0 -278
- django_bolt/tests/admin_tests/urls.py +0 -9
- django_bolt/tests/cbv/__init__.py +0 -0
- django_bolt/tests/cbv/test_class_views.py +0 -570
- django_bolt/tests/cbv/test_class_views_django_orm.py +0 -703
- django_bolt/tests/cbv/test_class_views_features.py +0 -1173
- django_bolt/tests/cbv/test_class_views_with_client.py +0 -622
- django_bolt/tests/conftest.py +0 -165
- django_bolt/tests/test_action_decorator.py +0 -399
- django_bolt/tests/test_auth_secret_key.py +0 -83
- django_bolt/tests/test_decorator_syntax.py +0 -159
- django_bolt/tests/test_error_handling.py +0 -481
- django_bolt/tests/test_file_response.py +0 -192
- django_bolt/tests/test_global_cors.py +0 -172
- django_bolt/tests/test_guards_auth.py +0 -441
- django_bolt/tests/test_guards_integration.py +0 -303
- django_bolt/tests/test_health.py +0 -283
- django_bolt/tests/test_integration_validation.py +0 -400
- django_bolt/tests/test_json_validation.py +0 -536
- django_bolt/tests/test_jwt_auth.py +0 -327
- django_bolt/tests/test_jwt_token.py +0 -458
- django_bolt/tests/test_logging.py +0 -837
- django_bolt/tests/test_logging_merge.py +0 -419
- django_bolt/tests/test_middleware.py +0 -492
- django_bolt/tests/test_middleware_server.py +0 -230
- django_bolt/tests/test_model_viewset.py +0 -323
- django_bolt/tests/test_models.py +0 -24
- django_bolt/tests/test_pagination.py +0 -1258
- django_bolt/tests/test_parameter_validation.py +0 -178
- django_bolt/tests/test_syntax.py +0 -626
- django_bolt/tests/test_testing_utilities.py +0 -163
- django_bolt/tests/test_testing_utilities_simple.py +0 -123
- django_bolt/tests/test_viewset_unified.py +0 -346
- {django_bolt-0.1.0.dist-info → django_bolt-0.1.2.dist-info}/WHEEL +0 -0
- {django_bolt-0.1.0.dist-info → django_bolt-0.1.2.dist-info}/entry_points.txt +0 -0
|
@@ -1,391 +0,0 @@
|
|
|
1
|
-
# Token Revocation Examples
|
|
2
|
-
|
|
3
|
-
Token revocation is **OPTIONAL** in Django-Bolt. Only use it if you need logout functionality or token invalidation.
|
|
4
|
-
|
|
5
|
-
## Quick Start
|
|
6
|
-
|
|
7
|
-
### Option 1: In-Memory (Development/Single-Process)
|
|
8
|
-
|
|
9
|
-
```python
|
|
10
|
-
from django_bolt import BoltAPI, JWTAuthentication, Token, IsAuthenticated
|
|
11
|
-
from django_bolt.auth.revocation import InMemoryRevocation
|
|
12
|
-
from datetime import timedelta
|
|
13
|
-
import uuid
|
|
14
|
-
|
|
15
|
-
# Create revocation store
|
|
16
|
-
revocation = InMemoryRevocation()
|
|
17
|
-
|
|
18
|
-
# Create API with revocation-enabled auth
|
|
19
|
-
api = BoltAPI()
|
|
20
|
-
|
|
21
|
-
auth = JWTAuthentication(
|
|
22
|
-
secret=settings.SECRET_KEY,
|
|
23
|
-
revocation_store=revocation, # ← OPTIONAL: Enables revocation
|
|
24
|
-
require_jti=True, # ← Auto-enabled when revocation_store is provided
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
# Login endpoint - create tokens with JTI
|
|
28
|
-
@api.post("/login")
|
|
29
|
-
async def login(username: str, password: str):
|
|
30
|
-
user = await authenticate(username=username, password=password)
|
|
31
|
-
|
|
32
|
-
if not user:
|
|
33
|
-
return {"error": "Invalid credentials"}, 401
|
|
34
|
-
|
|
35
|
-
# Create token with JTI (required for revocation)
|
|
36
|
-
token = Token.create(
|
|
37
|
-
sub=str(user.id),
|
|
38
|
-
expires_delta=timedelta(hours=1),
|
|
39
|
-
jti=str(uuid.uuid4()), # ← Unique token ID (required for revocation)
|
|
40
|
-
is_staff=user.is_staff,
|
|
41
|
-
is_admin=user.is_superuser,
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
return {
|
|
45
|
-
"access_token": token.encode(settings.SECRET_KEY),
|
|
46
|
-
"token_type": "bearer"
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
# Logout endpoint - revoke token
|
|
50
|
-
@api.post("/logout", auth=[auth], guards=[IsAuthenticated()])
|
|
51
|
-
async def logout(request):
|
|
52
|
-
jti = request["context"]["auth_claims"]["jti"]
|
|
53
|
-
|
|
54
|
-
# Revoke the token
|
|
55
|
-
await revocation.revoke(jti, ttl=3600) # TTL = remaining token lifetime
|
|
56
|
-
|
|
57
|
-
return {"message": "Logged out successfully"}
|
|
58
|
-
|
|
59
|
-
# Protected endpoint
|
|
60
|
-
@api.get("/profile", auth=[auth], guards=[IsAuthenticated()])
|
|
61
|
-
async def get_profile(request):
|
|
62
|
-
user_id = request["context"]["user_id"]
|
|
63
|
-
return {"user_id": user_id}
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
---
|
|
67
|
-
|
|
68
|
-
### Option 2: Django Cache (Production - Redis/Memcached)
|
|
69
|
-
|
|
70
|
-
```python
|
|
71
|
-
from django_bolt import BoltAPI, JWTAuthentication
|
|
72
|
-
from django_bolt.auth.revocation import DjangoCacheRevocation
|
|
73
|
-
|
|
74
|
-
# settings.py
|
|
75
|
-
CACHES = {
|
|
76
|
-
'default': {
|
|
77
|
-
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
|
|
78
|
-
'LOCATION': 'redis://127.0.0.1:6379/1',
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
# api.py
|
|
83
|
-
revocation = DjangoCacheRevocation(
|
|
84
|
-
cache_alias='default',
|
|
85
|
-
key_prefix='revoked:', # Keys will be "revoked:{jti}"
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
auth = JWTAuthentication(
|
|
89
|
-
secret=settings.SECRET_KEY,
|
|
90
|
-
revocation_store=revocation,
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
@api.post("/logout", auth=[auth])
|
|
94
|
-
async def logout(request):
|
|
95
|
-
jti = request["context"]["auth_claims"]["jti"]
|
|
96
|
-
|
|
97
|
-
# Revoke token - stored in Redis with TTL
|
|
98
|
-
await revocation.revoke(jti, ttl=86400 * 30) # 30 days
|
|
99
|
-
|
|
100
|
-
return {"message": "Token revoked"}
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
---
|
|
104
|
-
|
|
105
|
-
### Option 3: Database (No Cache Infrastructure)
|
|
106
|
-
|
|
107
|
-
```python
|
|
108
|
-
# myapp/models.py
|
|
109
|
-
from django.db import models
|
|
110
|
-
|
|
111
|
-
class RevokedToken(models.Model):
|
|
112
|
-
jti = models.CharField(max_length=255, unique=True, db_index=True)
|
|
113
|
-
revoked_at = models.DateTimeField(auto_now_add=True)
|
|
114
|
-
expires_at = models.DateTimeField(db_index=True)
|
|
115
|
-
|
|
116
|
-
class Meta:
|
|
117
|
-
indexes = [
|
|
118
|
-
models.Index(fields=['jti']),
|
|
119
|
-
models.Index(fields=['expires_at']),
|
|
120
|
-
]
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
```python
|
|
124
|
-
# api.py
|
|
125
|
-
from django_bolt.auth.revocation import DjangoORMRevocation
|
|
126
|
-
|
|
127
|
-
revocation = DjangoORMRevocation(model='myapp.RevokedToken')
|
|
128
|
-
|
|
129
|
-
auth = JWTAuthentication(
|
|
130
|
-
secret=settings.SECRET_KEY,
|
|
131
|
-
revocation_store=revocation,
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
@api.post("/logout", auth=[auth])
|
|
135
|
-
async def logout(request):
|
|
136
|
-
jti = request["context"]["auth_claims"]["jti"]
|
|
137
|
-
|
|
138
|
-
# Revoke token - stored in database
|
|
139
|
-
await revocation.revoke(jti, ttl=86400 * 30)
|
|
140
|
-
|
|
141
|
-
return {"message": "Token revoked"}
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
**⚠️ Important**: Add cleanup task for expired tokens:
|
|
145
|
-
|
|
146
|
-
```python
|
|
147
|
-
# Celery task or cron job
|
|
148
|
-
from datetime import datetime, timezone
|
|
149
|
-
from myapp.models import RevokedToken
|
|
150
|
-
|
|
151
|
-
async def cleanup_expired_tokens():
|
|
152
|
-
"""Run this periodically (e.g., daily)"""
|
|
153
|
-
await RevokedToken.objects.filter(
|
|
154
|
-
expires_at__lt=datetime.now(timezone.utc)
|
|
155
|
-
).adelete()
|
|
156
|
-
```
|
|
157
|
-
|
|
158
|
-
---
|
|
159
|
-
|
|
160
|
-
### Option 4: Custom Revocation Handler
|
|
161
|
-
|
|
162
|
-
If you have custom logic or use a different storage backend:
|
|
163
|
-
|
|
164
|
-
```python
|
|
165
|
-
from django_bolt import JWTAuthentication
|
|
166
|
-
import httpx
|
|
167
|
-
|
|
168
|
-
# Custom handler - checks external service
|
|
169
|
-
async def check_token_revoked(jti: str) -> bool:
|
|
170
|
-
"""Custom revocation logic"""
|
|
171
|
-
async with httpx.AsyncClient() as client:
|
|
172
|
-
response = await client.get(f"https://auth-service/check-revoked/{jti}")
|
|
173
|
-
return response.json()["revoked"]
|
|
174
|
-
|
|
175
|
-
auth = JWTAuthentication(
|
|
176
|
-
secret=settings.SECRET_KEY,
|
|
177
|
-
revoked_token_handler=check_token_revoked, # ← Custom function
|
|
178
|
-
require_jti=True,
|
|
179
|
-
)
|
|
180
|
-
```
|
|
181
|
-
|
|
182
|
-
---
|
|
183
|
-
|
|
184
|
-
## Without Revocation (Simpler, Faster)
|
|
185
|
-
|
|
186
|
-
If you don't need logout functionality, just don't provide a revocation handler:
|
|
187
|
-
|
|
188
|
-
```python
|
|
189
|
-
# NO revocation - tokens valid until expiry
|
|
190
|
-
auth = JWTAuthentication(
|
|
191
|
-
secret=settings.SECRET_KEY,
|
|
192
|
-
# No revocation_store or revoked_token_handler
|
|
193
|
-
)
|
|
194
|
-
|
|
195
|
-
# Users can't logout - tokens expire naturally
|
|
196
|
-
# ✅ Simpler
|
|
197
|
-
# ✅ Faster (~60k RPS vs ~50k RPS with revocation)
|
|
198
|
-
# ❌ No logout support
|
|
199
|
-
```
|
|
200
|
-
|
|
201
|
-
---
|
|
202
|
-
|
|
203
|
-
## Performance Comparison
|
|
204
|
-
|
|
205
|
-
| Revocation Strategy | RPS | Multi-Process | Notes |
|
|
206
|
-
|---------------------|-----|---------------|-------|
|
|
207
|
-
| **No Revocation** | ~60k | ✅ | Fastest, no logout |
|
|
208
|
-
| **InMemoryRevocation** | ~58k | ❌ | Single process only |
|
|
209
|
-
| **DjangoCacheRevocation (Redis)** | ~50k | ✅ | **Recommended for production** |
|
|
210
|
-
| **DjangoCacheRevocation (Memcached)** | ~48k | ✅ | Good alternative |
|
|
211
|
-
| **DjangoORMRevocation (PostgreSQL)** | ~5k | ✅ | Slowest, use if no cache |
|
|
212
|
-
| **DjangoORMRevocation (SQLite)** | ~2k | ⚠️ | Not for production |
|
|
213
|
-
|
|
214
|
-
---
|
|
215
|
-
|
|
216
|
-
## Complete Login/Logout Example
|
|
217
|
-
|
|
218
|
-
```python
|
|
219
|
-
from django_bolt import BoltAPI, JWTAuthentication, Token, IsAuthenticated, AllowAny
|
|
220
|
-
from django_bolt.auth.revocation import DjangoCacheRevocation
|
|
221
|
-
from django.contrib.auth import authenticate
|
|
222
|
-
from datetime import timedelta
|
|
223
|
-
import uuid
|
|
224
|
-
|
|
225
|
-
api = BoltAPI()
|
|
226
|
-
|
|
227
|
-
# Setup revocation
|
|
228
|
-
revocation = DjangoCacheRevocation()
|
|
229
|
-
|
|
230
|
-
auth = JWTAuthentication(
|
|
231
|
-
secret=settings.SECRET_KEY,
|
|
232
|
-
revocation_store=revocation,
|
|
233
|
-
)
|
|
234
|
-
|
|
235
|
-
# Public login endpoint
|
|
236
|
-
@api.post("/auth/login", guards=[AllowAny()])
|
|
237
|
-
async def login(username: str, password: str):
|
|
238
|
-
"""Authenticate and return JWT token."""
|
|
239
|
-
user = await authenticate(username=username, password=password)
|
|
240
|
-
|
|
241
|
-
if not user:
|
|
242
|
-
return {"error": "Invalid credentials"}, 401
|
|
243
|
-
|
|
244
|
-
# Create access token (short-lived)
|
|
245
|
-
access_token = Token.create(
|
|
246
|
-
sub=str(user.id),
|
|
247
|
-
expires_delta=timedelta(minutes=15),
|
|
248
|
-
jti=str(uuid.uuid4()), # Required for revocation
|
|
249
|
-
is_staff=user.is_staff,
|
|
250
|
-
is_admin=user.is_superuser,
|
|
251
|
-
permissions=list(user.get_all_permissions()),
|
|
252
|
-
extras={
|
|
253
|
-
"username": user.username,
|
|
254
|
-
"email": user.email,
|
|
255
|
-
}
|
|
256
|
-
)
|
|
257
|
-
|
|
258
|
-
# Optional: Create refresh token (long-lived)
|
|
259
|
-
refresh_token = Token.create(
|
|
260
|
-
sub=str(user.id),
|
|
261
|
-
expires_delta=timedelta(days=30),
|
|
262
|
-
jti=str(uuid.uuid4()),
|
|
263
|
-
extras={"type": "refresh"}
|
|
264
|
-
)
|
|
265
|
-
|
|
266
|
-
return {
|
|
267
|
-
"access_token": access_token.encode(settings.SECRET_KEY),
|
|
268
|
-
"refresh_token": refresh_token.encode(settings.SECRET_KEY),
|
|
269
|
-
"token_type": "bearer",
|
|
270
|
-
"expires_in": 900 # 15 minutes
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
# Protected logout endpoint
|
|
274
|
-
@api.post("/auth/logout", auth=[auth], guards=[IsAuthenticated()])
|
|
275
|
-
async def logout(request):
|
|
276
|
-
"""Logout - revoke current token."""
|
|
277
|
-
jti = request["context"]["auth_claims"]["jti"]
|
|
278
|
-
|
|
279
|
-
# Calculate TTL from token expiry
|
|
280
|
-
exp = request["context"]["auth_claims"]["exp"]
|
|
281
|
-
import time
|
|
282
|
-
ttl = max(0, exp - int(time.time()))
|
|
283
|
-
|
|
284
|
-
# Revoke token
|
|
285
|
-
await revocation.revoke(jti, ttl=ttl)
|
|
286
|
-
|
|
287
|
-
return {"message": "Logged out successfully"}
|
|
288
|
-
|
|
289
|
-
# Refresh token endpoint
|
|
290
|
-
@api.post("/auth/refresh", guards=[AllowAny()])
|
|
291
|
-
async def refresh_access_token(refresh_token: str):
|
|
292
|
-
"""Exchange refresh token for new access token."""
|
|
293
|
-
try:
|
|
294
|
-
# Decode refresh token
|
|
295
|
-
token = Token.decode(refresh_token, secret=settings.SECRET_KEY)
|
|
296
|
-
|
|
297
|
-
# Validate it's a refresh token
|
|
298
|
-
if token.extras.get("type") != "refresh":
|
|
299
|
-
return {"error": "Invalid token type"}, 401
|
|
300
|
-
|
|
301
|
-
# Check if revoked
|
|
302
|
-
if await revocation.is_revoked(token.jti):
|
|
303
|
-
return {"error": "Token has been revoked"}, 401
|
|
304
|
-
|
|
305
|
-
# Create new access token
|
|
306
|
-
new_access = Token.create(
|
|
307
|
-
sub=token.sub,
|
|
308
|
-
expires_delta=timedelta(minutes=15),
|
|
309
|
-
jti=str(uuid.uuid4()),
|
|
310
|
-
)
|
|
311
|
-
|
|
312
|
-
return {
|
|
313
|
-
"access_token": new_access.encode(settings.SECRET_KEY),
|
|
314
|
-
"token_type": "bearer",
|
|
315
|
-
"expires_in": 900
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
except ValueError as e:
|
|
319
|
-
return {"error": "Invalid token"}, 401
|
|
320
|
-
|
|
321
|
-
# Protected endpoint
|
|
322
|
-
@api.get("/profile", auth=[auth], guards=[IsAuthenticated()])
|
|
323
|
-
async def get_profile(request):
|
|
324
|
-
"""Get current user profile."""
|
|
325
|
-
user_id = request["context"]["user_id"]
|
|
326
|
-
is_admin = request["context"]["is_admin"]
|
|
327
|
-
permissions = request["context"].get("permissions", [])
|
|
328
|
-
|
|
329
|
-
return {
|
|
330
|
-
"user_id": user_id,
|
|
331
|
-
"is_admin": is_admin,
|
|
332
|
-
"permissions": permissions,
|
|
333
|
-
}
|
|
334
|
-
```
|
|
335
|
-
|
|
336
|
-
---
|
|
337
|
-
|
|
338
|
-
## Best Practices
|
|
339
|
-
|
|
340
|
-
### 1. Always Use JTI with Revocation
|
|
341
|
-
|
|
342
|
-
```python
|
|
343
|
-
# ✅ Good - JTI for revocation
|
|
344
|
-
token = Token.create(
|
|
345
|
-
sub="user123",
|
|
346
|
-
jti=str(uuid.uuid4()), # Unique ID
|
|
347
|
-
expires_delta=timedelta(hours=1)
|
|
348
|
-
)
|
|
349
|
-
|
|
350
|
-
# ❌ Bad - No JTI, can't revoke
|
|
351
|
-
token = Token.create(
|
|
352
|
-
sub="user123",
|
|
353
|
-
expires_delta=timedelta(hours=1)
|
|
354
|
-
)
|
|
355
|
-
```
|
|
356
|
-
|
|
357
|
-
### 2. Set Appropriate TTL
|
|
358
|
-
|
|
359
|
-
```python
|
|
360
|
-
# Match revocation TTL to token expiry
|
|
361
|
-
token = Token.create(sub="user123", expires_delta=timedelta(hours=1))
|
|
362
|
-
|
|
363
|
-
# Revoke with same TTL (3600 seconds = 1 hour)
|
|
364
|
-
await revocation.revoke(jti, ttl=3600)
|
|
365
|
-
|
|
366
|
-
# After 1 hour, token expires AND revocation entry is cleaned up
|
|
367
|
-
```
|
|
368
|
-
|
|
369
|
-
### 3. Use DjangoCacheRevocation in Production
|
|
370
|
-
|
|
371
|
-
```python
|
|
372
|
-
# ✅ Recommended for production
|
|
373
|
-
revocation = DjangoCacheRevocation() # Uses Redis/Memcached
|
|
374
|
-
|
|
375
|
-
# ⚠️ Only for development
|
|
376
|
-
revocation = InMemoryRevocation() # Single-process only
|
|
377
|
-
|
|
378
|
-
# 🐌 Slower but works without cache
|
|
379
|
-
revocation = DjangoORMRevocation(model='myapp.RevokedToken')
|
|
380
|
-
```
|
|
381
|
-
|
|
382
|
-
---
|
|
383
|
-
|
|
384
|
-
## Summary
|
|
385
|
-
|
|
386
|
-
- **Revocation is OPTIONAL** - only use if you need logout
|
|
387
|
-
- **Multiple storage options** - In-memory, Cache, Database, Custom
|
|
388
|
-
- **No Redis requirement** - Works with Django cache (any backend)
|
|
389
|
-
- **Performance-aware** - Cache-based revocation still does ~50k RPS
|
|
390
|
-
- **JTI auto-required** - When revocation is enabled, JTI becomes mandatory
|
|
391
|
-
- **Flexible** - Bring your own storage with custom handler
|
django_bolt/tests/__init__.py
DELETED
|
File without changes
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
# Admin integration tests
|
|
@@ -1,278 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Tests for Django admin integration that actually use a Django project.
|
|
3
|
-
|
|
4
|
-
These tests configure Django properly and will FAIL if ASGI bridge is broken.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import pytest
|
|
8
|
-
from django_bolt.api import BoltAPI
|
|
9
|
-
from django_bolt.testing import TestClient
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
@pytest.fixture(scope="module")
|
|
13
|
-
def api_with_admin():
|
|
14
|
-
"""Create API with admin enabled using real Django project."""
|
|
15
|
-
api = BoltAPI()
|
|
16
|
-
api._register_admin_routes('127.0.0.1', 8000)
|
|
17
|
-
|
|
18
|
-
@api.get("/test")
|
|
19
|
-
async def test_route():
|
|
20
|
-
return {"test": "ok"}
|
|
21
|
-
|
|
22
|
-
return api
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
@pytest.fixture(scope="module")
|
|
26
|
-
def client(api_with_admin):
|
|
27
|
-
"""Create test client with HTTP layer."""
|
|
28
|
-
with TestClient(api_with_admin, use_http_layer=True) as client:
|
|
29
|
-
yield client
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def test_admin_root_redirect(client):
|
|
33
|
-
"""Test /admin/ returns content (redirect or login page)."""
|
|
34
|
-
response = client.get("/admin/")
|
|
35
|
-
|
|
36
|
-
print(f"\n[Admin Root Test]")
|
|
37
|
-
print(f"Status: {response.status_code}")
|
|
38
|
-
print(f"Headers: {dict(response.headers)}")
|
|
39
|
-
print(f"Body length: {len(response.content)}")
|
|
40
|
-
print(f"Body preview: {response.text[:300] if response.text else 'N/A'}")
|
|
41
|
-
|
|
42
|
-
# Should return a valid response (redirect or login page)
|
|
43
|
-
assert response.status_code in (200, 301, 302), f"Expected valid response, got {response.status_code}"
|
|
44
|
-
|
|
45
|
-
# CRITICAL: Body should NOT be empty
|
|
46
|
-
assert len(response.content) > 0, f"Response body is EMPTY! Got {len(response.content)} bytes. ASGI bridge is BROKEN!"
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def test_admin_login_page(client):
|
|
50
|
-
"""Test /admin/login/ returns HTML page (not empty body)."""
|
|
51
|
-
response = client.get("/admin/login/")
|
|
52
|
-
|
|
53
|
-
print(f"\n[Admin Login Test]")
|
|
54
|
-
print(f"Status: {response.status_code}")
|
|
55
|
-
print(f"Headers: {dict(response.headers)}")
|
|
56
|
-
print(f"Body length: {len(response.content)}")
|
|
57
|
-
print(f"Body preview: {response.text[:300]}")
|
|
58
|
-
|
|
59
|
-
# Should return 200 OK
|
|
60
|
-
assert response.status_code == 200, f"Expected 200, got {response.status_code}"
|
|
61
|
-
|
|
62
|
-
# CRITICAL: Body should NOT be empty - THIS IS THE BUG
|
|
63
|
-
assert len(response.content) > 0, f"Admin login page body is EMPTY! Got {len(response.content)} bytes. ASGI bridge is BROKEN!"
|
|
64
|
-
|
|
65
|
-
# Should be HTML
|
|
66
|
-
content_type = response.headers.get('content-type', '')
|
|
67
|
-
assert 'html' in content_type.lower(), f"Expected HTML, got {content_type}"
|
|
68
|
-
|
|
69
|
-
# Should contain login form
|
|
70
|
-
body_text = response.text.lower()
|
|
71
|
-
assert 'login' in body_text or 'django' in body_text, f"Expected login content, got: {body_text[:200]}"
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
@pytest.mark.django_db
|
|
75
|
-
def test_asgi_bridge_direct_with_real_django():
|
|
76
|
-
"""Test ASGI bridge directly with real Django configuration."""
|
|
77
|
-
from django_bolt.admin.asgi_bridge import ASGIFallbackHandler
|
|
78
|
-
import asyncio
|
|
79
|
-
|
|
80
|
-
# Database is already set up by pytest-django
|
|
81
|
-
handler = ASGIFallbackHandler(server_host="127.0.0.1", server_port=8000)
|
|
82
|
-
|
|
83
|
-
request = {
|
|
84
|
-
"method": "GET",
|
|
85
|
-
"path": "/admin/login/",
|
|
86
|
-
"body": b"",
|
|
87
|
-
"params": {},
|
|
88
|
-
"query": {},
|
|
89
|
-
"headers": {"host": "127.0.0.1:8000"},
|
|
90
|
-
"cookies": {},
|
|
91
|
-
"context": None,
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
status, headers, body = asyncio.run(handler.handle_request(request))
|
|
95
|
-
|
|
96
|
-
print(f"\n[ASGI Bridge Direct Test]")
|
|
97
|
-
print(f"Status: {status}")
|
|
98
|
-
print(f"Headers: {dict(headers)}")
|
|
99
|
-
print(f"Body length: {len(body)}")
|
|
100
|
-
print(f"Body preview: {body[:300]}")
|
|
101
|
-
|
|
102
|
-
# Validate structure
|
|
103
|
-
assert isinstance(status, int), f"Status should be int, got {type(status)}"
|
|
104
|
-
assert isinstance(headers, list), f"Headers should be list, got {type(headers)}"
|
|
105
|
-
assert isinstance(body, bytes), f"Body should be bytes, got {type(body)}"
|
|
106
|
-
|
|
107
|
-
# Should return 200 OK
|
|
108
|
-
assert status == 200, f"Expected 200, got {status}"
|
|
109
|
-
|
|
110
|
-
# CRITICAL TEST: Body should NOT be empty - THIS WILL FAIL IF BUG EXISTS
|
|
111
|
-
assert len(body) > 0, f"ASGI bridge returned EMPTY body! Expected HTML content. Body length: {len(body)}"
|
|
112
|
-
|
|
113
|
-
# Should be HTML content
|
|
114
|
-
body_text = body.decode('utf-8', errors='ignore')
|
|
115
|
-
assert 'html' in body_text.lower(), f"Expected HTML content, got: {body_text[:100]}"
|
|
116
|
-
assert 'django' in body_text.lower() or 'login' in body_text.lower(), f"Expected Django admin content"
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
@pytest.mark.django_db
|
|
120
|
-
def test_asgi_bridge_admin_root():
|
|
121
|
-
"""Test ASGI bridge handles /admin/ root correctly."""
|
|
122
|
-
from django_bolt.admin.asgi_bridge import ASGIFallbackHandler
|
|
123
|
-
import asyncio
|
|
124
|
-
|
|
125
|
-
handler = ASGIFallbackHandler(server_host="127.0.0.1", server_port=8000)
|
|
126
|
-
|
|
127
|
-
request = {
|
|
128
|
-
"method": "GET",
|
|
129
|
-
"path": "/admin/",
|
|
130
|
-
"body": b"",
|
|
131
|
-
"params": {},
|
|
132
|
-
"query": {},
|
|
133
|
-
"headers": {"host": "127.0.0.1:8000"},
|
|
134
|
-
"cookies": {},
|
|
135
|
-
"context": None,
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
status, headers, body = asyncio.run(handler.handle_request(request))
|
|
139
|
-
|
|
140
|
-
# Should redirect to login
|
|
141
|
-
assert status in (301, 302), f"Expected redirect, got {status}"
|
|
142
|
-
|
|
143
|
-
# Should have location header
|
|
144
|
-
location = None
|
|
145
|
-
for name, value in headers:
|
|
146
|
-
if name.lower() == 'location':
|
|
147
|
-
location = value
|
|
148
|
-
break
|
|
149
|
-
|
|
150
|
-
assert location is not None, "Redirect should have Location header"
|
|
151
|
-
assert '/admin/login/' in location, f"Should redirect to login, got {location}"
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
@pytest.mark.django_db
|
|
155
|
-
def test_asgi_bridge_with_query_params():
|
|
156
|
-
"""Test ASGI bridge handles query parameters correctly."""
|
|
157
|
-
from django_bolt.admin.asgi_bridge import ASGIFallbackHandler
|
|
158
|
-
import asyncio
|
|
159
|
-
|
|
160
|
-
handler = ASGIFallbackHandler(server_host="127.0.0.1", server_port=8000)
|
|
161
|
-
|
|
162
|
-
request = {
|
|
163
|
-
"method": "GET",
|
|
164
|
-
"path": "/admin/login/",
|
|
165
|
-
"body": b"",
|
|
166
|
-
"params": {},
|
|
167
|
-
"query": {"next": "/admin/"},
|
|
168
|
-
"headers": {"host": "127.0.0.1:8000"},
|
|
169
|
-
"cookies": {},
|
|
170
|
-
"context": None,
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
status, headers, body = asyncio.run(handler.handle_request(request))
|
|
174
|
-
|
|
175
|
-
# Should return 200 OK
|
|
176
|
-
assert status == 200, f"Expected 200, got {status}"
|
|
177
|
-
assert len(body) > 0, "Body should not be empty"
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
@pytest.mark.django_db
|
|
181
|
-
def test_asgi_bridge_post_request():
|
|
182
|
-
"""Test ASGI bridge handles POST requests correctly."""
|
|
183
|
-
from django_bolt.admin.asgi_bridge import ASGIFallbackHandler
|
|
184
|
-
import asyncio
|
|
185
|
-
|
|
186
|
-
handler = ASGIFallbackHandler(server_host="127.0.0.1", server_port=8000)
|
|
187
|
-
|
|
188
|
-
# POST request with form data
|
|
189
|
-
form_data = b"username=admin&password=test123"
|
|
190
|
-
|
|
191
|
-
request = {
|
|
192
|
-
"method": "POST",
|
|
193
|
-
"path": "/admin/login/",
|
|
194
|
-
"body": form_data,
|
|
195
|
-
"params": {},
|
|
196
|
-
"query": {},
|
|
197
|
-
"headers": {
|
|
198
|
-
"host": "127.0.0.1:8000",
|
|
199
|
-
"content-type": "application/x-www-form-urlencoded",
|
|
200
|
-
"content-length": str(len(form_data)),
|
|
201
|
-
},
|
|
202
|
-
"cookies": {},
|
|
203
|
-
"context": None,
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
status, headers, body = asyncio.run(handler.handle_request(request))
|
|
207
|
-
|
|
208
|
-
# Should return response (even if login fails, it should process the request)
|
|
209
|
-
assert isinstance(status, int), f"Status should be int, got {type(status)}"
|
|
210
|
-
assert len(body) > 0, "Body should not be empty"
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
@pytest.mark.django_db
|
|
214
|
-
def test_asgi_bridge_404_path():
|
|
215
|
-
"""Test ASGI bridge handles non-existent admin paths correctly."""
|
|
216
|
-
from django_bolt.admin.asgi_bridge import ASGIFallbackHandler
|
|
217
|
-
import asyncio
|
|
218
|
-
|
|
219
|
-
handler = ASGIFallbackHandler(server_host="127.0.0.1", server_port=8000)
|
|
220
|
-
|
|
221
|
-
request = {
|
|
222
|
-
"method": "GET",
|
|
223
|
-
"path": "/admin/nonexistent/path/",
|
|
224
|
-
"body": b"",
|
|
225
|
-
"params": {},
|
|
226
|
-
"query": {},
|
|
227
|
-
"headers": {"host": "127.0.0.1:8000"},
|
|
228
|
-
"cookies": {},
|
|
229
|
-
"context": None,
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
status, headers, body = asyncio.run(handler.handle_request(request))
|
|
233
|
-
|
|
234
|
-
# Django redirects unauthenticated users to login for non-existent admin paths
|
|
235
|
-
# This is expected Django admin behavior
|
|
236
|
-
assert status in (302, 404), f"Expected redirect or 404, got {status}"
|
|
237
|
-
assert len(body) >= 0, "Response should have structure"
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
@pytest.mark.django_db
|
|
241
|
-
def test_asgi_bridge_with_cookies():
|
|
242
|
-
"""Test ASGI bridge handles cookies correctly."""
|
|
243
|
-
from django_bolt.admin.asgi_bridge import ASGIFallbackHandler
|
|
244
|
-
import asyncio
|
|
245
|
-
|
|
246
|
-
handler = ASGIFallbackHandler(server_host="127.0.0.1", server_port=8000)
|
|
247
|
-
|
|
248
|
-
request = {
|
|
249
|
-
"method": "GET",
|
|
250
|
-
"path": "/admin/login/",
|
|
251
|
-
"body": b"",
|
|
252
|
-
"params": {},
|
|
253
|
-
"query": {},
|
|
254
|
-
"headers": {
|
|
255
|
-
"host": "127.0.0.1:8000",
|
|
256
|
-
"cookie": "sessionid=abc123; csrftoken=xyz789",
|
|
257
|
-
},
|
|
258
|
-
"cookies": {
|
|
259
|
-
"sessionid": "abc123",
|
|
260
|
-
"csrftoken": "xyz789",
|
|
261
|
-
},
|
|
262
|
-
"context": None,
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
status, headers, body = asyncio.run(handler.handle_request(request))
|
|
266
|
-
|
|
267
|
-
# Should return 200 OK
|
|
268
|
-
assert status == 200, f"Expected 200, got {status}"
|
|
269
|
-
assert len(body) > 0, "Body should not be empty"
|
|
270
|
-
|
|
271
|
-
# Should set CSRF token cookie
|
|
272
|
-
has_csrf = False
|
|
273
|
-
for name, value in headers:
|
|
274
|
-
if name.lower() == 'set-cookie' and 'csrftoken' in value:
|
|
275
|
-
has_csrf = True
|
|
276
|
-
break
|
|
277
|
-
|
|
278
|
-
assert has_csrf, "Response should include CSRF token cookie"
|
|
File without changes
|