From a3d8049ae1170494bf2209dd0a58fc57f31139e3 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 Nov 2024 11:10:22 +0100 Subject: [PATCH] Add Smart CDN Signature Auth helper (#71) * Create ruby-sdk.code-workspace * Update README.md * Update test_assembly.rb * Create smartcdn-sig.ts * wip * Wip * Create Makefile * Update smartcdn-sig.ts * Cleanup * Move to tl instance * Wip * Inline smart cdn method * Readme * More comprehensive node comparison * Make no tsx fatal for TEST_NODE_PARITY * Update ci.yml * Document test flags * Support empty input * Update ruby-sdk.code-workspace * Assert empty string param behavior * Make empty string param show up in url * Update test_smart_cdn_node_parity.rb * Harcode urls * Merge parity testing into regular suite * Retire expireIn * Remove ability to override auth keys * Fix: redundant self * Fix linting * Fixes * Validate both implementations against hardcoded URL * Update lib/transloadit.rb Co-authored-by: Robin Mehner * Add flat_map. Thx @rmehner! Co-Authored-By: Robin Mehner * Update transloadit.rb Co-Authored-By: Robin Mehner * Ensure workspace and template can't be empty strings --------- Co-authored-by: Robin Mehner --- .github/workflows/ci.yml | 19 ++- .gitignore | 3 +- .vscode/ruby-sdk.code-workspace | 13 ++ Makefile | 18 +++ README.md | 144 +++++++++++++----- lib/transloadit.rb | 53 +++++++ test/test_helper.rb | 2 +- test/unit/transloadit/node-smartcdn-sig.ts | 89 +++++++++++ test/unit/transloadit/test_smart_cdn.rb | 169 +++++++++++++++++++++ 9 files changed, 460 insertions(+), 50 deletions(-) create mode 100644 .vscode/ruby-sdk.code-workspace create mode 100644 Makefile create mode 100644 test/unit/transloadit/node-smartcdn-sig.ts create mode 100644 test/unit/transloadit/test_smart_cdn.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df4591b..71b0d53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,12 @@ name: CI on: push: - branches: [ main ] + branches: + - main pull_request: - branches: [ main ] + types: + - opened + - synchronize jobs: ci: runs-on: ubuntu-latest @@ -13,15 +16,19 @@ jobs: - 3.0 - 3.1 - 3.2 + - 3.3 - jruby - # - jruby-head - truffleruby - # - truffleruby-head + fail-fast: false steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm install -g tsx - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - run: bundle exec standardrb - - run: COVERAGE=false bundle exec rake test + - run: COVERAGE=0 TEST_NODE_PARITY=1 bundle exec rake test diff --git a/.gitignore b/.gitignore index ce5d33d..7397957 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,5 @@ transloadit-*.gem .DS_Store env.sh - -# Bundle directory +.env vendor/bundle/ diff --git a/.vscode/ruby-sdk.code-workspace b/.vscode/ruby-sdk.code-workspace new file mode 100644 index 0000000..ee9fe3c --- /dev/null +++ b/.vscode/ruby-sdk.code-workspace @@ -0,0 +1,13 @@ +{ + "folders": [ + { + "path": ".." + } + ], + "settings": { + "workbench.colorCustomizations": { + "titleBar.activeForeground": "#ffffff", + "titleBar.activeBackground": "#cc0000" + }, + } +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..03f7d67 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +.PHONY: all test fix + +all: fix test + +# Run tests +test: + bundle exec rake test + +# Fix code formatting +fix: + bundle exec standardrb --fix + +# Install dependencies +install: + bundle install + +# Run both fix and test +check: fix test diff --git a/README.md b/README.md index b5ec516..09f0742 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A **Ruby** Integration for [Transloadit](https://transloadit.com)'s file uploadi This is a **Ruby** SDK to make it easy to talk to the [Transloadit](https://transloadit.com) REST API. -*If you run Ruby on Rails and are looking to integrate with the browser for file uploads, checkout the [rails-sdk](https://github.com/transloadit/rails-sdk).* +_If you run Ruby on Rails and are looking to integrate with the browser for file uploads, checkout the [rails-sdk](https://github.com/transloadit/rails-sdk)._ ## Install @@ -29,14 +29,14 @@ $ irb -rubygems => true ``` -Then create a Transloadit instance, which will maintain your -[authentication credentials](https://transloadit.com/accounts/credentials) +Then create a Transloadit instance, which will maintain your +[authentication credentials](https://transloadit.com/accounts/credentials) and allow us to make requests to [the API](https://transloadit.com/docs/api/). ```ruby transloadit = Transloadit.new( - :key => 'YOUR_TRANSLOADIT_KEY', - :secret => 'YOUR_TRANSLOADIT_SECRET' + :key => 'MY_TRANSLOADIT_KEY', + :secret => 'MY_TRANSLOADIT_SECRET' ) ``` @@ -49,8 +49,8 @@ and store the result on [Amazon S3](https://aws.amazon.com/s3/). require 'transloadit' transloadit = Transloadit.new( - :key => 'YOUR_TRANSLOADIT_KEY', - :secret => 'YOUR_TRANSLOADIT_SECRET' + :key => 'MY_TRANSLOADIT_KEY', + :secret => 'MY_TRANSLOADIT_SECRET' ) # First, we create two steps: one to resize the image to 320x240, and another to @@ -60,9 +60,9 @@ resize = transloadit.step 'resize', '/image/resize', :height => 240 store = transloadit.step 'store', '/s3/store', - :key => 'YOUR_AWS_KEY', - :secret => 'YOUR_AWS_SECRET', - :bucket => 'YOUR_S3_BUCKET' + :key => 'MY_AWS_KEY', + :secret => 'MY_AWS_SECRET', + :bucket => 'MY_S3_BUCKET' # Now that we have the steps, we create an assembly (which is just a request to # process a file or set of files) and let Transloadit do the rest. @@ -125,7 +125,7 @@ API at the time the Assembly was created. You have to explicitly ask # reloads the response's contents from the REST API response.reload! -# reloads once per second until all processing is finished, up to number of +# reloads once per second until all processing is finished, up to number of # times specified in :tries option, otherwise will raise ReloadLimitReached response.reload_until_finished! tries: 300 # default is 600 ``` @@ -147,8 +147,8 @@ than one file in the same request. You can also pass a single Step fo require 'transloadit' transloadit = Transloadit.new( - :key => 'YOUR_TRANSLOADIT_KEY', - :secret => 'YOUR_TRANSLOADIT_SECRET' + :key => 'MY_TRANSLOADIT_KEY', + :secret => 'MY_TRANSLOADIT_SECRET' ) assembly = transloadit.assembly(steps: store) @@ -160,7 +160,7 @@ response = assembly.create!( ) ``` -You can also pass an array of files to the `create!` method. +You can also pass an array of files to the `create!` method. Just unpack the array using the splat `*` operator. ```ruby @@ -178,8 +178,8 @@ simply need to `use` other Steps. Following require 'transloadit' transloadit = Transloadit.new( - :key => 'YOUR_TRANSLOADIT_KEY', - :secret => 'YOUR_TRANSLOADIT_SECRET' + :key => 'MY_TRANSLOADIT_KEY', + :secret => 'MY_TRANSLOADIT_SECRET' ) encode = transloadit.step 'encode', '/video/encode', { ... } @@ -208,17 +208,17 @@ for recurring encoding tasks. In order to use these do the following: require 'transloadit' transloadit = Transloadit.new( - :key => 'YOUR_TRANSLOADIT_KEY', - :secret => 'YOUR_TRANSLOADIT_SECRET' + :key => 'MY_TRANSLOADIT_KEY', + :secret => 'MY_TRANSLOADIT_SECRET' ) transloadit.assembly( - :template_id => 'YOUR_TEMPLATE_ID' + :template_id => 'MY_TEMPLATE_ID' ).create! open('/PATH/TO/FILE.mpg') ``` You can use your steps together with this template and even use variables. -The [Transloadit documentation](https://transloadit.com/docs/#passing-variables-into-a-template) +The [Transloadit documentation](https://transloadit.com/docs/#passing-variables-into-a-template) has some nice examples for that. ### 5. Using fields @@ -231,8 +231,8 @@ to the upload itself. You can use fields like the following: require 'transloadit' transloadit = Transloadit.new( - :key => 'YOUR_TRANSLOADIT_KEY', - :secret => 'YOUR_TRANSLOADIT_SECRET' + :key => 'MY_TRANSLOADIT_KEY', + :secret => 'MY_TRANSLOADIT_SECRET' ) transloadit.assembly( @@ -252,8 +252,8 @@ a Notify URL for the Assembly. require 'transloadit' transloadit = Transloadit.new( - :key => 'YOUR_TRANSLOADIT_KEY', - :secret => 'YOUR_TRANSLOADIT_SECRET' + :key => 'MY_TRANSLOADIT_KEY', + :secret => 'MY_TRANSLOADIT_SECRET' ) transloadit.assembly( @@ -271,8 +271,8 @@ Transloadit also provides methods to retrieve/replay Assemblies and t require 'transloadit' transloadit = Transloadit.new( - :key => 'YOUR_TRANSLOADIT_KEY', - :secret => 'YOUR_TRANSLOADIT_SECRET' + :key => 'MY_TRANSLOADIT_KEY', + :secret => 'MY_TRANSLOADIT_SECRET' ) assembly = transloadit.assembly @@ -281,10 +281,10 @@ assembly = transloadit.assembly assembly.list # returns a specific assembly -assembly.get 'YOUR_ASSEMBLY_ID' +assembly.get 'MY_ASSEMBLY_ID' # replays a specific assembly -response = assembly.replay 'YOUR_ASSEMBLY_ID' +response = assembly.replay 'MY_ASSEMBLY_ID' # should return true if assembly is replaying and false otherwise. response.replaying? @@ -292,7 +292,7 @@ response.replaying? assembly.get_notifications # replays an assembly notification -assembly.replay_notification 'YOUR_ASSEMBLY_ID' +assembly.replay_notification 'MY_ASSEMBLY_ID' ``` ### 8. Templates @@ -304,8 +304,8 @@ for recurring encoding tasks. Here's how you would create a Template: require 'transloadit' transloadit = Transloadit.new( - :key => 'YOUR_TRANSLOADIT_KEY', - :secret => 'YOUR_TRANSLOADIT_SECRET' + :key => 'MY_TRANSLOADIT_KEY', + :secret => 'MY_TRANSLOADIT_SECRET' ) template = transloadit.template @@ -331,8 +331,8 @@ There are also some other methods to retrieve, update and delete a Template require 'transloadit' transloadit = Transloadit.new( - :key => 'YOUR_TRANSLOADIT_KEY', - :secret => 'YOUR_TRANSLOADIT_SECRET' + :key => 'MY_TRANSLOADIT_KEY', + :secret => 'MY_TRANSLOADIT_SECRET' ) template = transloadit.template @@ -341,11 +341,11 @@ template = transloadit.template template.list # returns a specific template. -template.get 'YOUR_TEMPLATE_ID' +template.get 'MY_TEMPLATE_ID' # updates the template whose id is specified. template.update( - 'YOUR_TEMPLATE_ID', + 'MY_TEMPLATE_ID', :name => 'CHANGED_TEMPLATE_NAME', :template => { :steps => { @@ -358,7 +358,7 @@ template.update( ) # deletes a specific template -template.delete 'YOUR_TEMPLATE_ID' +template.delete 'MY_TEMPLATE_ID' ``` ### 9. Getting Bill reports @@ -370,8 +370,8 @@ you can use the `bill` method passing the required month and year like the follo require 'transloadit' transloadit = Transloadit.new( - :key => 'YOUR_TRANSLOADIT_KEY', - :secret => 'YOUR_TRANSLOADIT_SECRET' + :key => 'MY_TRANSLOADIT_KEY', + :secret => 'MY_TRANSLOADIT_SECRET' ) # returns bill report for February, 2016. @@ -380,7 +380,48 @@ transloadit.bill(2, 2016) Not specifying the `month` or `year` would default to the current month or year. -### 10. Rate limits +### 10. Signing Smart CDN URLs + +You can generate signed [Smart CDN](https://transloadit.com/services/content-delivery/) URLs using your Transloadit instance: + +```ruby +require 'transloadit' + +transloadit = Transloadit.new( + :key => 'MY_TRANSLOADIT_KEY', + :secret => 'MY_TRANSLOADIT_SECRET' +) + +# Generate a signed URL using instance credentials +url = transloadit.signed_smart_cdn_url( + workspace: "MY_WORKSPACE", + template: "MY_TEMPLATE", + input: "avatars/jane.jpg" +) + +# Add URL parameters +url = transloadit.signed_smart_cdn_url( + workspace: "MY_WORKSPACE", + template: "MY_TEMPLATE", + input: "avatars/jane.jpg", + url_params: { + width: 100, + height: 200 + } +) + +# Set expiration time +url = transloadit.signed_smart_cdn_url( + workspace: "MY_WORKSPACE", + template: "MY_TEMPLATE", + input: "avatars/jane.jpg", + expire_at_ms: 1732550672867 # Specific timestamp +) +``` + +The generated URL will be signed using your Transloadit credentials and can be used to access files through the Smart CDN in a secure manner. + +### 11. Rate limits Transloadit enforces rate limits to guarantee that no customers are adversely affected by the usage of any given customer. See [Rate Limiting](https://transloadit.com/docs/api/#rate-limiting). @@ -393,8 +434,8 @@ To change the number of attempts that will be made when creating an Assembl require 'transloadit' transloadit = Transloadit.new( - :key => 'YOUR_TRANSLOADIT_KEY', - :secret => 'YOUR_TRANSLOADIT_SECRET' + :key => 'MY_TRANSLOADIT_KEY', + :secret => 'MY_TRANSLOADIT_SECRET' ) # would make one extra attempt after a failed attempt. @@ -423,3 +464,24 @@ Please see [ci.yml](https://github.com/transloadit/ruby-sdk/tree/main/.github/wo ### Ruby 2.x If you still need support for Ruby 2.x, 2.0.1 is the last version that supports it. + +## Contributing + +### Running tests + +```bash +bundle install +bundle exec rake test +``` + +To also test parity against the Node.js reference implementation, run: + +```bash +TEST_NODE_PARITY=1 bundle exec rake test +``` + +To disable coverage reporting, run: + +```bash +COVERAGE=0 bundle exec rake test +``` diff --git a/lib/transloadit.rb b/lib/transloadit.rb index d2a2869..9cda61d 100644 --- a/lib/transloadit.rb +++ b/lib/transloadit.rb @@ -1,5 +1,9 @@ require "multi_json" require "date" +require "json" +require "openssl" +require "uri" +require "cgi" # # Implements the Transloadit REST API in Ruby. Check the {file:README.md README} @@ -11,6 +15,7 @@ class Transloadit autoload :Exception, "transloadit/exception" autoload :Request, "transloadit/request" autoload :Response, "transloadit/response" + autoload :SmartCDN, "transloadit/smart_cdn" autoload :Step, "transloadit/step" autoload :Template, "transloadit/template" autoload :VERSION, "transloadit/version" @@ -130,6 +135,54 @@ def to_json MultiJson.dump(to_hash) end + # @param workspace [String] Workspace slug + # @param template [String] Template slug or template ID + # @param input [String] Input value that is provided as `${fields.input}` in the template + # @param url_params [Hash] Additional parameters for the URL query string (optional) + # @param expire_at_ms [Integer] Expiration time as Unix timestamp in milliseconds (optional) + # @return [String] Signed Smart CDN URL + def signed_smart_cdn_url( + workspace:, + template:, + input:, + expire_at_ms: nil, + url_params: {} + ) + raise ArgumentError, "workspace is required" if workspace.nil? || workspace.empty? + raise ArgumentError, "template is required" if template.nil? || template.empty? + raise ArgumentError, "input is required" if input.nil? + + workspace_slug = CGI.escape(workspace) + template_slug = CGI.escape(template) + input_field = CGI.escape(input) + + expire_at = expire_at_ms || (Time.now.to_i * 1000 + 60 * 60 * 1000) # 1 hour default + + query_params = {} + url_params.each do |key, value| + next if value.nil? + Array(value).each do |val| + next if val.nil? + (query_params[key.to_s] ||= []) << val.to_s + end + end + + query_params["auth_key"] = [key] + query_params["exp"] = [expire_at.to_s] + + # Sort parameters to ensure consistent ordering + sorted_params = query_params.sort.flat_map do |key, values| + values.map { |v| "#{CGI.escape(key)}=#{CGI.escape(v)}" } + end.join("&") + + string_to_sign = "#{workspace_slug}/#{template_slug}/#{input_field}?#{sorted_params}" + + signature = OpenSSL::HMAC.hexdigest("sha256", secret, string_to_sign) + + final_params = "#{sorted_params}&sig=#{CGI.escape("sha256:#{signature}")}" + "https://#{workspace_slug}.tlcdn.com/#{template_slug}/#{input_field}?#{final_params}" + end + private # diff --git a/test/test_helper.rb b/test/test_helper.rb index ae9fa98..8ff789f 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,7 +1,7 @@ $:.unshift File.dirname(__FILE__) $:.unshift File.expand_path("../../lib", __FILE__) -if ENV["COVERAGE"] != "false" +if ENV["COVERAGE"] != "0" require "simplecov" SimpleCov.start { add_filter "/test/" } end diff --git a/test/unit/transloadit/node-smartcdn-sig.ts b/test/unit/transloadit/node-smartcdn-sig.ts new file mode 100644 index 0000000..14c9461 --- /dev/null +++ b/test/unit/transloadit/node-smartcdn-sig.ts @@ -0,0 +1,89 @@ +#!/usr/bin/env tsx +// Reference Smart CDN (https://transloadit.com/services/content-delivery/) Signature implementation +// And CLI tester to see if our SDK's implementation +// matches Node's + +/// + +import { createHash, createHmac } from 'crypto' + +interface SmartCDNParams { + workspace: string + template: string + input: string + expire_at_ms?: number + auth_key?: string + auth_secret?: string + url_params?: Record +} + +function signSmartCDNUrl(params: SmartCDNParams): string { + const { + workspace, + template, + input, + expire_at_ms, + auth_key = 'my-key', + auth_secret = 'my-secret', + url_params = {}, + } = params + + if (!workspace) throw new Error('workspace is required') + if (!template) throw new Error('template is required') + if (input === null || input === undefined) + throw new Error('input must be a string') + + const workspaceSlug = encodeURIComponent(workspace) + const templateSlug = encodeURIComponent(template) + const inputField = encodeURIComponent(input) + + const expireAt = expire_at_ms ?? Date.now() + 60 * 60 * 1000 // 1 hour default + + const queryParams: Record = {} + + // Handle url_params + Object.entries(url_params).forEach(([key, value]) => { + if (value === null || value === undefined) return + if (Array.isArray(value)) { + value.forEach((val) => { + if (val === null || val === undefined) return + ;(queryParams[key] ||= []).push(String(val)) + }) + } else { + queryParams[key] = [String(value)] + } + }) + + queryParams.auth_key = [auth_key] + queryParams.exp = [String(expireAt)] + + // Sort parameters to ensure consistent ordering + const sortedParams = Object.entries(queryParams) + .sort() + .map(([key, values]) => + values.map((v) => `${encodeURIComponent(key)}=${encodeURIComponent(v)}`) + ) + .flat() + .join('&') + + const stringToSign = `${workspaceSlug}/${templateSlug}/${inputField}?${sortedParams}` + const signature = createHmac('sha256', auth_secret) + .update(stringToSign) + .digest('hex') + + const finalParams = `${sortedParams}&sig=${encodeURIComponent( + `sha256:${signature}` + )}` + return `https://${workspaceSlug}.tlcdn.com/${templateSlug}/${inputField}?${finalParams}` +} + +// Read JSON from stdin +let jsonInput = '' +process.stdin.on('data', (chunk) => { + jsonInput += chunk +}) + +process.stdin.on('end', () => { + const params = JSON.parse(jsonInput) + console.log(signSmartCDNUrl(params)) +}) diff --git a/test/unit/transloadit/test_smart_cdn.rb b/test/unit/transloadit/test_smart_cdn.rb new file mode 100644 index 0000000..586eaa7 --- /dev/null +++ b/test/unit/transloadit/test_smart_cdn.rb @@ -0,0 +1,169 @@ +require "test_helper" +require "json" +require "open3" + +describe Transloadit do + before do + @auth_key = "my-key" + @auth_secret = "my-secret" + @transloadit = Transloadit.new(key: @auth_key, secret: @auth_secret) + @workspace = "my-app" + @template = "test-smart-cdn" + @input = "inputs/prinsengracht.jpg" + @expire_at = 1732550672867 + end + + def run_node_script(params) + return unless ENV["TEST_NODE_PARITY"] == "1" + script_path = File.expand_path("./node-smartcdn-sig", __dir__) + json_input = JSON.dump(params) + stdout, stderr, status = Open3.capture3("tsx #{script_path}", stdin_data: json_input) + raise "Node script failed: #{stderr}" unless status.success? + stdout.strip + end + + describe "#signed_smart_cdn_url" do + it "requires workspace" do + assert_raises ArgumentError, "workspace is required" do + @transloadit.signed_smart_cdn_url( + workspace: nil, + template: @template, + input: @input + ) + end + + assert_raises ArgumentError, "workspace is required" do + @transloadit.signed_smart_cdn_url( + workspace: "", + template: @template, + input: @input + ) + end + end + + it "requires template" do + assert_raises ArgumentError, "template is required" do + @transloadit.signed_smart_cdn_url( + workspace: @workspace, + template: nil, + input: @input + ) + end + + assert_raises ArgumentError, "template is required" do + @transloadit.signed_smart_cdn_url( + workspace: @workspace, + template: "", + input: @input + ) + end + end + + it "requires input" do + assert_raises ArgumentError, "input is required" do + @transloadit.signed_smart_cdn_url( + workspace: @workspace, + template: @template, + input: nil + ) + end + end + + it "allows empty input string" do + params = { + workspace: @workspace, + template: @template, + input: "", + expire_at_ms: @expire_at + } + expected_url = "https://my-app.tlcdn.com/test-smart-cdn/?auth_key=my-key&exp=1732550672867&sig=sha256%3Ad5e13df4acde8d4aaa0f34534489e54098b5128c54392600ed96dd77669a533e" + + url = @transloadit.signed_smart_cdn_url(**params) + assert_equal expected_url, url + + if (node_url = run_node_script(params.merge(auth_key: "my-key", auth_secret: "my-secret"))) + assert_equal expected_url, node_url + end + end + + it "uses instance credentials" do + params = { + workspace: @workspace, + template: @template, + input: @input, + expire_at_ms: @expire_at + } + expected_url = "https://my-app.tlcdn.com/test-smart-cdn/inputs%2Fprinsengracht.jpg?auth_key=my-key&exp=1732550672867&sig=sha256%3A8620fc2a22aec6081cde730b7f3f29c0d8083f58a68f62739e642b3c03709139" + + url = @transloadit.signed_smart_cdn_url(**params) + assert_equal expected_url, url + + if (node_url = run_node_script(params.merge(auth_key: "my-key", auth_secret: "my-secret"))) + assert_equal expected_url, node_url + end + end + + it "includes empty width parameter" do + params = { + workspace: @workspace, + template: @template, + input: @input, + expire_at_ms: @expire_at, + url_params: { + width: "", + height: 200 + } + } + expected_url = "https://my-app.tlcdn.com/test-smart-cdn/inputs%2Fprinsengracht.jpg?auth_key=my-key&exp=1732550672867&height=200&width=&sig=sha256%3Aebf562722c504839db97165e657583f74192ac4ab580f1a0dd67d3d868b4ced3" + + url = @transloadit.signed_smart_cdn_url(**params) + assert_equal expected_url, url + + if (node_url = run_node_script(params.merge(auth_key: "my-key", auth_secret: "my-secret"))) + assert_equal expected_url, node_url + end + end + + it "handles nil values in parameters" do + params = { + workspace: @workspace, + template: @template, + input: @input, + expire_at_ms: @expire_at, + url_params: { + width: nil, + height: 200 + } + } + expected_url = "https://my-app.tlcdn.com/test-smart-cdn/inputs%2Fprinsengracht.jpg?auth_key=my-key&exp=1732550672867&height=200&sig=sha256%3Ad6897a0cb527a14eaab13c54b06f53527797c553d8b7e5d0b1a5df237212f083" + + url = @transloadit.signed_smart_cdn_url(**params) + assert_equal expected_url, url + + if (node_url = run_node_script(params.merge(auth_key: "my-key", auth_secret: "my-secret"))) + assert_equal expected_url, node_url + end + end + + it "handles array values in parameters" do + params = { + workspace: @workspace, + template: @template, + input: @input, + expire_at_ms: @expire_at, + url_params: { + tags: ["landscape", "amsterdam", nil, ""], + height: 200 + } + } + expected_url = "https://my-app.tlcdn.com/test-smart-cdn/inputs%2Fprinsengracht.jpg?auth_key=my-key&exp=1732550672867&height=200&tags=landscape&tags=amsterdam&tags=&sig=sha256%3Aff46eb0083d64b250b2e4510380e333f67da855b2401493dee7a706a47957d3f" + + url = @transloadit.signed_smart_cdn_url(**params) + assert_equal expected_url, url + + if (node_url = run_node_script(params.merge(auth_key: "my-key", auth_secret: "my-secret"))) + assert_equal expected_url, node_url + end + end + end +end