mirror of https://github.com/docusealco/docuseal
CP-11535 Fix download filenames to exclude CloudFront query parameters (#36)
* Fix download filenames to exclude CloudFront query parameters - Strip query parameters from URLs before extracting filenames in download buttons - Add Content-Disposition headers to CloudFront signed URLs for proper browser filename handling * remove Download combined PDF * we don't do multiple submissions in the iframe, so this button is not needed * line length fix * add fallback filename and tests * rubocop changes * refactor build_cloudfront_url reduce complexity of method by extracting into other methods, also reduces need for more in line comments.pull/544/head
parent
692a64e039
commit
f2f2847908
@ -0,0 +1,246 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe DocumentSecurityService do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:blob) do
|
||||||
|
ActiveStorage::Blob.create_and_upload!(
|
||||||
|
io: Rails.root.join('spec/fixtures/sample-document.pdf').open,
|
||||||
|
filename: 'test-document.pdf',
|
||||||
|
content_type: 'application/pdf'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
let(:attachment) do
|
||||||
|
ActiveStorage::Attachment.create!(
|
||||||
|
blob: blob,
|
||||||
|
name: :documents,
|
||||||
|
record: account
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
ActiveStorage::Current.url_options = { host: 'test.example.com' }
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.signed_url_for' do
|
||||||
|
context 'when CloudFront is not configured' do
|
||||||
|
before do
|
||||||
|
allow(ENV).to receive(:fetch).with('CF_URL', nil).and_return(nil)
|
||||||
|
allow(ENV).to receive(:fetch).with('CF_KEY_PAIR_ID', nil).and_return(nil)
|
||||||
|
allow(ENV).to receive(:fetch).with('SECURE_ATTACHMENT_PRIVATE_KEY', nil).and_return(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns the regular attachment URL' do
|
||||||
|
result = described_class.signed_url_for(attachment)
|
||||||
|
expect(result).to eq(attachment.url)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when CloudFront is configured' do
|
||||||
|
let(:cloudfront_url) { 'https://d123456.cloudfront.net' }
|
||||||
|
let(:key_pair_id) { 'EXAMPLE_KEY' }
|
||||||
|
let(:private_key) { 'fake-private-key-for-testing' }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(ENV).to receive(:fetch).with('CF_URL', nil).and_return(cloudfront_url)
|
||||||
|
allow(ENV).to receive(:fetch).with('CF_KEY_PAIR_ID', nil).and_return(key_pair_id)
|
||||||
|
allow(ENV).to receive(:fetch).with('SECURE_ATTACHMENT_PRIVATE_KEY', nil).and_return(private_key)
|
||||||
|
end
|
||||||
|
|
||||||
|
after do
|
||||||
|
# Clear memoized signer between examples
|
||||||
|
described_class.instance_variable_set(:@cloudfront_signer, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'generates a signed CloudFront URL' do
|
||||||
|
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://signed-url.example.com')
|
||||||
|
|
||||||
|
result = described_class.signed_url_for(attachment)
|
||||||
|
|
||||||
|
expect(result).to eq('https://signed-url.example.com')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes Content-Disposition header in the URL' do
|
||||||
|
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://signed-url.example.com')
|
||||||
|
expected_url_pattern = %r{
|
||||||
|
#{Regexp.escape(cloudfront_url)}/docuseal/.*
|
||||||
|
\?response-content-disposition=.*filename%3D%22test-document\.pdf%22.*
|
||||||
|
&response-content-type=application%2Fpdf
|
||||||
|
}x
|
||||||
|
|
||||||
|
described_class.signed_url_for(attachment)
|
||||||
|
|
||||||
|
expect(signer).to have_received(:signed_url) do |url, **_options|
|
||||||
|
expect(url).to match(expected_url_pattern)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'properly escapes special characters in filename' do
|
||||||
|
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://signed-url.example.com')
|
||||||
|
|
||||||
|
special_blob = ActiveStorage::Blob.create_and_upload!(
|
||||||
|
io: StringIO.new('test'),
|
||||||
|
filename: 'document with spaces & special.pdf',
|
||||||
|
content_type: 'application/pdf'
|
||||||
|
)
|
||||||
|
special_attachment = ActiveStorage::Attachment.create!(
|
||||||
|
blob: special_blob,
|
||||||
|
name: :documents,
|
||||||
|
record: account
|
||||||
|
)
|
||||||
|
|
||||||
|
described_class.signed_url_for(special_attachment)
|
||||||
|
|
||||||
|
expect(signer).to have_received(:signed_url) do |url, **_options|
|
||||||
|
expect(url).to include('response-content-disposition=')
|
||||||
|
expect(url).to include(CGI.escape('document with spaces & special.pdf'))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses default filename when blob filename is empty' do
|
||||||
|
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://signed-url.example.com')
|
||||||
|
|
||||||
|
empty_blob = ActiveStorage::Blob.create_and_upload!(
|
||||||
|
io: StringIO.new('test'),
|
||||||
|
filename: '',
|
||||||
|
content_type: 'application/pdf'
|
||||||
|
)
|
||||||
|
empty_attachment = ActiveStorage::Attachment.create!(
|
||||||
|
blob: empty_blob,
|
||||||
|
name: :documents,
|
||||||
|
record: account
|
||||||
|
)
|
||||||
|
|
||||||
|
described_class.signed_url_for(empty_attachment)
|
||||||
|
|
||||||
|
expect(signer).to have_received(:signed_url) do |url, **_options|
|
||||||
|
decoded_url = CGI.unescape(url)
|
||||||
|
expect(decoded_url).to include('filename="download.pdf"')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'adds docuseal prefix to S3 key if not present' do
|
||||||
|
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://signed-url.example.com')
|
||||||
|
|
||||||
|
described_class.signed_url_for(attachment)
|
||||||
|
|
||||||
|
expect(signer).to have_received(:signed_url) do |url, **_options|
|
||||||
|
expect(url).to include('/docuseal/')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not duplicate docuseal prefix if already present' do
|
||||||
|
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://signed-url.example.com')
|
||||||
|
allow(blob).to receive(:key).and_return('docuseal/existing-key')
|
||||||
|
|
||||||
|
described_class.signed_url_for(attachment)
|
||||||
|
|
||||||
|
expect(signer).to have_received(:signed_url) do |url, **_options|
|
||||||
|
expect(url).to match(%r{/docuseal/[^/]})
|
||||||
|
expect(url).not_to match(%r{/docuseal/docuseal/})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'respects the expires_in parameter' do
|
||||||
|
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://signed-url.example.com')
|
||||||
|
expires_time = 2.hours.from_now
|
||||||
|
|
||||||
|
described_class.signed_url_for(attachment, expires_in: 2.hours)
|
||||||
|
|
||||||
|
expect(signer).to have_received(:signed_url) do |_url, **options|
|
||||||
|
expect(options[:expires]).to be_within(1).of(expires_time.to_i)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses inline disposition in Content-Disposition header' do
|
||||||
|
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://signed-url.example.com')
|
||||||
|
|
||||||
|
described_class.signed_url_for(attachment)
|
||||||
|
|
||||||
|
expect(signer).to have_received(:signed_url) do |url, **_options|
|
||||||
|
decoded_url = CGI.unescape(url)
|
||||||
|
expect(decoded_url).to include('inline; filename="test-document.pdf"')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when signing fails' do
|
||||||
|
it 'logs the error' do
|
||||||
|
signer = instance_double(Aws::CloudFront::UrlSigner)
|
||||||
|
allow(Aws::CloudFront::UrlSigner).to receive(:new).and_return(signer)
|
||||||
|
allow(signer).to receive(:signed_url).and_raise(StandardError.new('Signing failed'))
|
||||||
|
allow(Rails.logger).to receive(:error)
|
||||||
|
|
||||||
|
described_class.signed_url_for(attachment)
|
||||||
|
|
||||||
|
expect(Rails.logger).to have_received(:error).with(/Failed to generate signed URL: Signing failed/)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'falls back to the regular attachment URL' do
|
||||||
|
signer = instance_double(Aws::CloudFront::UrlSigner)
|
||||||
|
allow(Aws::CloudFront::UrlSigner).to receive(:new).and_return(signer)
|
||||||
|
allow(signer).to receive(:signed_url).and_raise(StandardError.new('Signing failed'))
|
||||||
|
allow(Rails.logger).to receive(:error)
|
||||||
|
|
||||||
|
result = described_class.signed_url_for(attachment)
|
||||||
|
expect(result).to eq(attachment.url)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with different content types' do
|
||||||
|
let(:cloudfront_url) { 'https://d123456.cloudfront.net' }
|
||||||
|
let(:key_pair_id) { 'EXAMPLE_KEY' }
|
||||||
|
let(:private_key) { 'fake-private-key-for-testing' }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(ENV).to receive(:fetch).with('CF_URL', nil).and_return(cloudfront_url)
|
||||||
|
allow(ENV).to receive(:fetch).with('CF_KEY_PAIR_ID', nil).and_return(key_pair_id)
|
||||||
|
allow(ENV).to receive(:fetch).with('SECURE_ATTACHMENT_PRIVATE_KEY', nil).and_return(private_key)
|
||||||
|
end
|
||||||
|
|
||||||
|
after do
|
||||||
|
# Clear memoized signer between examples
|
||||||
|
described_class.instance_variable_set(:@cloudfront_signer, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes the correct content type for images' do
|
||||||
|
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://signed-url.example.com')
|
||||||
|
image_blob = ActiveStorage::Blob.create_and_upload!(
|
||||||
|
io: StringIO.new('fake image'),
|
||||||
|
filename: 'image.jpg',
|
||||||
|
content_type: 'image/jpeg'
|
||||||
|
)
|
||||||
|
image_attachment = ActiveStorage::Attachment.create!(
|
||||||
|
blob: image_blob,
|
||||||
|
name: :documents,
|
||||||
|
record: account
|
||||||
|
)
|
||||||
|
|
||||||
|
described_class.signed_url_for(image_attachment)
|
||||||
|
|
||||||
|
expect(signer).to have_received(:signed_url) do |url, **_options|
|
||||||
|
expect(url).to include('response-content-type=image%2Fjpeg')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Reference in new issue