# 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")