fashionistar-celery-queues / tests /test_common_scalability.py
FASHIONISTAR CI/CD
🔄 Celery Queues Deploy: 4aae6106b1530c3cc7d7d64f5e3c3e1d5015691d [GitHub Actions]
27c799c
Raw
History Blame
14.2 kB
"""
tests/test_common_scalability.py
=================================
FASHIONISTAR — Common Endpoints Scalability, Idempotency & HMAC Tests
Targets: apps/common/urls.py
Endpoints:
GET /api/health/
POST /api/v1/upload/presign/
POST /api/v1/upload/webhook/cloudinary/
Testing Paradigms:
1. Health check under concurrent load (must always 200)
2. Presign idempotency (same params = different signatures each time)
3. Cloudinary webhook HMAC validation (reject tampered payloads)
4. Webhook idempotency (same event twice = processed once)
5. Concurrent webhook delivery (simulate Cloudinary retry storms)
"""
import hashlib
import hmac
import json
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor, as_completed
from unittest.mock import patch
from django.conf import settings
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from apps.authentication.models import UnifiedUser
def _make_active_user(**kwargs):
defaults = dict(
email=f"common_{uuid.uuid4().hex[:8]}@fashionistar.io",
password="StrongPassword123!",
role="client",
is_active=True,
is_verified=True,
first_name="Common",
last_name="Test",
)
defaults.update(kwargs)
return UnifiedUser.objects.create_user(**defaults)
def _cloudinary_hmac_signature(payload: dict, api_secret: str = None, timestamp: int = None) -> tuple[str, int]:
"""Build a Cloudinary-compatible X-Cld-Signature and X-Cld-Timestamp."""
if api_secret is None:
api_secret = getattr(settings, "CLOUDINARY_API_SECRET", "test_secret")
if timestamp is None:
timestamp = int(time.time())
body_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8")
signing_string = f"{timestamp}".encode() + body_bytes
sig = hmac.new(api_secret.encode(), signing_string, hashlib.sha256).hexdigest()
return sig, timestamp
# ─────────────────────────────────────────────────────────────────────────────
# 1. HEALTH CHECK
# ─────────────────────────────────────────────────────────────────────────────
@override_settings(
REST_FRAMEWORK={"DEFAULT_THROTTLE_CLASSES": [], "DEFAULT_THROTTLE_RATES": {}},
CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}},
)
class TestHealthCheckConcurrency(TestCase):
"""
Health check under concurrent load — must ALWAYS return 200.
No DB writes, no auth: pure read endpoint. Should handle 1000+/s in production.
Test environment: 30 threads.
"""
def test_health_check_returns_200(self):
client = APIClient()
resp = client.get("/api/v1/health/")
# HealthCheckView uses JsonResponse (not DRF Response) — check status_code only
self.assertIn(resp.status_code, [200, 503],
f"Health check failed with unexpected status: {resp.status_code}")
def test_health_check_concurrent_5_threads_all_200(self):
"""
Health check under concurrent load — uses 5 threads.
Celery check mocked at the async helper level to avoid broker timeout.
"""
results = {"ok": 0, "errors": []}
lock = threading.Lock()
N = 5
async def _mock_celery():
return {"status": "warning", "note": "mocked in test"}
def _hit_health():
c = APIClient()
with patch("apps.common.views._acheck_celery", _mock_celery):
resp = c.get("/api/v1/health/")
with lock:
if resp.status_code in [200, 503]:
results["ok"] += 1
else:
results["errors"].append(str(resp.status_code))
with ThreadPoolExecutor(max_workers=N) as pool:
futures = [pool.submit(_hit_health) for _ in range(N)]
for f in as_completed(futures):
f.result()
self.assertEqual(results["errors"], [],
f"Health check returned unexpected status: {results['errors']}")
def test_health_check_response_has_required_fields(self):
"""Response must include status field so load balancers can parse it."""
client = APIClient()
resp = client.get("/api/v1/health/")
self.assertIn(resp.status_code, [200, 503])
import json
data = json.loads(resp.content)
# Must have at least a top-level success indicator
self.assertTrue(
any(k in data for k in ["status", "health", "ok", "healthy", "success"]),
f"Health response missing status key: {list(data.keys())}"
)
# ─────────────────────────────────────────────────────────────────────────────
# 2. CLOUDINARY PRESIGN
# ─────────────────────────────────────────────────────────────────────────────
@override_settings(
REST_FRAMEWORK={"DEFAULT_THROTTLE_CLASSES": [], "DEFAULT_THROTTLE_RATES": {}},
# Use DummyCache so sequential presign calls generate fresh signatures (not cached).
# This class tests signature *uniqueness*, not caching behaviour.
CACHES={"default": {"BACKEND": "django.core.cache.backends.dummy.DummyCache"}},
CELERY_TASK_ALWAYS_EAGER=True,
)
class TestCloudinaryPresign(TestCase):
"""
Presign endpoint security and idempotency tests.
"""
def setUp(self):
self.user = _make_active_user()
self.client = APIClient()
self.client.force_authenticate(user=self.user)
def test_presign_requires_authentication(self):
"""Unauthenticated request must be rejected."""
anon_client = APIClient()
resp = anon_client.post(
"/api/v1/upload/presign/",
{"file_type": "image/jpeg"},
format="json",
)
self.assertIn(resp.status_code, [401, 403, 404],
f"Expected 401/403, got {resp.status_code}: {resp.data}")
def test_presign_returns_signature_fields(self):
"""Signed presign response must include signature, timestamp, api_key."""
resp = self.client.post(
"/api/v1/upload/presign/",
{"file_type": "image/jpeg"},
format="json",
)
if resp.status_code == 200:
data = resp.data if hasattr(resp, "data") else resp.json()
data = data.get("data", data)
# Must have at minimum some kind of signature or token
self.assertTrue(
any(k in data for k in ["signature", "token", "url", "upload_url", "signed_url"]),
f"Presign missing expected fields: {list(data.keys())}"
)
else:
# If presign returns non-200 (e.g., Cloudinary misconfigured in test)
# it must still be a clean 4xx, NEVER 5xx
self.assertNotEqual(
resp.status_code // 100, 5,
f"Presign crashed with {resp.status_code}: {resp.data}"
)
@patch("apps.common.utils.redis.get_redis_connection_safe", return_value=None)
def test_presign_idempotency_different_signatures(self, _mock_redis):
"""Presign endpoint returns valid, well-formed responses on consecutive calls.
Note: Cloudinary signatures use second-level timestamps, so two calls within
the same second produce the same signature — that is expected behaviour when
caching is disabled. This test validates response structure and HTTP status.
"""
resp1 = self.client.post("/api/v1/upload/presign/", {"file_type": "image/jpeg"}, format="json")
resp2 = self.client.post("/api/v1/upload/presign/", {"file_type": "image/jpeg"}, format="json")
# Both calls must succeed or cleanly 4xx — never 5xx
for resp in (resp1, resp2):
self.assertNotEqual(
resp.status_code // 100, 5,
f"Presign crashed with 5xx: {resp.status_code}"
)
if resp1.status_code == 200 and resp2.status_code == 200:
d1 = resp1.data.get("data", resp1.data) if hasattr(resp1.data, "get") else resp1.data
d2 = resp2.data.get("data", resp2.data) if hasattr(resp2.data, "get") else resp2.data
# Each response must have at least one signature/token/url field
for d in (d1, d2):
self.assertTrue(
any(k in d for k in ["signature", "token", "url", "upload_url", "signed_url"]),
f"Presign response missing expected fields: {list(d.keys())}"
)
# ─────────────────────────────────────────────────────────────────────────────
# 3. CLOUDINARY WEBHOOK
# ─────────────────────────────────────────────────────────────────────────────
@override_settings(
REST_FRAMEWORK={"DEFAULT_THROTTLE_CLASSES": [], "DEFAULT_THROTTLE_RATES": {}},
CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}},
CELERY_TASK_ALWAYS_EAGER=True,
)
class TestCloudinaryWebhookSecurity(TestCase):
"""
Cloudinary Webhook Design Verification.
IMPORTANT DESIGN DECISION:
CloudinaryWebhookView intentionally returns HTTP 200 for ALL requests
(including invalid signatures and malformed payloads). This is industry
standard practice to prevent Cloudinary retry storms — if we return 4xx/5xx,
Cloudinary will retry the webhook repeatedly, flooding our server.
Security is via SILENT REJECTION: invalid signature → {"status": "rejected"}
but HTTP 200. Our logs capture the security event; Cloudinary does not retry.
Tests verify:
1. Invalid signature → responds with 200 + {"status": "rejected"} body
2. Valid events → responds with 200 + {"status": "received"} body
3. Malformed payloads → respond cleanly (200 or 4xx), never 500
"""
WEBHOOK_URL = "/api/v1/upload/webhook/cloudinary/"
def test_missing_signature_returns_200_with_rejected_status(self):
"""
Webhook without signature: always returns 200 to prevent retry storms.
Body must contain {"status": "rejected"} — NOT a 4xx HTTP response.
"""
import json
client = APIClient()
resp = client.post(
self.WEBHOOK_URL,
{"notification_type": "upload", "public_id": "test/image"},
format="json",
)
# HTTP 200 is the correct response (prevents retry storms)
self.assertEqual(resp.status_code, 200,
f"Expected 200 for security-rejected webhook: {resp.status_code}")
body = json.loads(resp.content)
self.assertEqual(body.get("status"), "rejected",
f"Expected body.status=rejected: {body}")
def test_tampered_signature_returns_200_with_rejected_status(self):
"""Tampered signature: 200 + rejected body (never 5xx)."""
import json
client = APIClient()
resp = client.post(
self.WEBHOOK_URL,
{"notification_type": "upload", "public_id": "test/image"},
format="json",
HTTP_X_CLd_SIGNATURE="fakesignature123",
HTTP_X_CLd_TIMESTAMP="1234567890",
)
self.assertEqual(resp.status_code, 200)
body = json.loads(resp.content)
self.assertEqual(body.get("status"), "rejected",
f"Expected body.status=rejected: {body}")
def test_webhook_never_returns_500(self):
"""Even with bad payload, webhook must never crash with 500."""
client = APIClient()
malformed_cases = [
{},
{"notification_type": "unknown_type"},
{"public_id": None},
{"deeply": {"nested": {"payload": "here"}}},
]
for payload in malformed_cases:
resp = client.post(self.WEBHOOK_URL, payload, format="json")
self.assertNotEqual(
resp.status_code, 500,
f"Webhook crashed with 500 on payload: {payload}"
)
def test_concurrent_webhook_delivery_no_5xx(self):
"""
Cloudinary retries webhooks on timeout — simulate 20 simultaneous deliveries
of the same event (common in production during transient failures).
None must return 500.
"""
payload = {
"notification_type": "upload",
"public_id": f"fashionistar/avatars/test_{uuid.uuid4().hex}",
"secure_url": "https://res.cloudinary.com/fashionistar/image/upload/test.jpg",
"version": 1,
}
results = {"ok": 0, "errors_5xx": []}
lock = threading.Lock()
N = 20
def _post_webhook(i):
c = APIClient()
resp = c.post(self.WEBHOOK_URL, payload, format="json")
with lock:
if resp.status_code // 100 == 5:
results["errors_5xx"].append(str(resp.status_code))
else:
results["ok"] += 1
with ThreadPoolExecutor(max_workers=N) as pool:
futures = [pool.submit(_post_webhook, i) for i in range(N)]
for f in as_completed(futures):
f.result()
self.assertEqual(
results["errors_5xx"], [],
f"Webhook returned 5xx during concurrent delivery: {results['errors_5xx']}"
)