mirror of https://github.com/docusealco/docuseal
parent
f2f2847908
commit
6ea2618cb1
@ -0,0 +1,60 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
class SignedDocumentUrlsController < ApiBaseController
|
||||||
|
load_and_authorize_resource :submission
|
||||||
|
|
||||||
|
def show
|
||||||
|
# Find the last completed submitter
|
||||||
|
last_submitter = @submission.submitters
|
||||||
|
.where.not(completed_at: nil)
|
||||||
|
.order(:completed_at)
|
||||||
|
.last
|
||||||
|
|
||||||
|
return render json: { error: 'Submission not completed' },
|
||||||
|
status: :unprocessable_entity if last_submitter.blank?
|
||||||
|
|
||||||
|
# Ensure documents are generated
|
||||||
|
Submissions::EnsureResultGenerated.call(last_submitter)
|
||||||
|
|
||||||
|
# Build signed URLs using standard DocuSeal configuration
|
||||||
|
documents = build_signed_urls(last_submitter)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
submission_id: @submission.id,
|
||||||
|
submitter_id: last_submitter.id,
|
||||||
|
documents: documents
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def build_signed_urls(submitter)
|
||||||
|
Submitters.select_attachments_for_download(submitter).map do |attachment|
|
||||||
|
{
|
||||||
|
name: attachment.filename.to_s,
|
||||||
|
url: generate_url(attachment),
|
||||||
|
size_bytes: attachment.blob.byte_size,
|
||||||
|
content_type: attachment.blob.content_type
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate_url(attachment)
|
||||||
|
if uses_secured_storage?(attachment)
|
||||||
|
# CloudFront signed URL with 1 hour expiration (default)
|
||||||
|
DocumentSecurityService.signed_url_for(attachment)
|
||||||
|
else
|
||||||
|
# Standard ActiveStorage proxy URL with 1 hour expiration
|
||||||
|
ActiveStorage::Blob.proxy_url(
|
||||||
|
attachment.blob,
|
||||||
|
expires_at: 1.hour.from_now.to_i
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def uses_secured_storage?(attachment)
|
||||||
|
attachment.blob.service_name == 'aws_s3_secured'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,315 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe 'Signed Document URLs API' do
|
||||||
|
let(:account) { create(:account, :with_testing_account) }
|
||||||
|
let(:testing_account) { account.testing_accounts.first }
|
||||||
|
let(:author) { create(:user, account:) }
|
||||||
|
let(:testing_author) { create(:user, account: testing_account) }
|
||||||
|
let(:template) { create(:template, account:, author:) }
|
||||||
|
let(:testing_template) { create(:template, account: testing_account, author: testing_author) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
ActiveStorage::Current.url_options = { host: 'test.example.com' }
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'GET /api/submissions/:submission_id/signed_document_url' do
|
||||||
|
context 'with a completed submission' do
|
||||||
|
let(:submission) { create(:submission, :with_submitters, template:, created_by_user: author) }
|
||||||
|
let(:completed_submitter) { submission.submitters.first }
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Mark submitter as completed
|
||||||
|
completed_submitter.update!(completed_at: Time.current)
|
||||||
|
|
||||||
|
# Create a document attachment for the submitter
|
||||||
|
blob = ActiveStorage::Blob.create_and_upload!(
|
||||||
|
io: StringIO.new('test pdf content'),
|
||||||
|
filename: 'completed-document.pdf',
|
||||||
|
content_type: 'application/pdf'
|
||||||
|
)
|
||||||
|
completed_submitter.documents.attach(blob)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns signed URLs for completed documents' do
|
||||||
|
allow(Submissions::EnsureResultGenerated).to receive(:call).and_return(completed_submitter.documents)
|
||||||
|
|
||||||
|
get "/api/submissions/#{submission.id}/signed_document_url",
|
||||||
|
headers: { 'x-auth-token': author.access_token.token }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.parsed_body['submission_id']).to eq(submission.id)
|
||||||
|
expect(response.parsed_body['submitter_id']).to eq(completed_submitter.id)
|
||||||
|
expect(response.parsed_body['documents']).to be_an(Array)
|
||||||
|
expect(response.parsed_body['documents'].size).to eq(1)
|
||||||
|
|
||||||
|
document = response.parsed_body['documents'].first
|
||||||
|
expect(document['name']).to eq('completed-document.pdf')
|
||||||
|
expect(document['url']).to be_present
|
||||||
|
expect(document['size_bytes']).to be_a(Integer)
|
||||||
|
expect(document['content_type']).to eq('application/pdf')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'calls EnsureResultGenerated to generate documents if needed' do
|
||||||
|
expect(Submissions::EnsureResultGenerated).to receive(:call).with(completed_submitter)
|
||||||
|
|
||||||
|
get "/api/submissions/#{submission.id}/signed_document_url",
|
||||||
|
headers: { 'x-auth-token': author.access_token.token }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses standard ActiveStorage URLs for disk storage' do
|
||||||
|
allow(Submissions::EnsureResultGenerated).to receive(:call).and_return(completed_submitter.documents)
|
||||||
|
|
||||||
|
get "/api/submissions/#{submission.id}/signed_document_url",
|
||||||
|
headers: { 'x-auth-token': author.access_token.token }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
document = response.parsed_body['documents'].first
|
||||||
|
# For disk storage in test, should use proxy URL pattern
|
||||||
|
expect(document['url']).to include('/file/')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes document metadata' do
|
||||||
|
allow(Submissions::EnsureResultGenerated).to receive(:call).and_return(completed_submitter.documents)
|
||||||
|
|
||||||
|
get "/api/submissions/#{submission.id}/signed_document_url",
|
||||||
|
headers: { 'x-auth-token': author.access_token.token }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
document = response.parsed_body['documents'].first
|
||||||
|
expect(document).to have_key('name')
|
||||||
|
expect(document).to have_key('url')
|
||||||
|
expect(document).to have_key('size_bytes')
|
||||||
|
expect(document).to have_key('content_type')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with secured storage (CloudFront)' do
|
||||||
|
let(:submission) { create(:submission, :with_submitters, template:, created_by_user: author) }
|
||||||
|
let(:completed_submitter) { submission.submitters.first }
|
||||||
|
|
||||||
|
before do
|
||||||
|
completed_submitter.update!(completed_at: Time.current)
|
||||||
|
|
||||||
|
# Create a regular document (test storage)
|
||||||
|
blob = ActiveStorage::Blob.create_and_upload!(
|
||||||
|
io: StringIO.new('test pdf content'),
|
||||||
|
filename: 'secured-document.pdf',
|
||||||
|
content_type: 'application/pdf'
|
||||||
|
)
|
||||||
|
completed_submitter.documents.attach(blob)
|
||||||
|
|
||||||
|
# Mock CloudFront configuration
|
||||||
|
allow(ENV).to receive(:fetch).with('CF_URL', nil).and_return('https://d123.cloudfront.net')
|
||||||
|
allow(ENV).to receive(:fetch).with('CF_KEY_PAIR_ID', nil).and_return('TEST_KEY')
|
||||||
|
allow(ENV).to receive(:fetch).with('SECURE_ATTACHMENT_PRIVATE_KEY', nil).and_return('test-key')
|
||||||
|
|
||||||
|
signer = instance_double(Aws::CloudFront::UrlSigner)
|
||||||
|
allow(Aws::CloudFront::UrlSigner).to receive(:new).and_return(signer)
|
||||||
|
allow(signer).to receive(:signed_url).and_return('https://d123.cloudfront.net/signed-url')
|
||||||
|
end
|
||||||
|
|
||||||
|
after do
|
||||||
|
DocumentSecurityService.instance_variable_set(:@cloudfront_signer, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses DocumentSecurityService for secured storage' do
|
||||||
|
allow(Submissions::EnsureResultGenerated).to receive(:call).and_return(completed_submitter.documents)
|
||||||
|
|
||||||
|
# Stub Submitters.select_attachments_for_download to return the documents
|
||||||
|
allow(Submitters).to receive(:select_attachments_for_download).and_return(completed_submitter.documents)
|
||||||
|
|
||||||
|
# Mock the attachment's blob to appear as if it's using aws_s3_secured service
|
||||||
|
completed_submitter.documents.each do |attachment|
|
||||||
|
allow(attachment.blob).to receive(:service_name).and_return('aws_s3_secured')
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/api/submissions/#{submission.id}/signed_document_url",
|
||||||
|
headers: { 'x-auth-token': author.access_token.token }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
document = response.parsed_body['documents'].first
|
||||||
|
expect(document['url']).to include('cloudfront.net')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with an incomplete submission' do
|
||||||
|
let(:submission) { create(:submission, :with_submitters, template:, created_by_user: author) }
|
||||||
|
|
||||||
|
it 'returns an error when submission is not completed' do
|
||||||
|
get "/api/submissions/#{submission.id}/signed_document_url",
|
||||||
|
headers: { 'x-auth-token': author.access_token.token }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(response.parsed_body).to eq({ 'error' => 'Submission not completed' })
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not call EnsureResultGenerated for incomplete submissions' do
|
||||||
|
expect(Submissions::EnsureResultGenerated).not_to receive(:call)
|
||||||
|
|
||||||
|
get "/api/submissions/#{submission.id}/signed_document_url",
|
||||||
|
headers: { 'x-auth-token': author.access_token.token }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with multiple completed submitters' do
|
||||||
|
let(:template) { create(:template, submitter_count: 2, account:, author:) }
|
||||||
|
let(:submission) { create(:submission, :with_submitters, template:, created_by_user: author) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Complete first submitter
|
||||||
|
submission.submitters.first.update!(completed_at: 1.hour.ago)
|
||||||
|
blob1 = ActiveStorage::Blob.create_and_upload!(
|
||||||
|
io: StringIO.new('first document'),
|
||||||
|
filename: 'first-document.pdf',
|
||||||
|
content_type: 'application/pdf'
|
||||||
|
)
|
||||||
|
submission.submitters.first.documents.attach(blob1)
|
||||||
|
|
||||||
|
# Complete second submitter (most recent)
|
||||||
|
submission.submitters.last.update!(completed_at: Time.current)
|
||||||
|
blob2 = ActiveStorage::Blob.create_and_upload!(
|
||||||
|
io: StringIO.new('second document'),
|
||||||
|
filename: 'second-document.pdf',
|
||||||
|
content_type: 'application/pdf'
|
||||||
|
)
|
||||||
|
submission.submitters.last.documents.attach(blob2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns documents from the last completed submitter' do
|
||||||
|
allow(Submissions::EnsureResultGenerated).to receive(:call).and_return(submission.submitters.last.documents)
|
||||||
|
|
||||||
|
get "/api/submissions/#{submission.id}/signed_document_url",
|
||||||
|
headers: { 'x-auth-token': author.access_token.token }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.parsed_body['submitter_id']).to eq(submission.submitters.last.id)
|
||||||
|
expect(response.parsed_body['documents'].first['name']).to eq('second-document.pdf')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with multiple documents' do
|
||||||
|
let(:submission) { create(:submission, :with_submitters, template:, created_by_user: author) }
|
||||||
|
let(:completed_submitter) { submission.submitters.first }
|
||||||
|
|
||||||
|
before do
|
||||||
|
completed_submitter.update!(completed_at: Time.current)
|
||||||
|
|
||||||
|
# Create multiple document attachments
|
||||||
|
blob1 = ActiveStorage::Blob.create_and_upload!(
|
||||||
|
io: StringIO.new('first pdf'),
|
||||||
|
filename: 'document-1.pdf',
|
||||||
|
content_type: 'application/pdf'
|
||||||
|
)
|
||||||
|
blob2 = ActiveStorage::Blob.create_and_upload!(
|
||||||
|
io: StringIO.new('second pdf'),
|
||||||
|
filename: 'document-2.pdf',
|
||||||
|
content_type: 'application/pdf'
|
||||||
|
)
|
||||||
|
completed_submitter.documents.attach([blob1, blob2])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns all documents' do
|
||||||
|
allow(Submissions::EnsureResultGenerated).to receive(:call).and_return(completed_submitter.documents)
|
||||||
|
|
||||||
|
get "/api/submissions/#{submission.id}/signed_document_url",
|
||||||
|
headers: { 'x-auth-token': author.access_token.token }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.parsed_body['documents'].size).to eq(2)
|
||||||
|
expect(response.parsed_body['documents'].map { |d| d['name'] }).to contain_exactly(
|
||||||
|
'document-1.pdf',
|
||||||
|
'document-2.pdf'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with authorization' do
|
||||||
|
let(:submission) { create(:submission, :with_submitters, template:, created_by_user: author) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
submission.submitters.first.update!(completed_at: Time.current)
|
||||||
|
blob = ActiveStorage::Blob.create_and_upload!(
|
||||||
|
io: StringIO.new('test'),
|
||||||
|
filename: 'test.pdf',
|
||||||
|
content_type: 'application/pdf'
|
||||||
|
)
|
||||||
|
submission.submitters.first.documents.attach(blob)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns error when using testing API token for production submission' do
|
||||||
|
get "/api/submissions/#{submission.id}/signed_document_url",
|
||||||
|
headers: { 'x-auth-token': testing_author.access_token.token }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:forbidden)
|
||||||
|
expect(response.parsed_body['error']).to include('testing API key')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns error when using production API token for testing submission' do
|
||||||
|
testing_submission = create(:submission, :with_submitters, template: testing_template,
|
||||||
|
created_by_user: testing_author)
|
||||||
|
testing_submission.submitters.first.update!(completed_at: Time.current)
|
||||||
|
blob = ActiveStorage::Blob.create_and_upload!(
|
||||||
|
io: StringIO.new('test'),
|
||||||
|
filename: 'test.pdf',
|
||||||
|
content_type: 'application/pdf'
|
||||||
|
)
|
||||||
|
testing_submission.submitters.first.documents.attach(blob)
|
||||||
|
|
||||||
|
get "/api/submissions/#{testing_submission.id}/signed_document_url",
|
||||||
|
headers: { 'x-auth-token': author.access_token.token }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:forbidden)
|
||||||
|
expect(response.parsed_body['error']).to include('production API key')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns error when no auth token is provided' do
|
||||||
|
get "/api/submissions/#{submission.id}/signed_document_url"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
expect(response.parsed_body).to eq({ 'error' => 'Not authenticated' })
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises RecordNotFound when submission does not exist' do
|
||||||
|
expect do
|
||||||
|
get '/api/submissions/99999/signed_document_url',
|
||||||
|
headers: { 'x-auth-token': author.access_token.token }
|
||||||
|
end.to raise_error(ActiveRecord::RecordNotFound)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows access with valid token for same account' do
|
||||||
|
allow(Submissions::EnsureResultGenerated).to receive(:call).and_return(submission.submitters.first.documents)
|
||||||
|
|
||||||
|
get "/api/submissions/#{submission.id}/signed_document_url",
|
||||||
|
headers: { 'x-auth-token': author.access_token.token }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when EnsureResultGenerated fails' do
|
||||||
|
let(:submission) { create(:submission, :with_submitters, template:, created_by_user: author) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
submission.submitters.first.update!(completed_at: Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'propagates the error' do
|
||||||
|
allow(Submissions::EnsureResultGenerated).to receive(:call).and_raise(StandardError, 'Generation failed')
|
||||||
|
|
||||||
|
expect do
|
||||||
|
get "/api/submissions/#{submission.id}/signed_document_url",
|
||||||
|
headers: { 'x-auth-token': author.access_token.token }
|
||||||
|
end.to raise_error(StandardError, 'Generation failed')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Reference in new issue