Skip to content

Commit 65d5f90

Browse files
committed
Add Node reference implementation parity testing in CI
1 parent de551fd commit 65d5f90

File tree

3 files changed

+181
-24
lines changed

3 files changed

+181
-24
lines changed

.github/workflows/ci.yml

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,23 @@ jobs:
88
strategy:
99
matrix:
1010
os: [ubuntu-latest, windows-latest]
11-
python-version: ["3.9", "3.10", "3.11", "3.12"]
11+
python-version: ['3.9', '3.10', '3.11', '3.12']
1212

1313
steps:
14-
- uses: actions/checkout@v3
14+
- uses: actions/checkout@v4
15+
16+
- uses: actions/setup-node@v4
17+
with:
18+
node-version: '20'
19+
- name: Install tsx
20+
run: npm install -g tsx
1521

1622
- name: Set up Python
1723
uses: actions/setup-python@v4
1824
with:
1925
python-version: ${{ matrix.python-version }}
2026
architecture: x64
21-
cache: "pip"
27+
cache: 'pip'
2228

2329
- name: Install Poetry manager
2430
run: pip install --upgrade poetry
@@ -29,3 +35,5 @@ jobs:
2935
- name: Test with pytest
3036
run: |
3137
poetry run pytest --cov=transloadit tests
38+
env:
39+
TEST_NODE_PARITY: 1

tests/test_client.py

Lines changed: 161 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import unittest
22
from unittest import mock
3+
import json
4+
import os
5+
import subprocess
6+
import time
7+
from pathlib import Path
38

49
import requests_mock
510
from six.moves import urllib
@@ -8,9 +13,43 @@
813
from transloadit.client import Transloadit
914

1015

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+
1142
class ClientTest(unittest.TestCase):
1243
def setUp(self):
1344
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')
1453

1554
@requests_mock.Mocker()
1655
def test_get_assembly(self, mock):
@@ -97,22 +136,130 @@ def test_get_bill(self, mock):
97136
self.assertEqual(response.data["ok"], "BILL_FOUND")
98137

99138
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)
101179

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):
104184
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
112190
)
113191

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']
116249
)
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)

transloadit/client.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -172,13 +172,13 @@ def get_bill(self, month: int, year: int):
172172
Return an instance of <transloadit.response.Response>
173173
"""
174174
return self.request.get(f"/bill/{year}-{month:02d}")
175-
175+
176176
def get_signed_smart_cdn_url(
177177
self,
178178
workspace: str,
179179
template: str,
180180
input: str,
181-
url_params: Optional[dict[str, Union[str, int, float, bool, List[Union[str, int, float, bool]]]]] = None,
181+
url_params: Optional[dict[str, Union[str, int, float, bool, List[Union[str, int, float, bool]], None]]] = None,
182182
expires_in: Optional[int] = 60 * 60 * 1000 # 1 hour
183183
) -> str:
184184
"""
@@ -189,14 +189,14 @@ def get_signed_smart_cdn_url(
189189
- workspace (str): Workspace slug
190190
- template (str): Template slug or template ID
191191
- input (str): Input value that is provided as ${fields.input} in the template
192-
- url_params (Optional[dict]): Additional parameters for the URL query string. Values can be strings, numbers, booleans or arrays thereof.
192+
- url_params (Optional[dict]): Additional parameters for the URL query string. Values can be strings, numbers, booleans, arrays thereof, or None.
193193
- expires_in (Optional[int]): Expiration time of signature in milliseconds. Defaults to 1 hour.
194194
195195
:Returns:
196196
str: The signed Smart CDN URL
197197
198198
:Raises:
199-
ValueError: If url_params contains values that are not strings, numbers, booleans or arrays
199+
ValueError: If url_params contains values that are not strings, numbers, booleans, arrays, or None
200200
"""
201201
workspace_slug = quote_plus(workspace)
202202
template_slug = quote_plus(template)
@@ -205,12 +205,14 @@ def get_signed_smart_cdn_url(
205205
params = []
206206
if url_params:
207207
for k, v in url_params.items():
208-
if isinstance(v, (str, int, float, bool)):
208+
if v is None:
209+
continue # Skip None values
210+
elif isinstance(v, (str, int, float, bool)):
209211
params.append((k, str(v)))
210212
elif isinstance(v, (list, tuple)):
211213
params.append((k, [str(vv) for vv in v]))
212214
else:
213-
raise ValueError(f"URL parameter values must be strings, numbers, booleans or arrays. Got {type(v)} for {k}")
215+
raise ValueError(f"URL parameter values must be strings, numbers, booleans, arrays, or None. Got {type(v)} for {k}")
214216

215217
params.append(("auth_key", self.auth_key))
216218
params.append(("exp", str(int(time.time() * 1000) + expires_in)))
@@ -221,7 +223,7 @@ def get_signed_smart_cdn_url(
221223

222224
string_to_sign = f"{workspace_slug}/{template_slug}/{input_field}?{query_string}"
223225
algorithm = "sha256"
224-
226+
225227
signature = algorithm + ":" + hmac.new(
226228
self.auth_secret.encode("utf-8"),
227229
string_to_sign.encode("utf-8"),

0 commit comments

Comments
 (0)