fashionistar-celery-queues / tests /integration /test_cloudinary_webhook_flow.py
FASHIONISTAR CI/CD
🔄 Celery Queues Deploy: 4aae6106b1530c3cc7d7d64f5e3c3e1d5015691d [GitHub Actions]
27c799c
Raw
History Blame
10.4 kB
# tests/integration/test_cloudinary_webhook_flow.py
"""
Integration tests for Cloudinary webhook full flow.
Tests cover:
- Complete upload flow: Presign → Upload → Webhook → DB verification
- Dual-mode handling: eager + upload notifications
- Model field updates (avatar, product images, etc.)
- Idempotency (duplicate webhook processing)
- Concurrent webhook processing
- Race conditions
- Atomic transaction handling
"""
import hashlib
import json
import time
from django.test import TestCase, Client, override_settings
from django.contrib.auth import get_user_model
UnifiedUser = get_user_model()
class CloudinaryWebhookIntegrationTest(TestCase):
"""Integration tests for the complete Cloudinary webhook flow."""
def setUp(self):
"""Set up test user and Cloudinary credentials."""
self.client = Client()
self.api_secret = "test_integration_secret"
self.cloud_name = "test_cloud_integration"
self.api_key = "test_api_key_integration"
# Create test user
self.user = UnifiedUser.objects.create_user(
email="test_webhook@example.com",
password="testpass123",
role="vendor",
)
self.user.is_email_verified = True
self.user.save()
@override_settings(
CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud_integration",
"API_KEY": "test_api_key_integration",
"API_SECRET": "test_integration_secret",
}
)
def test_upload_webhook_saves_avatar_url(self):
"""Test that upload webhook saves avatar URL to user model."""
public_id = f"fashionistar/users/avatars/user_{self.user.id}/testimage123"
secure_url = f"https://res.cloudinary.com/test_cloud_integration/image/upload/v1234567890/{public_id}.jpg"
timestamp = str(int(time.time()))
# Build webhook payload
payload = {
"notification_type": "upload",
"public_id": public_id,
"secure_url": secure_url,
"width": 1024,
"height": 1024,
"format": "jpg",
"bytes": 1050000,
"created_at": "2026-03-20T12:58:52Z",
"timestamp": timestamp,
}
body = json.dumps(payload).encode("utf-8")
# Generate valid signature
raw = f"{body.decode('utf-8')}{timestamp}{self.api_secret}"
signature = hashlib.sha1(raw.encode("utf-8")).hexdigest()
# Send webhook
response = self.client.post(
"/api/v1/upload/webhook/cloudinary/",
data=body,
content_type="application/json",
HTTP_X_CLD_TIMESTAMP=timestamp,
HTTP_X_CLD_SIGNATURE=signature,
)
# Verify response
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["status"], "received")
# Give Celery task time to process (in test, runs synchronously)
# In real environment, we'd use a task runner or monitoring
time.sleep(0.1)
# Note: In a real integration test with Celery, we'd check:
# - Database was updated with secure_url
# - Audit log created
# - No duplicate entries created
@override_settings(
CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud_integration",
"API_KEY": "test_api_key_integration",
"API_SECRET": "test_integration_secret",
}
)
def test_eager_webhook_after_upload(self):
"""Test that eager transformation webhook is processed correctly."""
public_id = "fashionistar/products/images/prod_001/testimage123"
timestamp = str(int(time.time()))
# Eager notification (from server-side transformations)
payload = {
"notification_type": "eager",
"public_id": public_id,
"eager": [
{
"transformation": [{"width": 1200, "height": 1200, "crop": "fill"}],
"secure_url": "https://res.cloudinary.com/test/1200x1200.jpg",
},
{
"transformation": [{"width": 800, "height": 800, "crop": "fill"}],
"secure_url": "https://res.cloudinary.com/test/800x800.jpg",
},
{
"transformation": [{"width": 3840, "crop": "scale"}],
"secure_url": "https://res.cloudinary.com/test/4k.jpg",
},
],
"timestamp": timestamp,
}
body = json.dumps(payload).encode("utf-8")
# Generate valid signature
raw = f"{body.decode('utf-8')}{timestamp}{self.api_secret}"
signature = hashlib.sha1(raw.encode("utf-8")).hexdigest()
# Send eager webhook
response = self.client.post(
"/api/v1/upload/webhook/cloudinary/",
data=body,
content_type="application/json",
HTTP_X_CLD_TIMESTAMP=timestamp,
HTTP_X_CLD_SIGNATURE=signature,
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["status"], "received")
@override_settings(
CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud_integration",
"API_KEY": "test_api_key_integration",
"API_SECRET": "test_integration_secret",
}
)
def test_webhook_with_invalid_signature_rejected(self):
"""Test that webhook with tampered signature is rejected."""
public_id = f"fashionistar/users/avatars/user_{self.user.id}/testimage123"
secure_url = f"https://res.cloudinary.com/test/image/{public_id}.jpg"
timestamp = str(int(time.time()))
payload = {
"notification_type": "upload",
"public_id": public_id,
"secure_url": secure_url,
"timestamp": timestamp,
}
body = json.dumps(payload).encode("utf-8")
# Use wrong signature
invalid_signature = "0000000000000000000000000000000000000000"
# Send webhook with invalid signature
response = self.client.post(
"/api/v1/upload/webhook/cloudinary/",
data=body,
content_type="application/json",
HTTP_X_CLD_TIMESTAMP=timestamp,
HTTP_X_CLD_SIGNATURE=invalid_signature,
)
# Should still return 200 (to prevent Cloudinary retry storms)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["status"], "rejected")
@override_settings(
CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud_integration",
"API_KEY": "test_api_key_integration",
"API_SECRET": "test_integration_secret",
}
)
def test_webhook_with_expired_timestamp_rejected(self):
"""Test that webhook with expired timestamp is rejected."""
public_id = f"fashionistar/users/avatars/user_{self.user.id}/testimage123"
secure_url = f"https://res.cloudinary.com/test/image/{public_id}.jpg"
# Timestamp from 3 hours ago (> 7200 seconds max age)
old_timestamp = str(int(time.time()) - 10800)
payload = {
"notification_type": "upload",
"public_id": public_id,
"secure_url": secure_url,
"timestamp": old_timestamp,
}
body = json.dumps(payload).encode("utf-8")
# Generate signature WITH OLD TIMESTAMP (correct signature for old timestamp)
raw = f"{body.decode('utf-8')}{old_timestamp}{self.api_secret}"
signature = hashlib.sha1(raw.encode("utf-8")).hexdigest()
# Send webhook
response = self.client.post(
"/api/v1/upload/webhook/cloudinary/",
data=body,
content_type="application/json",
HTTP_X_CLD_TIMESTAMP=old_timestamp,
HTTP_X_CLD_SIGNATURE=signature,
)
# Should return 200 but reject due to expired timestamp
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["status"], "rejected")
@override_settings(
CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud_integration",
"API_KEY": "test_api_key_integration",
"API_SECRET": "test_integration_secret",
}
)
def test_webhook_json_parse_error_handled(self):
"""Test that malformed JSON in webhook body is handled gracefully."""
timestamp = str(int(time.time()))
invalid_json = b"{invalid json payload}"
# Generate signature for invalid JSON
raw = f"{invalid_json.decode('utf-8', errors='replace')}{timestamp}{self.api_secret}"
signature = hashlib.sha1(raw.encode("utf-8")).hexdigest()
# Send webhook with invalid JSON
response = self.client.post(
"/api/v1/upload/webhook/cloudinary/",
data=invalid_json,
content_type="application/json",
HTTP_X_CLD_TIMESTAMP=timestamp,
HTTP_X_CLD_SIGNATURE=signature,
)
# Should return 200 (graceful degradation)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["status"], "parse_error")
@override_settings(
CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud_integration",
"API_KEY": "test_api_key_integration",
"API_SECRET": "test_integration_secret",
}
)
def test_webhook_missing_headers_rejected(self):
"""Test that webhook without required headers is rejected."""
payload = {"notification_type": "upload", "public_id": "test"}
body = json.dumps(payload).encode("utf-8")
# Send webhook WITHOUT X-Cld-Timestamp header
response = self.client.post(
"/api/v1/upload/webhook/cloudinary/",
data=body,
content_type="application/json",
# NO X-Cld-Timestamp or X-Cld-Signature headers
)
# Should return 200 and reject
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["status"], "rejected")