|
1 | 1 | import unittest |
2 | 2 | from unittest import mock |
| 3 | +import json |
| 4 | +import os |
| 5 | +import subprocess |
| 6 | +import time |
| 7 | +from pathlib import Path |
3 | 8 |
|
4 | 9 | import requests_mock |
5 | 10 | from six.moves import urllib |
|
8 | 13 | from transloadit.client import Transloadit |
9 | 14 |
|
10 | 15 |
|
| 16 | +def get_expected_url(params): |
| 17 | + """Get expected URL from Node.js reference implementation.""" |
| 18 | + if os.getenv('TEST_NODE_PARITY') != '1': |
| 19 | + return None |
| 20 | + |
| 21 | + # Check for tsx before trying to use it |
| 22 | + tsx_path = subprocess.run(['which', 'tsx'], capture_output=True) |
| 23 | + if tsx_path.returncode != 0: |
| 24 | + raise RuntimeError('tsx command not found. Please install it with: npm install -g tsx') |
| 25 | + |
| 26 | + script_path = Path(__file__).parent / 'node-smartcdn-sig.ts' |
| 27 | + json_input = json.dumps(params) |
| 28 | + |
| 29 | + result = subprocess.run( |
| 30 | + ['tsx', str(script_path)], |
| 31 | + input=json_input, |
| 32 | + capture_output=True, |
| 33 | + text=True |
| 34 | + ) |
| 35 | + |
| 36 | + if result.returncode != 0: |
| 37 | + raise RuntimeError(f'Node script failed: {result.stderr}') |
| 38 | + |
| 39 | + return result.stdout.strip() |
| 40 | + |
| 41 | + |
11 | 42 | class ClientTest(unittest.TestCase): |
12 | 43 | def setUp(self): |
13 | 44 | self.transloadit = Transloadit("key", "secret") |
| 45 | + # Use fixed timestamp for all Smart CDN tests |
| 46 | + self.expire_at_ms = 1732550672867 |
| 47 | + |
| 48 | + def assert_parity_with_node(self, url, params, message=''): |
| 49 | + """Assert that our URL matches the Node.js reference implementation.""" |
| 50 | + expected_url = get_expected_url(params) |
| 51 | + if expected_url is not None: |
| 52 | + self.assertEqual(expected_url, url, message or 'URL should match Node.js reference implementation') |
14 | 53 |
|
15 | 54 | @requests_mock.Mocker() |
16 | 55 | def test_get_assembly(self, mock): |
@@ -97,22 +136,130 @@ def test_get_bill(self, mock): |
97 | 136 | self.assertEqual(response.data["ok"], "BILL_FOUND") |
98 | 137 |
|
99 | 138 | def test_get_signed_smart_cdn_url(self): |
100 | | - client = Transloadit("foo_key", "foo_secret") |
| 139 | + """Test Smart CDN URL signing with various scenarios.""" |
| 140 | + client = Transloadit("test-key", "test-secret") |
| 141 | + |
| 142 | + # Test basic URL generation |
| 143 | + params = { |
| 144 | + 'workspace': 'workspace', |
| 145 | + 'template': 'template', |
| 146 | + 'input': 'file.jpg', |
| 147 | + 'auth_key': 'test-key', |
| 148 | + 'auth_secret': 'test-secret', |
| 149 | + 'expire_at_ms': self.expire_at_ms |
| 150 | + } |
| 151 | + |
| 152 | + with mock.patch('time.time', return_value=self.expire_at_ms/1000 - 3600): |
| 153 | + url = client.get_signed_smart_cdn_url( |
| 154 | + params['workspace'], |
| 155 | + params['template'], |
| 156 | + params['input'], |
| 157 | + {}, |
| 158 | + 3600 * 1000 # 1 hour |
| 159 | + ) |
| 160 | + |
| 161 | + expected_url = 'https://workspace.tlcdn.com/template/file.jpg?auth_key=test-key&exp=1732550672867&sig=sha256%3Ad994b8a737db1c43d6e04a07018dc33e8e28b23b27854bd6383d828a212cfffb' |
| 162 | + self.assertEqual(url, expected_url, 'Basic URL should match expected') |
| 163 | + self.assert_parity_with_node(url, params) |
| 164 | + |
| 165 | + # Test with different input field |
| 166 | + params['input'] = 'input.jpg' |
| 167 | + with mock.patch('time.time', return_value=self.expire_at_ms/1000 - 3600): |
| 168 | + url = client.get_signed_smart_cdn_url( |
| 169 | + params['workspace'], |
| 170 | + params['template'], |
| 171 | + params['input'], |
| 172 | + {}, |
| 173 | + 3600 * 1000 |
| 174 | + ) |
| 175 | + |
| 176 | + expected_url = 'https://workspace.tlcdn.com/template/input.jpg?auth_key=test-key&exp=1732550672867&sig=sha256%3A75991f02828d194792c9c99f8fea65761bcc4c62dbb287a84f642033128297c0' |
| 177 | + self.assertEqual(url, expected_url, 'URL with different input should match expected') |
| 178 | + self.assert_parity_with_node(url, params) |
101 | 179 |
|
102 | | - # Freeze time to 2024-05-01T00:00:00.000Z for consistent signatures |
103 | | - with mock.patch('time.time', return_value=1714521600): |
| 180 | + # Test with additional parameters |
| 181 | + params['input'] = 'file.jpg' |
| 182 | + params['url_params'] = {'width': 100} |
| 183 | + with mock.patch('time.time', return_value=self.expire_at_ms/1000 - 3600): |
104 | 184 | url = client.get_signed_smart_cdn_url( |
105 | | - workspace="foo_workspace", |
106 | | - template="foo_template", |
107 | | - input="foo/input", |
108 | | - url_params={ |
109 | | - "foo": "bar", |
110 | | - "aaa": [42, 21] # Should be sorted before `foo` |
111 | | - } |
| 185 | + params['workspace'], |
| 186 | + params['template'], |
| 187 | + params['input'], |
| 188 | + params['url_params'], |
| 189 | + 3600 * 1000 |
112 | 190 | ) |
113 | 191 |
|
114 | | - expected_url = ( |
115 | | - "https://foo_workspace.tlcdn.com/foo_template/foo%2Finput?aaa=42&aaa=21&auth_key=foo_key&exp=1714525200000&foo=bar&sig=sha256%3A9a8df3bb28eea621b46ec808a250b7903b2546be7e66c048956d4f30b8da7519" |
| 192 | + expected_url = 'https://workspace.tlcdn.com/template/file.jpg?auth_key=test-key&exp=1732550672867&width=100&sig=sha256%3Ae5271d8fb6482d9351ebe4285b6fc75539c4d311ff125c4d76d690ad71c258ef' |
| 193 | + self.assertEqual(url, expected_url, 'URL with additional params should match expected') |
| 194 | + self.assert_parity_with_node(url, params) |
| 195 | + |
| 196 | + # Test with empty parameter string |
| 197 | + params['url_params'] = {'width': '', 'height': '200'} |
| 198 | + with mock.patch('time.time', return_value=self.expire_at_ms/1000 - 3600): |
| 199 | + url = client.get_signed_smart_cdn_url( |
| 200 | + params['workspace'], |
| 201 | + params['template'], |
| 202 | + params['input'], |
| 203 | + params['url_params'], |
| 204 | + 3600 * 1000 |
| 205 | + ) |
| 206 | + |
| 207 | + expected_url = 'https://workspace.tlcdn.com/template/file.jpg?auth_key=test-key&exp=1732550672867&height=200&width=&sig=sha256%3A1a26733c859f070bc3d83eb3174650d7a0155642e44a5ac448a43bc728bc0f85' |
| 208 | + self.assertEqual(url, expected_url, 'URL with empty param should match expected') |
| 209 | + self.assert_parity_with_node(url, params) |
| 210 | + |
| 211 | + # Test with null parameter (should be excluded) |
| 212 | + params['url_params'] = {'width': None, 'height': '200'} |
| 213 | + with mock.patch('time.time', return_value=self.expire_at_ms/1000 - 3600): |
| 214 | + url = client.get_signed_smart_cdn_url( |
| 215 | + params['workspace'], |
| 216 | + params['template'], |
| 217 | + params['input'], |
| 218 | + params['url_params'], |
| 219 | + 3600 * 1000 |
| 220 | + ) |
| 221 | + |
| 222 | + expected_url = 'https://workspace.tlcdn.com/template/file.jpg?auth_key=test-key&exp=1732550672867&height=200&sig=sha256%3Adb740ebdfad6e766ebf6516ed5ff6543174709f8916a254f8d069c1701cef517' |
| 223 | + self.assertEqual(url, expected_url, 'URL with null param should match expected') |
| 224 | + self.assert_parity_with_node(url, params) |
| 225 | + |
| 226 | + # Test with only empty parameter |
| 227 | + params['url_params'] = {'width': ''} |
| 228 | + with mock.patch('time.time', return_value=self.expire_at_ms/1000 - 3600): |
| 229 | + url = client.get_signed_smart_cdn_url( |
| 230 | + params['workspace'], |
| 231 | + params['template'], |
| 232 | + params['input'], |
| 233 | + params['url_params'], |
| 234 | + 3600 * 1000 |
| 235 | + ) |
| 236 | + |
| 237 | + expected_url = 'https://workspace.tlcdn.com/template/file.jpg?auth_key=test-key&exp=1732550672867&width=&sig=sha256%3A840426f9ac72dde02fd080f09b2304d659fdd41e630b1036927ec1336c312e9d' |
| 238 | + self.assertEqual(url, expected_url, 'URL with only empty param should match expected') |
| 239 | + self.assert_parity_with_node(url, params) |
| 240 | + |
| 241 | + # Test default expiry (should be about 1 hour from now) |
| 242 | + params['url_params'] = {} |
| 243 | + del params['expire_at_ms'] |
| 244 | + now = time.time() |
| 245 | + url = client.get_signed_smart_cdn_url( |
| 246 | + params['workspace'], |
| 247 | + params['template'], |
| 248 | + params['input'] |
116 | 249 | ) |
117 | | - |
118 | | - self.assertEqual(url, expected_url) |
| 250 | + |
| 251 | + import re |
| 252 | + match = re.search(r'exp=(\d+)', url) |
| 253 | + self.assertIsNotNone(match, 'URL should contain expiry timestamp') |
| 254 | + |
| 255 | + expiry = int(match.group(1)) |
| 256 | + now_ms = int(now * 1000) |
| 257 | + one_hour = 60 * 60 * 1000 |
| 258 | + |
| 259 | + self.assertGreater(expiry, now_ms, 'Expiry should be in the future') |
| 260 | + self.assertLess(expiry, now_ms + one_hour + 5000, 'Expiry should be about 1 hour from now') |
| 261 | + self.assertGreater(expiry, now_ms + one_hour - 5000, 'Expiry should be about 1 hour from now') |
| 262 | + |
| 263 | + # For parity test, set the exact expiry time to match Node.js |
| 264 | + params['expire_at_ms'] = expiry |
| 265 | + self.assert_parity_with_node(url, params) |
0 commit comments