Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ response.completed? # => true
response.error? # => false
```

Assembly responses returned by `create!`, `get`, and `replay` retain their client
for cancellation. `cancel!` includes authentication and signs the DELETE request
when that client has an Auth Secret, so it works with Workspaces that require
correct signatures. Responses constructed directly without a client retain
the existing unsigned cancellation behavior.

It's important to note that none of these queries are "live" (with the
exception of the `cancel!` method). They all check the response given by the
API at the time the <dfn>Assembly</dfn> was created. You have to explicitly ask the
Expand Down
14 changes: 10 additions & 4 deletions lib/transloadit/assembly.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ def create!(*ios, **params)
# update the payload with file entries
ios.each_with_index { |f, i| extra_params.update "file_#{i}": f }

response = _do_request(
response = _assembly_response(_do_request(
"/assemblies", params, "post", extra_params
).extend!(Transloadit::Response::Assembly)
))

return response unless response.rate_limit?

Expand Down Expand Up @@ -89,7 +89,7 @@ def list(params = {})
# @param [String] id id of the desired assembly
#
def get(id)
_do_request("/assemblies/#{id}").extend!(Transloadit::Response::Assembly)
_assembly_response(_do_request("/assemblies/#{id}"))
end

#
Expand All @@ -99,7 +99,7 @@ def get(id)
#
def replay(id, params = {})
params[:wait] = false
_do_request("/assemblies/#{id}/replay", params, "post").extend!(Transloadit::Response::Assembly)
_assembly_response(_do_request("/assemblies/#{id}/replay", params, "post"))
end

#
Expand Down Expand Up @@ -131,6 +131,12 @@ def to_hash

private

def _assembly_response(response)
response.extend!(Transloadit::Response::Assembly)
response.transloadit = transloadit
response
end

#
# Returns a Transloadit-compatible Hash wrapping the +steps+ passed to it.
# Accepts any supported format the +steps+ could come in.
Expand Down
12 changes: 11 additions & 1 deletion lib/transloadit/response/assembly.rb
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
require "transloadit"
require "securerandom"

module Transloadit::Response::Assembly
# @return [Transloadit, nil] the client used to authenticate Assembly actions
attr_accessor :transloadit

def reload!
replace Transloadit::Request.new(self["assembly_ssl_url"]).get
end

def cancel!
replace Transloadit::Request.new(self["assembly_ssl_url"]).delete
return replace Transloadit::Request.new(self["assembly_ssl_url"]).delete unless transloadit

auth = transloadit.to_hash
# Separate cancellation attempts must not produce the same signed payload.
auth[:nonce] = SecureRandom.uuid unless transloadit.secret.to_s.empty?
replace Transloadit::Request.new(self["assembly_ssl_url"], transloadit.secret)
.delete(params: {auth: auth})
end

def aborted?
Expand Down
90 changes: 89 additions & 1 deletion test/unit/transloadit/test_assembly.rb
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@
response = @assembly.create! open("lib/transloadit/version.rb")
_(response["ok"]).must_equal "ASSEMBLY_COMPLETED"
}
_(warning).must_equal "Rate limit reached. Waiting for 0 seconds before retrying.\n"
_(warning).must_include "Rate limit reached. Waiting for 0 seconds before retrying.\n"
end
end

Expand Down Expand Up @@ -302,6 +302,94 @@
@assembly = Transloadit::Assembly.new @transloadit
end

after do
WebMock.reset!
end

it "must sign cancellations of created assemblies with the originating client" do
client = Transloadit.new(key: "workspace-key", secret: "workspace-secret")
endpoint = "https://api2-jane.transloadit.com/assemblies/assembly-id"
status = MultiJson.dump(ok: "ASSEMBLY_EXECUTING", assembly_ssl_url: endpoint)
stub_request(:post, "https://api2.transloadit.com/assemblies").to_return(body: status)
stub_request(:delete, endpoint).to_return(
body: MultiJson.dump(ok: "ASSEMBLY_CANCELED", assembly_ssl_url: endpoint)
)

response = client.assembly.create!
_(response.transloadit).must_be_same_as client
_(response.cancel!).must_be_same_as response
_(response.canceled?).must_equal true

assert_requested(:delete, endpoint) do |request|
fields = URI.decode_www_form(request.body).to_h
params = MultiJson.load(fields.fetch("params"))
_(params.dig("auth", "key")).must_equal "workspace-key"
_(params.dig("auth", "expires")).wont_be_nil
_(params.dig("auth", "nonce")).must_match(/\A[0-9a-f-]{36}\z/)
expected = "sha384:" + OpenSSL::HMAC.hexdigest("SHA384", client.secret, fields.fetch("params"))
_(fields.fetch("signature")).must_equal expected
_(request.body).wont_include client.secret
end
end

it "must keep cancellation authentication after reloading a fetched assembly" do
client = Transloadit.new(key: "workspace-key", secret: "workspace-secret")
endpoint = "https://api2-jane.transloadit.com/assemblies/assembly-id"
status = MultiJson.dump(ok: "ASSEMBLY_EXECUTING", assembly_ssl_url: endpoint)
stub_request(:get, "https://api2.transloadit.com/assemblies/assembly-id").to_return(body: status)
stub_request(:get, endpoint).to_return(body: status)
stub_request(:delete, endpoint).to_return(body: '{"ok":"ASSEMBLY_CANCELED"}')

response = client.assembly.get("assembly-id")
response.reload!
_(response.transloadit).must_be_same_as client
response.cancel!

assert_requested(:delete, endpoint) do |request|
fields = URI.decode_www_form(request.body).to_h
_(fields.fetch("signature")).must_equal(
"sha384:" + OpenSSL::HMAC.hexdigest("SHA384", client.secret, fields.fetch("params"))
)
end
end

it "must authenticate cancellation of replayed assemblies with fresh nonces" do
client = Transloadit.new(key: "workspace-key", secret: "workspace-secret")
endpoint = "https://api2-jane.transloadit.com/assemblies/replayed-id"
status = MultiJson.dump(ok: "ASSEMBLY_REPLAYING", assembly_ssl_url: endpoint)
stub_request(:post, "https://api2.transloadit.com/assemblies/assembly-id/replay")
.to_return(body: status)
nonces = []
stub_request(:delete, endpoint).to_return do |request|
fields = URI.decode_www_form(request.body).to_h
nonces << MultiJson.load(fields.fetch("params")).dig("auth", "nonce")
{body: MultiJson.dump(ok: "ASSEMBLY_CANCELED", assembly_ssl_url: endpoint)}
end

response = client.assembly.replay("assembly-id")
response.cancel!
response.cancel!

_(response.transloadit).must_be_same_as client
_(nonces.length).must_equal 2
_(nonces.uniq.length).must_equal 2
end

it "must include the client key without a signature when no secret was configured" do
endpoint = "https://api2-jane.transloadit.com/assemblies/assembly-id"
status = MultiJson.dump(ok: "ASSEMBLY_EXECUTING", assembly_ssl_url: endpoint)
stub_request(:get, "https://api2.transloadit.com/assemblies/assembly-id").to_return(body: status)
stub_request(:delete, endpoint).to_return(body: '{"ok":"ASSEMBLY_CANCELED"}')

@assembly.get("assembly-id").cancel!

assert_requested(:delete, endpoint) do |request|
fields = URI.decode_www_form(request.body).to_h
_(MultiJson.load(fields.fetch("params"))).must_equal "auth" => {"key" => ""}
_(fields).wont_include "signature"
end
end

describe "when fetching all assemblies" do
it "must perform GET request to /assemblies" do
stub = stub_request(:get, "https://api2.transloadit.com/assemblies?params=%7B%22auth%22:%7B%22key%22:%22%22%7D%7D")
Expand Down
2 changes: 2 additions & 0 deletions transloadit.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ Gem::Specification.new do |gem|

gem.add_dependency "faraday", ">= 2.0", "< 3.0"
gem.add_dependency "faraday-multipart", "~> 1.0"
# Ruby 3.1 uses MultiJson 1.19, whose positional parse options are incompatible with JSON 3.
gem.add_dependency "json", "< 3.0"
gem.add_dependency "multi_json"
gem.add_dependency "mime-types"

Expand Down
Loading