From 5abec47b94661283faf4b90f2f8636e3feec5722 Mon Sep 17 00:00:00 2001 From: Ryan Arakawa Date: Thu, 6 Nov 2025 13:51:30 -0600 Subject: [PATCH] Add download URL generator endpoint for API (#37) * Add download URL generator endpoint for API * rubocop/spec fixes * move logic out of view into controller * move build_signed_urls method logic into new SignedDocumentUrlBuilder service * slim down controller and its request specs --- .../api/signed_document_urls_controller.rb | 25 +++ app/models/submission.rb | 4 + app/services/signed_document_url_builder.rb | 43 +++++ config/routes.rb | 1 + spec/requests/signed_document_urls_spec.rb | 155 ++++++++++++++++++ .../signed_document_url_builder_spec.rb | 115 +++++++++++++ 6 files changed, 343 insertions(+) create mode 100644 app/controllers/api/signed_document_urls_controller.rb create mode 100644 app/services/signed_document_url_builder.rb create mode 100644 spec/requests/signed_document_urls_spec.rb create mode 100644 spec/services/signed_document_url_builder_spec.rb diff --git a/app/controllers/api/signed_document_urls_controller.rb b/app/controllers/api/signed_document_urls_controller.rb new file mode 100644 index 00000000..5b600fe7 --- /dev/null +++ b/app/controllers/api/signed_document_urls_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Api + class SignedDocumentUrlsController < ApiBaseController + load_and_authorize_resource :submission + + def show + last_submitter = @submission.last_completed_submitter + + if last_submitter.blank? + return render json: { error: 'Submission not completed' }, + status: :unprocessable_entity + end + + # Ensure documents are generated + Submissions::EnsureResultGenerated.call(last_submitter) + + render json: { + submission_id: @submission.id, + submitter_id: last_submitter.id, + documents: SignedDocumentUrlBuilder.new(last_submitter).call + } + end + end +end diff --git a/app/models/submission.rb b/app/models/submission.rb index 514bc541..2abfbb5b 100644 --- a/app/models/submission.rb +++ b/app/models/submission.rb @@ -102,6 +102,10 @@ class Submission < ApplicationRecord expire_at && expire_at <= Time.current end + def last_completed_submitter + submitters.where.not(completed_at: nil).order(:completed_at).last + end + def schema_documents if template_id? template_schema_documents diff --git a/app/services/signed_document_url_builder.rb b/app/services/signed_document_url_builder.rb new file mode 100644 index 00000000..831cf4c0 --- /dev/null +++ b/app/services/signed_document_url_builder.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class SignedDocumentUrlBuilder + URL_EXPIRATION_TIME = 1.hour + + def initialize(submitter) + @submitter = submitter + end + + def call + attachments.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 + + private + + attr_reader :submitter + + def attachments + Submitters.select_attachments_for_download(submitter) + end + + def generate_url(attachment) + if uses_secured_storage?(attachment) + DocumentSecurityService.signed_url_for(attachment) + else + ActiveStorage::Blob.proxy_url( + attachment.blob, + expires_at: URL_EXPIRATION_TIME.from_now.to_i + ) + end + end + + def uses_secured_storage?(attachment) + attachment.blob.service_name == 'aws_s3_secured' + end +end diff --git a/config/routes.rb b/config/routes.rb index 2ea9dc02..f886341d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -36,6 +36,7 @@ Rails.application.routes.draw do resources :submitters, only: %i[index show update] resources :submissions, only: %i[index show create destroy] do resources :documents, only: %i[index], controller: 'submission_documents' + resource :signed_document_url, only: %i[show] collection do resources :init, only: %i[create], controller: 'submissions' resources :emails, only: %i[create], controller: 'submissions', as: :submissions_emails diff --git a/spec/requests/signed_document_urls_spec.rb b/spec/requests/signed_document_urls_spec.rb new file mode 100644 index 00000000..add5df09 --- /dev/null +++ b/spec/requests/signed_document_urls_spec.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Signed Document URLs API' do + let(:account) { create(:account, :with_testing_account) } + let(:author) { create(:user, account:) } + let(:template) { create(:template, account:, author:) } + + 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 } + let(:builder) { instance_double(SignedDocumentUrlBuilder) } + let(:fake_docs) { [{ name: 'completed-document.pdf', url: 'http://example.com/doc', size_bytes: 123, content_type: 'application/pdf' }] } + + before do + completed_submitter.update!(completed_at: Time.current) + allow(SignedDocumentUrlBuilder).to receive(:new).with(completed_submitter).and_return(builder) + allow(builder).to receive(:call).and_return(fake_docs) + end + + it 'returns signed URLs for completed documents' do + allow(Submissions::EnsureResultGenerated).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(: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'].first['name']).to eq('completed-document.pdf') + end + + it 'calls EnsureResultGenerated to generate documents' do + allow(Submissions::EnsureResultGenerated).to receive(:call) + + get "/api/submissions/#{submission.id}/signed_document_url", + headers: { 'x-auth-token': author.access_token.token } + + expect(SignedDocumentUrlBuilder).to have_received(:new).with(completed_submitter) + expect(builder).to have_received(:call) + 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 + allow(Submissions::EnsureResultGenerated).to receive(:call) + + get "/api/submissions/#{submission.id}/signed_document_url", + headers: { 'x-auth-token': author.access_token.token } + + expect(Submissions::EnsureResultGenerated).not_to have_received(:call) + 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) } + let(:last_submitter) { submission.submitters.last } + let(:builder) { instance_double(SignedDocumentUrlBuilder, call: [{ name: 'second-document.pdf', url: 'http://example.com/2', size_bytes: 1, content_type: 'application/pdf' }]) } + + before do + submission.submitters.first.update!(completed_at: 1.hour.ago) + last_submitter.update!(completed_at: Time.current) + allow(Submissions::EnsureResultGenerated).to receive(:call) + allow(SignedDocumentUrlBuilder).to receive(:new).with(last_submitter).and_return(builder) + end + + it 'returns documents from the last completed submitter' do + 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(last_submitter.id) + expect(response.parsed_body['documents'].first['name']).to eq('second-document.pdf') + end + end + + context 'with authorization' do + let(:testing_account) { account.testing_accounts.first } + let(:testing_author) { create(:user, account: testing_account) } + let(:testing_template) { create(:template, account: testing_account, author: testing_author) } + let(:submission) { create(:submission, :with_submitters, template:, created_by_user: author) } + + before { submission.submitters.first.update!(completed_at: Time.current) } + + 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) + + 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 + 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 diff --git a/spec/services/signed_document_url_builder_spec.rb b/spec/services/signed_document_url_builder_spec.rb new file mode 100644 index 00000000..57dd8e85 --- /dev/null +++ b/spec/services/signed_document_url_builder_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SignedDocumentUrlBuilder do + let(:account) { create(:account) } + let(:author) { create(:user, account:) } + let(:template) { create(:template, account:, author:) } + let(:submission) { create(:submission, :with_submitters, template:, account:, created_by_user: author) } + let(:submitter) { submission.submitters.first } + let(:blob) do + ActiveStorage::Blob.create_and_upload!( + io: StringIO.new('test pdf content'), + filename: 'test-document.pdf', + content_type: 'application/pdf' + ) + end + + before do + ActiveStorage::Current.url_options = { host: 'test.example.com' } + submitter.update!(completed_at: Time.current) + submitter.documents.attach(blob) + end + + describe '#call' do + subject(:builder) { described_class.new(submitter) } + + it 'returns an array of document hashes' do + result = builder.call + + expect(result).to be_an(Array) + expect(result.size).to eq(1) + end + + it 'includes document metadata' do + result = builder.call.first + + expect(result[:name]).to eq('test-document.pdf') + expect(result[:url]).to be_present + expect(result[:size_bytes]).to be_a(Integer) + expect(result[:content_type]).to eq('application/pdf') + end + + context 'with standard storage' do + it 'generates ActiveStorage proxy URLs' do + result = builder.call.first + + expect(result[:url]).to include('/file/') + end + + it 'includes expiration time in the URL' do + expected_expiration = 1.hour.from_now.to_i + + allow(ActiveStorage::Blob).to receive(:proxy_url).and_call_original + builder.call + + expect(ActiveStorage::Blob).to have_received(:proxy_url) + .with(blob, hash_including(expires_at: expected_expiration)) + end + end + + context 'with secured storage (aws_s3_secured)' do + subject(:builder) { described_class.new(submitter_two) } + + let(:submitter_two) { submission.submitters.second || create(:submitter, submission:, uuid: 'submitter-uuid-1') } + let(:secured_blob) do + ActiveStorage::Blob.create_and_upload!( + io: StringIO.new('test pdf content'), + filename: 'secured-document.pdf', + content_type: 'application/pdf' + ) + end + + before do + submitter_two.update!(completed_at: Time.current) + submitter_two.documents.attach(secured_blob) + + # Stub the service_name method to simulate secured storage + # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(ActiveStorage::Blob).to receive(:service_name).and_return('aws_s3_secured') + # rubocop:enable RSpec/AnyInstance + end + + it 'uses DocumentSecurityService for signed URLs' do + allow(DocumentSecurityService).to receive(:signed_url_for) + .and_return('https://signed-url.example.com') + + result = builder.call.first + + expect(result[:url]).to eq('https://signed-url.example.com') + end + end + + context 'with multiple documents' do + let(:blob2) do + ActiveStorage::Blob.create_and_upload!( + io: StringIO.new('second pdf content'), + filename: 'second-document.pdf', + content_type: 'application/pdf' + ) + end + + before do + submitter.documents.attach(blob2) + end + + it 'returns all documents' do + result = builder.call + + expect(result.size).to eq(2) + expect(result.pluck(:name)).to contain_exactly('test-document.pdf', 'second-document.pdf') + end + end + end +end