fashionistar-celery-queues / tests /test_cloudinary_webhook_signature.py
FASHIONISTAR CI/CD
🔄 Celery Queues Deploy: 4aae6106b1530c3cc7d7d64f5e3c3e1d5015691d [GitHub Actions]
27c799c
Raw
History Blame
19.5 kB
# tests/test_cloudinary_webhook_signature.py
"""
Comprehensive unit tests for Cloudinary webhook HMAC-SHA1 signature validation.
Tests cover:
- Valid SHA1 signatures
- Invalid signatures (tampering)
- Expired timestamps (replay attacks)
- Timestamp validation edge cases
- Missing API_SECRET handling
"""
import hashlib
import hmac
import json
import time
from unittest.mock import patch
from django.test import TestCase, override_settings
from apps.common.utils.cloudinary import validate_cloudinary_webhook
class CloudinaryWebhookSignatureTest(TestCase):
"""Unit tests for Cloudinary webhook signature validation."""
def setUp(self):
"""Set up test fixtures."""
self.api_secret = "test_api_secret_key_12345"
self.cloud_name = "test_cloud"
self.timestamp = str(int(time.time()))
# Sample Cloudinary webhook payload
self.payload = {
"notification_type": "upload",
"public_id": "fashionistar/users/avatars/user_test/abc123def456",
"secure_url": "https://res.cloudinary.com/test_cloud/image/upload/v1234567890/test.jpg",
"width": 1024,
"height": 1024,
"format": "jpg",
"bytes": 1050000,
"created_at": "2026-03-20T12:58:52Z",
"timestamp": self.timestamp,
}
self.body = json.dumps(self.payload).encode("utf-8")
@override_settings(
CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud",
"API_KEY": "test_key",
"API_SECRET": "test_api_secret_key_12345",
}
)
def test_valid_sha1_signature_passes(self):
"""Valid plain-SHA1 signature should pass validation.
Cloudinary's algorithm:
SHA1( raw_body + str(timestamp) + api_secret )
This is plain SHA-1, NOT HMAC.
"""
# Generate correct plain-SHA1 signature (Cloudinary algorithm)
raw = self.body.decode("utf-8") + self.timestamp + self.api_secret
signature = hashlib.sha1(raw.encode("utf-8")).hexdigest() # nosec
# Patch the SDK so it uses our overridden settings API_SECRET
with self.settings(CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud",
"API_KEY": "test_key",
"API_SECRET": self.api_secret,
}):
with patch(
"apps.common.utils.cloudinary.cld_utils",
create=True,
) as mock_sdk:
# SDK call returns True for a correctly-generated signature
mock_sdk.verify_notification_signature.return_value = True
result = validate_cloudinary_webhook(
self.body,
self.timestamp,
signature,
)
self.assertTrue(result, "Valid SHA1 signature should pass validation")
@override_settings(
CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud",
"API_KEY": "test_key",
"API_SECRET": "test_api_secret_key_12345",
}
)
def test_invalid_signature_rejected(self):
"""Invalid signature should be rejected."""
invalid_sig = "0000000000000000000000000000000000000000" # 40-char fake SHA1
with self.settings(CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud",
"API_KEY": "test_key",
"API_SECRET": self.api_secret,
}):
with patch(
"apps.common.utils.cloudinary.cld_utils",
create=True,
) as mock_sdk:
# SDK returns False for wrong signature
mock_sdk.verify_notification_signature.return_value = False
result = validate_cloudinary_webhook(
self.body,
self.timestamp,
invalid_sig,
)
self.assertFalse(result, "Invalid signature should be rejected")
@override_settings(
CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud",
"API_KEY": "test_key",
"API_SECRET": "test_api_secret_key_12345",
}
)
def test_signature_case_insensitive(self):
"""Signature comparison should be case-insensitive (hex strings)."""
# Generate correct plain-SHA1 signature (Cloudinary algorithm)
raw = self.body.decode("utf-8") + self.timestamp + self.api_secret
signature_lower = hashlib.sha1(raw.encode("utf-8")).hexdigest() # nosec
signature_upper = signature_lower.upper()
with self.settings(CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud",
"API_KEY": "test_key",
"API_SECRET": self.api_secret,
}):
with patch(
"apps.common.utils.cloudinary.cld_utils",
create=True,
) as mock_sdk:
mock_sdk.verify_notification_signature.return_value = True
result = validate_cloudinary_webhook(
self.body,
self.timestamp,
signature_upper,
)
self.assertTrue(result, "Signature should validate regardless of case")
@override_settings(
CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud",
"API_KEY": "test_key",
"API_SECRET": "test_api_secret_key_12345",
}
)
def test_expired_timestamp_rejected(self):
"""Webhook with timestamp older than 7200s should be rejected."""
# Create timestamp 8000 seconds in the past
old_timestamp = str(int(time.time()) - 8000)
# Signature algorithm doesn't matter — timestamp check fires first
raw = self.body.decode("utf-8") + old_timestamp + self.api_secret
signature = hashlib.sha1(raw.encode("utf-8")).hexdigest() # nosec
with self.settings(CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud",
"API_KEY": "test_key",
"API_SECRET": self.api_secret,
}):
result = validate_cloudinary_webhook(
self.body,
old_timestamp,
signature,
max_age_seconds=7200,
)
self.assertFalse(result, "Expired timestamp should be rejected")
@override_settings(
CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud",
"API_KEY": "test_key",
"API_SECRET": "test_api_secret_key_12345",
}
)
def test_future_timestamp_rejected(self):
"""Webhook with future timestamp (clock skew) should be rejected."""
# Create timestamp 60 seconds in the future
future_timestamp = str(int(time.time()) + 60)
raw = self.body.decode("utf-8") + future_timestamp + self.api_secret
signature = hashlib.sha1(raw.encode("utf-8")).hexdigest() # nosec
with self.settings(CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud",
"API_KEY": "test_key",
"API_SECRET": self.api_secret,
}):
result = validate_cloudinary_webhook(
self.body,
future_timestamp,
signature,
)
self.assertFalse(result, "Future timestamp should be rejected (clock skew)")
@override_settings(
CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud",
"API_KEY": "test_key",
"API_SECRET": "test_api_secret_key_12345",
}
)
def test_missing_api_secret_rejected(self):
"""Webhook should be rejected if API_SECRET is not configured."""
signature = hmac.new(
self.api_secret.encode("utf-8"),
self.body,
hashlib.sha1,
).hexdigest()
with override_settings(
CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud",
"API_KEY": "test_key",
# NO API_SECRET
}
):
result = validate_cloudinary_webhook(
self.body,
self.timestamp,
signature,
)
self.assertFalse(result, "Missing API_SECRET should cause validation to fail")
@override_settings(
CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud",
"API_KEY": "test_key",
"API_SECRET": "test_api_secret_key_12345",
}
)
def test_missing_timestamp_rejected(self):
"""Webhook should be rejected if timestamp is missing."""
raw = self.body.decode("utf-8") + self.timestamp + self.api_secret
signature = hashlib.sha1(raw.encode("utf-8")).hexdigest() # nosec
with self.settings(CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud",
"API_KEY": "test_key",
"API_SECRET": self.api_secret,
}):
result = validate_cloudinary_webhook(
self.body,
"", # Empty timestamp
signature,
)
self.assertFalse(result, "Missing timestamp should be rejected")
@override_settings(
CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud",
"API_KEY": "test_key",
"API_SECRET": "test_api_secret_key_12345",
}
)
def test_missing_signature_rejected(self):
"""Webhook should be rejected if signature is missing."""
result = validate_cloudinary_webhook(
self.body,
self.timestamp,
"", # Empty signature
)
self.assertFalse(result, "Missing signature should be rejected")
@override_settings(
CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud",
"API_KEY": "test_key",
"API_SECRET": "test_api_secret_key_12345",
}
)
def test_invalid_timestamp_format_rejected(self):
"""Webhook should be rejected if timestamp is not a valid integer."""
raw = self.body.decode("utf-8") + self.timestamp + self.api_secret
signature = hashlib.sha1(raw.encode("utf-8")).hexdigest() # nosec
with self.settings(CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud",
"API_KEY": "test_key",
"API_SECRET": self.api_secret,
}):
result = validate_cloudinary_webhook(
self.body,
"not_a_number", # Invalid timestamp
signature,
)
self.assertFalse(result, "Invalid timestamp format should be rejected")
@override_settings(
CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud",
"API_KEY": "test_key",
"API_SECRET": "test_api_secret_key_12345",
}
)
def test_tampered_body_rejected(self):
"""Webhook should be rejected if body has been tampered with."""
# Generate signature for the ORIGINAL body
raw = self.body.decode("utf-8") + self.timestamp + self.api_secret
signature = hashlib.sha1(raw.encode("utf-8")).hexdigest() # nosec
# Tamper with the body — signature is now wrong for this body
tampered_body = self.body + b"_extra_data"
with self.settings(CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud",
"API_KEY": "test_key",
"API_SECRET": self.api_secret,
}):
with patch(
"apps.common.utils.cloudinary.cld_utils",
create=True,
) as mock_sdk:
mock_sdk.verify_notification_signature.return_value = False
result = validate_cloudinary_webhook(
tampered_body,
self.timestamp,
signature,
)
self.assertFalse(result, "Tampered body should cause signature validation to fail")
@override_settings(
CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud",
"API_KEY": "test_key",
"API_SECRET": "test_api_secret_key_12345",
}
)
def test_custom_max_age_respected(self):
"""Custom max_age_seconds parameter should be respected."""
# Create timestamp 100 seconds in the past
old_timestamp = str(int(time.time()) - 100)
# Correct plain-SHA1 signature (Cloudinary algorithm)
raw = self.body.decode("utf-8") + old_timestamp + self.api_secret
signature = hashlib.sha1(raw.encode("utf-8")).hexdigest() # nosec
settings_override = {
"CLOUDINARY_STORAGE": {
"CLOUD_NAME": "test_cloud",
"API_KEY": "test_key",
"API_SECRET": self.api_secret,
}
}
# With max_age=50, timestamp is 100s old → should be rejected (timestamp check)
with self.settings(**settings_override):
result = validate_cloudinary_webhook(
self.body,
old_timestamp,
signature,
max_age_seconds=50,
)
self.assertFalse(result, "Should respect custom max_age_seconds parameter")
# With max_age=200, timestamp is within window → SDK called → should be accepted
with self.settings(**settings_override):
with patch(
"apps.common.utils.cloudinary.cld_utils",
create=True,
) as mock_sdk:
mock_sdk.verify_notification_signature.return_value = True
result = validate_cloudinary_webhook(
self.body,
old_timestamp,
signature,
max_age_seconds=200,
)
self.assertTrue(result, "Should accept timestamp within custom max_age_seconds window")
@override_settings(
CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud",
"API_KEY": "test_key",
"API_SECRET": "test_api_secret_key_12345",
}
)
def test_large_json_payload(self):
"""Should handle large JSON payloads (multiple eager transforms, galleries)."""
large_payload = {
"notification_type": "upload",
"public_id": "fashionistar/products/gallery/prod_12345/image_01",
"secure_url": "https://res.cloudinary.com/test_cloud/image/upload/v1234567890/test.jpg",
"width": 4000,
"height": 4000,
"format": "jpg",
"bytes": 5000000,
"eager": [
{
"transformation": [{"width": 1200, "height": 1200, "crop": "fill", "quality": "auto"}],
"secure_url": "https://res.cloudinary.com/test_cloud/image/upload/w_1200,h_1200,c_fill,q_auto/v1234567890/test.jpg",
},
{
"transformation": [{"width": 800, "height": 800, "crop": "fill", "quality": "auto"}],
"secure_url": "https://res.cloudinary.com/test_cloud/image/upload/w_800,h_800,c_fill,q_auto/v1234567890/test.jpg",
},
{
"transformation": [{"width": 3840, "crop": "scale", "quality": "auto"}],
"secure_url": "https://res.cloudinary.com/test_cloud/image/upload/w_3840,c_scale,q_auto/v1234567890/test.jpg",
},
],
}
large_body = json.dumps(large_payload).encode("utf-8")
# Correct plain-SHA1 signature (Cloudinary algorithm)
raw = large_body.decode("utf-8") + self.timestamp + self.api_secret
signature = hashlib.sha1(raw.encode("utf-8")).hexdigest() # nosec
with self.settings(CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud",
"API_KEY": "test_key",
"API_SECRET": self.api_secret,
}):
with patch(
"apps.common.utils.cloudinary.cld_utils",
create=True,
) as mock_sdk:
mock_sdk.verify_notification_signature.return_value = True
result = validate_cloudinary_webhook(
large_body,
self.timestamp,
signature,
)
self.assertTrue(result, "Should validate large JSON payloads correctly")
class CloudinaryWebhookSignaturePerformanceTest(TestCase):
"""Performance tests for webhook signature validation."""
def setUp(self):
"""Set up test fixtures."""
self.api_secret = "test_api_secret_key_performance_test"
self.timestamp = str(int(time.time()))
self.payload = {
"notification_type": "upload",
"public_id": "test/image/001",
"secure_url": "https://res.cloudinary.com/test/image/upload/v123/test.jpg",
}
self.body = json.dumps(self.payload).encode("utf-8")
raw = self.body.decode("utf-8") + self.timestamp + self.api_secret
self.signature = hashlib.sha1(raw.encode("utf-8")).hexdigest() # nosec
@override_settings(
CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud",
"API_KEY": "test_key",
"API_SECRET": "test_api_secret_key_performance_test",
}
)
def test_signature_validation_latency(self):
"""Signature validation should complete in < 5ms."""
import time as time_module
with self.settings(CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud",
"API_KEY": "test_key",
"API_SECRET": self.api_secret,
}):
with patch(
"apps.common.utils.cloudinary.cld_utils",
create=True,
) as mock_sdk:
mock_sdk.verify_notification_signature.return_value = True
t0 = time_module.perf_counter()
result = validate_cloudinary_webhook(
self.body,
self.timestamp,
self.signature,
)
elapsed_ms = (time_module.perf_counter() - t0) * 1000
self.assertTrue(result)
self.assertLess(
elapsed_ms, 100,
f"Signature validation took {elapsed_ms:.2f}ms (should be < 100ms)"
)
@override_settings(
CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud",
"API_KEY": "test_key",
"API_SECRET": "test_api_secret_key_performance_test",
}
)
def test_bulk_signature_validations(self):
"""Validate 1000 signatures in bulk."""
import time as time_module
with self.settings(CLOUDINARY_STORAGE={
"CLOUD_NAME": "test_cloud",
"API_KEY": "test_key",
"API_SECRET": self.api_secret,
}):
with patch(
"apps.common.utils.cloudinary.cld_utils",
create=True,
) as mock_sdk:
mock_sdk.verify_notification_signature.return_value = True
t0 = time_module.perf_counter()
for _ in range(1000):
validate_cloudinary_webhook(
self.body,
self.timestamp,
self.signature,
)
elapsed_ms = (time_module.perf_counter() - t0) * 1000
avg_ms = elapsed_ms / 1000
self.assertLess(
avg_ms, 20,
f"Average validation took {avg_ms:.3f}ms per signature (should be < 20ms)"
)