From ebf58a6e9b7babffa129b69fb7465f5fb549d8e6 Mon Sep 17 00:00:00 2001 From: Dan Lim <54864006+danlim715@users.noreply.github.com> Date: Thu, 19 Dec 2024 16:20:28 -0600 Subject: [PATCH 1/3] Validate document attachments at time of upload (#19532) * Validate document attachments at time of upload * Added flipper for extra validation steps --------- Co-authored-by: Wayne Weibel --- .github/CODEOWNERS | 3 + .../v0/claim_documents_controller.rb | 66 +++++++++-- app/models/persistent_attachment.rb | 3 + config/features.yml | 4 + lib/claim_documents/monitor.rb | 48 ++++++++ spec/fixtures/files/tiny.pdf | Bin 0 -> 291 bytes spec/lib/claim_documents/monitor_spec.rb | 50 ++++++++ spec/requests/swagger_spec.rb | 38 +++--- spec/requests/v0/claim_documents_spec.rb | 111 ++++++++++-------- .../uploads/validate_document.yml | 49 ++++++++ 10 files changed, 295 insertions(+), 77 deletions(-) create mode 100644 lib/claim_documents/monitor.rb create mode 100755 spec/fixtures/files/tiny.pdf create mode 100644 spec/lib/claim_documents/monitor_spec.rb create mode 100644 spec/support/vcr_cassettes/uploads/validate_document.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a1c5f57f338..65d46e7489d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -851,6 +851,7 @@ lib/caseflow @department-of-veterans-affairs/lighthouse-banana-peels @department lib/central_mail @department-of-veterans-affairs/va-api-engineers @department-of-veterans-affairs/backend-review-group lib/chip @department-of-veterans-affairs/vsa-healthcare-health-quest-1-backend @department-of-veterans-affairs/patient-check-in @department-of-veterans-affairs/backend-review-group lib/claim_letters @department-of-veterans-affairs/benefits-management-tools-be @department-of-veterans-affairs/va-api-engineers @department-of-veterans-affairs/backend-review-group +lib/claim_documents/monitor.rb @department-of-veterans-affairs/pension-and-burials @department-of-veterans-affairs/backend-review-group lib/clamav @department-of-veterans-affairs/backend-review-group lib/common/client/base.rb @department-of-veterans-affairs/va-api-engineers @department-of-veterans-affairs/backend-review-group lib/common/client/concerns/mhv_fhir_session_client.rb @department-of-veterans-affairs/vfs-mhv-medical-records @department-of-veterans-affairs/va-api-engineers @department-of-veterans-affairs/backend-review-group @@ -1419,6 +1420,7 @@ spec/lib/carma @department-of-veterans-affairs/vfs-10-10 @department-of-veterans spec/lib/caseflow @department-of-veterans-affairs/lighthouse-banana-peels @department-of-veterans-affairs/va-api-engineers @department-of-veterans-affairs/backend-review-group spec/lib/central_mail @department-of-veterans-affairs/va-api-engineers @department-of-veterans-affairs/backend-review-group spec/lib/chip @department-of-veterans-affairs/vsa-healthcare-health-quest-1-backend @department-of-veterans-affairs/patient-check-in @department-of-veterans-affairs/backend-review-group +spec/lib/claim_documents/monitor_spec.rb @department-of-veterans-affairs/pension-and-burials @department-of-veterans-affairs/backend-review-group spec/lib/claim_status_tool @department-of-veterans-affairs/benefits-management-tools-be @department-of-veterans-affairs/va-api-engineers @department-of-veterans-affairs/backend-review-group spec/lib/common/client/concerns/mhv_fhir_session_client_spec.rb @department-of-veterans-affairs/vfs-mhv-medical-records @department-of-veterans-affairs/va-api-engineers @department-of-veterans-affairs/backend-review-group spec/lib/common/client/concerns/mhv_jwt_session_client_spec.rb @department-of-veterans-affairs/vfs-mhv-medical-records @department-of-veterans-affairs/va-api-engineers @department-of-veterans-affairs/backend-review-group @@ -2127,6 +2129,7 @@ spec/support/vcr_cassettes/spec/support @department-of-veterans-affairs/octo-ide spec/support/vcr_cassettes/staccato @department-of-veterans-affairs/vfs-10-10 @department-of-veterans-affairs/va-api-engineers @department-of-veterans-affairs/backend-review-group spec/support/vcr_cassettes/token_validation @department-of-veterans-affairs/lighthouse-banana-peels @department-of-veterans-affairs/va-api-engineers @department-of-veterans-affairs/backend-review-group spec/support/vcr_cassettes/travel_pay @department-of-veterans-affairs/travel-pay-integration @department-of-veterans-affairs/backend-review-group +spec/support/vcr_cassettes/uploads/validate_document.yml @department-of-veterans-affairs/pension-and-burials @department-of-veterans-affairs/backend-review-group spec/spupport/vcr_cassettes/user/get_facilities_empty.yml @department-of-veterans-affairs/vfs-facilities-frontend @department-of-veterans-affairs/va-api-engineers @department-of-veterans-affairs/backend-review-group spec/support/vcr_cassettes/va_forms @department-of-veterans-affairs/platform-va-product-forms @department-of-veterans-affairs/va-api-engineers @department-of-veterans-affairs/backend-review-group spec/support/vcr_cassettes/va_notify @department-of-veterans-affairs/va-notify-write @department-of-veterans-affairs/va-api-engineers @department-of-veterans-affairs/backend-review-group diff --git a/app/controllers/v0/claim_documents_controller.rb b/app/controllers/v0/claim_documents_controller.rb index 86449fe0812..2fef551ea4a 100644 --- a/app/controllers/v0/claim_documents_controller.rb +++ b/app/controllers/v0/claim_documents_controller.rb @@ -2,28 +2,38 @@ require 'pension_burial/tag_sentry' require 'lgy/tag_sentry' +require 'claim_documents/monitor' +require 'lighthouse/benefits_intake/service' +require 'pdf_utilities/datestamp_pdf' module V0 class ClaimDocumentsController < ApplicationController service_tag 'claims-shared' skip_before_action(:authenticate) + before_action :load_user def create - Rails.logger.info "Creating PersistentAttachment FormID=#{form_id}" + uploads_monitor.track_document_upload_attempt(form_id, current_user) - attachment = klass.new(form_id:) + @attachment = klass&.new(form_id:) # add the file after so that we have a form_id and guid for the uploader to use - attachment.file = unlock_file(params['file'], params['password']) + @attachment.file = unlock_file(params['file'], params['password']) - raise Common::Exceptions::ValidationErrors, attachment unless attachment.valid? + if %w[21P-527EZ 21P-530 21P-530V2].include?(form_id) && + Flipper.enabled?(:document_upload_validation_enabled) && !stamped_pdf_valid? - attachment.save + raise Common::Exceptions::ValidationErrors, @attachment + end + + raise Common::Exceptions::ValidationErrors, @attachment unless @attachment.valid? - Rails.logger.info "Success creating PersistentAttachment FormID=#{form_id} AttachmentID=#{attachment.id}" + @attachment.save - render json: PersistentAttachmentSerializer.new(attachment) + uploads_monitor.track_document_upload_success(form_id, @attachment.id, current_user) + + render json: PersistentAttachmentSerializer.new(@attachment) rescue => e - Rails.logger.error "Error creating PersistentAttachment FormID=#{form_id} AttachmentID=#{attachment.id} #{e}" + uploads_monitor.track_document_upload_failed(form_id, @attachment&.id, current_user, e) raise e end @@ -31,7 +41,7 @@ def create def klass case form_id - when '21P-527EZ', '21P-530EZ' + when '21P-527EZ', '21P-530EZ', '21P-530V2' PensionBurial::TagSentry.tag_sentry PersistentAttachments::PensionBurial when '21-686C', '686C-674' @@ -47,7 +57,7 @@ def form_id end def unlock_file(file, file_password) - return file unless File.extname(file) == '.pdf' && file_password + return file unless File.extname(file) == '.pdf' && file_password.present? pdftk = PdfForms.new(Settings.binaries.pdftk) tmpf = Tempfile.new(['decrypted_form_attachment', '.pdf']) @@ -69,5 +79,41 @@ def unlock_file(file, file_password) file.tempfile = tmpf file end + + # rubocop:disable Metrics/MethodLength + def stamped_pdf_valid? + extension = File.extname(@attachment&.file&.id) + allowed_types = PersistentAttachment::ALLOWED_DOCUMENT_TYPES + + if allowed_types.exclude?(extension) + raise Common::Exceptions::UnprocessableEntity.new( + detail: I18n.t('errors.messages.extension_allowlist_error', extension:, allowed_types:), + source: 'PersistentAttachment.stamped_pdf_valid?' + ) + elsif @attachment&.file&.size&.< PersistentAttachment::MINIMUM_FILE_SIZE + raise Common::Exceptions::UnprocessableEntity.new( + detail: 'File size must not be less than 1.0 KB', + source: 'PersistentAttachment.stamped_pdf_valid?' + ) + end + + document = PDFUtilities::DatestampPdf.new(@attachment.to_pdf).run(text: 'VA.GOV', x: 5, y: 5) + intake_service.valid_document?(document:) + rescue BenefitsIntake::Service::InvalidDocumentError => e + @attachment.errors.add(:attachment, e.message) + false + rescue PdfForms::PdftkError + @attachment.errors.add(:attachment, 'File is corrupt and cannot be uploaded') + false + end + # rubocop:enable Metrics/MethodLength + + def intake_service + @intake_service ||= BenefitsIntake::Service.new + end + + def uploads_monitor + @uploads_monitor ||= ClaimDocuments::Monitor.new + end end end diff --git a/app/models/persistent_attachment.rb b/app/models/persistent_attachment.rb index f37730ed72b..ac967d3f5af 100644 --- a/app/models/persistent_attachment.rb +++ b/app/models/persistent_attachment.rb @@ -8,6 +8,9 @@ class PersistentAttachment < ApplicationRecord include SetGuid + ALLOWED_DOCUMENT_TYPES = %w[.pdf .jpg .jpeg .png].freeze + MINIMUM_FILE_SIZE = 1.kilobyte.freeze + has_kms_key has_encrypted :file_data, key: :kms_key, **lockbox_options belongs_to :saved_claim, inverse_of: :persistent_attachments, optional: true diff --git a/config/features.yml b/config/features.yml index c4d42a7791a..ef8e771bc7d 100644 --- a/config/features.yml +++ b/config/features.yml @@ -90,6 +90,10 @@ features: disability_compensation_staging_lighthouse_brd: actor_type: user description: Switches to Lighthouse Staging BRD Service. NEVER ENABLE IN PRODUCTION. + document_upload_validation_enabled: + actor_type: user + description: Enables stamped PDF validation on document upload + enable_in_development: true hca_browser_monitoring_enabled: actor_type: user description: Enables browser monitoring for the health care application. diff --git a/lib/claim_documents/monitor.rb b/lib/claim_documents/monitor.rb new file mode 100644 index 00000000000..14c68d398c7 --- /dev/null +++ b/lib/claim_documents/monitor.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'logging/monitor' + +module ClaimDocuments + ## + # Monitor functions for Rails logging and StatsD + # @todo abstract, split logging for controller and sidekiq + # + class Monitor < ::Logging::Monitor + # statsd key for document uploads + DOCUMENT_STATS_KEY = 'api.claim_documents' + + def initialize + super('claim_documents') + end + + def track_document_upload_attempt(form_id, current_user) + additional_context = { + user_account_uuid: current_user&.user_account_uuid, + tags: ["form_id:#{form_id}"] + } + track_request('info', "Creating PersistentAttachment FormID=#{form_id}", "#{DOCUMENT_STATS_KEY}.attempt", + **additional_context) + end + + def track_document_upload_success(form_id, attachment_id, current_user) + additional_context = { + attachment_id:, + user_account_uuid: current_user&.user_account_uuid, + tags: ["form_id:#{form_id}"] + } + track_request('info', "Success creating PersistentAttachment FormID=#{form_id} AttachmentID=#{attachment_id}", + "#{DOCUMENT_STATS_KEY}.success", **additional_context) + end + + def track_document_upload_failed(form_id, attachment_id, current_user, e) + additional_context = { + attachment_id:, + user_account_uuid: current_user&.user_account_uuid, + tags: ["form_id:#{form_id}"], + message: e&.message + } + track_request('error', "Error creating PersistentAttachment FormID=#{form_id} AttachmentID=#{attachment_id} #{e}", + "#{DOCUMENT_STATS_KEY}.failure", **additional_context) + end + end +end diff --git a/spec/fixtures/files/tiny.pdf b/spec/fixtures/files/tiny.pdf new file mode 100755 index 0000000000000000000000000000000000000000..593558f9a45127a9d0c28d7a7a98b718b96bffe5 GIT binary patch literal 291 zcmZ9HK?{OF5QXmx`yb}wv355#2*g8{mx#o=iH8k0F~kks!20!#nijsp!oK%CX2yzk z;X*7qB?36;>)rF%<@Hc3kVcj|XOYZR11k(;-&a+JNdNmodxRZ|tV!&SOIe_wl>spo zI(t@NN0k+FtJ{QQXoH=OG$n1VZj^9v@R { - 'form_id' => '21P-530EZ', - file: fixture_file_upload('spec/fixtures/files/doctors-note.pdf') - } - ) + VCR.use_cassette('uploads/validate_document') do + expect(subject).to validate( + :post, + '/v0/claim_attachments', + 200, + '_data' => { + 'form_id' => '21P-530EZ', + file: fixture_file_upload('spec/fixtures/files/doctors-note.pdf') + } + ) - expect(subject).to validate( - :post, - '/v0/claim_attachments', - 422, - '_data' => { - 'form_id' => '21P-530EZ', - file: fixture_file_upload('spec/fixtures/files/empty_file.txt') - } - ) + expect(subject).to validate( + :post, + '/v0/claim_attachments', + 422, + '_data' => { + 'form_id' => '21P-530EZ', + file: fixture_file_upload('spec/fixtures/files/empty_file.txt') + } + ) + end end it 'supports checking stem_claim_status' do diff --git a/spec/requests/v0/claim_documents_spec.rb b/spec/requests/v0/claim_documents_spec.rb index 32446d2818d..ec1a0148505 100644 --- a/spec/requests/v0/claim_documents_spec.rb +++ b/spec/requests/v0/claim_documents_spec.rb @@ -16,69 +16,80 @@ end it 'uploads a file' do - params = { file:, form_id: '21P-527EZ' } - expect do - post('/v0/claim_documents', params:) - end.to change(PersistentAttachment, :count).by(1) - expect(response).to have_http_status(:ok) - resp = JSON.parse(response.body) - expect(resp['data']['attributes'].keys.sort).to eq(%w[confirmation_code name size]) - expect(PersistentAttachment.last).to be_a(PersistentAttachments::PensionBurial) + VCR.use_cassette('uploads/validate_document') do + params = { file:, form_id: '21P-527EZ' } + expect do + post('/v0/claim_documents', params:) + end.to change(PersistentAttachment, :count).by(1) + expect(response).to have_http_status(:ok) + resp = JSON.parse(response.body) + expect(resp['data']['attributes'].keys.sort).to eq(%w[confirmation_code name size]) + expect(PersistentAttachment.last).to be_a(PersistentAttachments::PensionBurial) + end end it 'uploads a file to the alternate route' do - params = { file:, form_id: '21P-527EZ' } - expect do - post('/v0/claim_attachments', params:) - end.to change(PersistentAttachment, :count).by(1) - expect(response).to have_http_status(:ok) - resp = JSON.parse(response.body) - expect(resp['data']['attributes'].keys.sort).to eq(%w[confirmation_code name size]) - expect(PersistentAttachment.last).to be_a(PersistentAttachments::PensionBurial) + VCR.use_cassette('uploads/validate_document') do + params = { file:, form_id: '21P-527EZ' } + expect do + post('/v0/claim_attachments', params:) + end.to change(PersistentAttachment, :count).by(1) + expect(response).to have_http_status(:ok) + resp = JSON.parse(response.body) + expect(resp['data']['attributes'].keys.sort).to eq(%w[confirmation_code name size]) + expect(PersistentAttachment.last).to be_a(PersistentAttachments::PensionBurial) + end end it 'logs a successful upload' do - expect(Rails.logger).to receive(:info).with('Creating PersistentAttachment FormID=21P-527EZ') - expect(Rails.logger).to receive(:info).with( - /^Success creating PersistentAttachment FormID=21P-527EZ AttachmentID=\d+/ - ) - expect(Rails.logger).not_to receive(:error).with( - 'Error creating PersistentAttachment FormID=21P-527EZ AttachmentID= Common::Exceptions::ValidationErrors' - ) + VCR.use_cassette('uploads/validate_document') do + expect(Rails.logger).to receive(:info).with('Creating PersistentAttachment FormID=21P-527EZ', instance_of(Hash)) + expect(Rails.logger).to receive(:info).with( + /^Success creating PersistentAttachment FormID=21P-527EZ AttachmentID=\d+/, instance_of(Hash) + ) + expect(Rails.logger).not_to receive(:error).with( + 'Error creating PersistentAttachment FormID=21P-527EZ AttachmentID= Common::Exceptions::ValidationErrors' + ) - params = { file:, form_id: '21P-527EZ' } - expect do - post('/v0/claim_documents', params:) - end.to change(PersistentAttachment, :count).by(1) + params = { file:, form_id: '21P-527EZ' } + expect do + post('/v0/claim_documents', params:) + end.to change(PersistentAttachment, :count).by(1) + end end end context 'with an invalid file' do - let(:file) do - fixture_file_upload('empty_file.txt', 'text/plain') - end + let(:file) { fixture_file_upload('empty-file.jpg') } it 'does not upload the file' do - params = { file:, form_id: '21P-527EZ' } - expect do - post('/v0/claim_attachments', params:) - end.not_to change(PersistentAttachment, :count) - expect(response).to have_http_status(:unprocessable_entity) - resp = JSON.parse(response.body) - expect(resp['errors'][0]['title']).to eq('File size must not be less than 1.0 KB') + VCR.use_cassette('uploads/validate_document') do + params = { file:, form_id: '21P-527EZ' } + expect do + post('/v0/claim_attachments', params:) + end.not_to change(PersistentAttachment, :count) + expect(response).to have_http_status(:unprocessable_entity) + resp = JSON.parse(response.body) + expect(resp['errors'][0]['detail']).to eq('File size must not be less than 1.0 KB') + end end it 'logs the error' do - expect(Rails.logger).to receive(:info).with('Creating PersistentAttachment FormID=21P-527EZ') - expect(Rails.logger).not_to receive(:info).with( - /^Success creating PersistentAttachment FormID=21P-527EZ AttachmentID=\d+/ - ) - expect(Rails.logger).to receive(:error).with( - 'Error creating PersistentAttachment FormID=21P-527EZ AttachmentID= Common::Exceptions::ValidationErrors' - ) + VCR.use_cassette('uploads/validate_document') do + expect(Rails.logger).to receive(:info).with('Creating PersistentAttachment FormID=21P-527EZ', + hash_including(user_account_uuid: nil, + statsd: 'api.claim_documents.attempt')) + expect(Rails.logger).not_to receive(:info).with( + /^Success creating PersistentAttachment FormID=21P-527EZ AttachmentID=\d+/ + ) + expect(Rails.logger).to receive(:error).with( + 'Error creating PersistentAttachment FormID=21P-527EZ AttachmentID= Common::Exceptions::UnprocessableEntity', + instance_of(Hash) + ) - params = { file:, form_id: '21P-527EZ' } - post('/v0/claim_attachments', params:) + params = { file:, form_id: '21P-527EZ' } + post('/v0/claim_attachments', params:) + end end end @@ -88,9 +99,11 @@ end it 'does not raise an error when password is correct' do - params = { file:, form_id: '26-1880', password: 'test' } - post('/v0/claim_attachments', params:) - expect(response).to have_http_status(:ok) + VCR.use_cassette('uploads/validate_document') do + params = { file:, form_id: '26-1880', password: 'test' } + post('/v0/claim_attachments', params:) + expect(response).to have_http_status(:ok) + end end it 'raises an error when password is incorrect' do diff --git a/spec/support/vcr_cassettes/uploads/validate_document.yml b/spec/support/vcr_cassettes/uploads/validate_document.yml new file mode 100644 index 00000000000..cf4ca401aa8 --- /dev/null +++ b/spec/support/vcr_cassettes/uploads/validate_document.yml @@ -0,0 +1,49 @@ +--- +http_interactions: +- request: + method: post + uri: "/services/vba_documents/v1/uploads/validate_document" + body: + encoding: ASCII-8BIT + string: !binary |- + JVBERi0xLjMKJeLjz9MKMSAwIG9iagoKPDwKL1N1YnR5cGUgL0Zvcm0KL1R5cGUgL1hPYmplY3QKL01hdHJpeCBbMSAwIDAgMSAwIDBdCi9Gb3JtVHlwZSAxCi9SZXNvdXJjZXMgCjw8Ci9Qcm9jU2V0IFsvUERGIC9UZXh0IC9JbWFnZUIgL0ltYWdlQyAvSW1hZ2VJXQovRm9udCAKPDwKL0YxLjAgMiAwIFIKPj4KPj4KL0xlbmd0aCAxMTUKL0JCb3ggWzAgMCA2MTIgNzkyXQo+PgpzdHJlYW0KcQoKQlQKNS4wIDUuMCBUZAovRjEuMCAxMCBUZgpbPDU2PiA4MCA8NDEyZTQ3NGY+IDUwIDw1NjIwMzIzMDMyMzQyZDMxMzIyZDMwMzMyMDMwMzUzYTM1MzEyMDUwNGQyMDU1NTQ0Mz5dIFRKCkVUCgpRCgplbmRzdHJlYW0gCmVuZG9iagoKMiAwIG9iagoKPDwKL1N1YnR5cGUgL1R5cGUxCi9CYXNlRm9udCAvSGVsdmV0aWNhCi9UeXBlIC9Gb250Ci9FbmNvZGluZyAvV2luQW5zaUVuY29kaW5nCj4+CmVuZG9iagoKMyAwIG9iagoKPDwKL0ZpbHRlciAvRmxhdGVEZWNvZGUKL0xlbmd0aCAxMAo+PgpzdHJlYW0KeJwr5AIAAO4AfAplbmRzdHJlYW0gCmVuZG9iagoKNCAwIG9iagoKPDwKL0ZpbHRlciAvRmxhdGVEZWNvZGUKL0xlbmd0aCAzNgo+PgpzdHJlYW0KeJxTCOQq5CpUMFQwAEIImZyroB+RaaDgkq8QyBXIBQB5OAcjCmVuZHN0cmVhbSAKZW5kb2JqCgo1IDAgb2JqCgo8PAovQ29udGVudHMgWzMgMCBSIDYgMCBSIDQgMCBSXQovVHlwZSAvUGFnZQovUmVzb3VyY2VzIAo8PAovQ29sb3JTcGFjZSAKPDwKL0NzMSA3IDAgUgo+PgovUHJvY1NldCBbL1BERiAvVGV4dCAvSW1hZ2VCIC9JbWFnZUMgL0ltYWdlSV0KL0ZvbnQgCjw8Ci9UVDEgOCAwIFIKPj4KL1hPYmplY3QgCjw8Ci9YaTAgMSAwIFIKPj4KPj4KL1BhcmVudCA5IDAgUgovTWVkaWFCb3ggWzAgMCA2MTIgNzkyXQo+PgplbmRvYmoKCjkg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CjAwMDAwMDAzNTcgMDAwMDAgbiAKMDAwMDAwMDQ1NiAwMDAwMCBuIAowMDAwMDAwNTQwIDAwMDAwIG4gCjAwMDAwMDA2NTAgMDAwMDAgbiAKMDAwMDAwMDk4MSAwMDAwMCBuIAowMDAwMDAxMzkxIDAwMDAwIG4gCjAwMDAwMDE0MjYgMDAwMDAgbiAKMDAwMDAwMDg5OCAwMDAwMCBuIAowMDAwMDAxMjkxIDAwMDAwIG4gCjAwMDAwMDE4MTEgMDAwMDAgbiAKMDAwMDAwMzA4NCAwMDAwMCBuIAowMDAwMDAzMDMyIDAwMDAwIG4gCjAwMDAwMDMzMzYgMDAwMDAgbiAKMDAwMDAxMDIyMSAwMDAwMCBuIAowMDAwMDEwMjU0IDAwMDAwIG4gCjAwMDAwMTAzMDggMDAwMDAgbiAKMDAwMDAxMDM1MSAwMDAwMCBuIAowMDAwMDEwMzcxIDAwMDAwIG4gCjAwMDAwMTAzOTkgMDAwMDAgbiAKMDAwMDAxMDQ0MiAwMDAwMCBuIAowMDAwMDEwNDYyIDAwMDAwIG4gCjAwMDAwMTA0ODQgMDAwMDAgbiAKdHJhaWxlcgoKPDwKL0luZm8gMjMgMCBSCi9JRCBbPDU0NDVhZTJmNzM3ZGRiMjA1MmVhZDIzMDZmNzk4NmYyPiA8NTQ0NWFlMmY3MzdkZGIyMDUyZWFkMjMwNmY3OTg2ZjI+XQovUm9vdCAxMyAwIFIKL1NpemUgMjQKPj4Kc3RhcnR4cmVmCjEwNzUxCiUlRU9GCg== + headers: + Accept: + - application/json + Content-Type: + - application/pdf + User-Agent: + - Vets.gov Agent + Apikey: + - fake_api_key + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Date: + - Tue, 03 Dec 2024 17:51:46 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '52' + Connection: + - keep-alive + Access-Control-Allow-Origin: + - "*" + Cache-Control: + - '' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + X-Frame-Options: + - SAMEORIGIN + body: + encoding: UTF-8 + string: |- + { + "message":"Invalid authentication credentials" + } + recorded_at: Tue, 03 Dec 2024 17:51:46 GMT +recorded_with: VCR 6.3.1 From 816cbcb25e8ed13cab27b50af4e31e00723966c1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Dec 2024 22:27:40 +0000 Subject: [PATCH 2/3] Bump pg_query from 5.1.0 to 6.0.0 (#19912) Bumps [pg_query](https://github.com/pganalyze/pg_query) from 5.1.0 to 6.0.0. - [Changelog](https://github.com/pganalyze/pg_query/blob/main/CHANGELOG.md) - [Commits](https://github.com/pganalyze/pg_query/compare/v5.1.0...v6.0.0) --- updated-dependencies: - dependency-name: pg_query dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 977c8d6d7b2..748a97c8e45 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -764,8 +764,8 @@ GEM ruby-rc4 ttfunk pg (1.5.9) - pg_query (5.1.0) - google-protobuf (>= 3.22.3) + pg_query (6.0.0) + google-protobuf (>= 3.25.3) pg_search (2.3.7) activerecord (>= 6.1) activesupport (>= 6.1) From 291311920e66d592dcab0d30989bcdfd78b45320 Mon Sep 17 00:00:00 2001 From: Brandon Cooper Date: Fri, 20 Dec 2024 08:56:22 -0500 Subject: [PATCH 3/3] [10-10EZ] Add VANotify Callbacks to Failure email (#19964) * add callback metadata to send failure email * add hca_zero_silent_failures flipper toggle and use it for metadata --- app/models/health_care_application.rb | 27 ++++++--- config/features.yml | 3 + spec/models/health_care_application_spec.rb | 66 ++++++++++++++++++--- 3 files changed, 78 insertions(+), 18 deletions(-) diff --git a/app/models/health_care_application.rb b/app/models/health_care_application.rb index 5fc081a9527..090b2ea5f55 100644 --- a/app/models/health_care_application.rb +++ b/app/models/health_care_application.rb @@ -14,6 +14,10 @@ class HealthCareApplication < ApplicationRecord FORM_ID = '10-10EZ' ACTIVEDUTY_ELIGIBILITY = 'TRICARE' DISABILITY_THRESHOLD = 50 + DD_ZSF_TAGS = [ + 'service:healthcare-application', + 'function: 10-10EZ async form submission' + ].freeze LOCKBOX = Lockbox.new(key: Settings.lockbox.master_key, encode: true) attr_accessor :user, :async_compatible, :google_analytics_client_id, :form @@ -255,15 +259,14 @@ def log_sync_submission_failure end def log_async_submission_failure - log_zero_silent_failures + log_zero_silent_failures unless Flipper.enabled?(:hca_zero_silent_failures) StatsD.increment("#{HCA::Service::STATSD_KEY_PREFIX}.failed_wont_retry") StatsD.increment("#{HCA::Service::STATSD_KEY_PREFIX}.failed_wont_retry_short_form") if short_form? log_submission_failure_details end def log_zero_silent_failures - tags = ['service:healthcare-application', 'function: 10-10EZ async form submission'] - StatsD.increment('silent_failure_avoided_no_confirmation', tags:) + StatsD.increment('silent_failure_avoided_no_confirmation', tags: DD_ZSF_TAGS) end def log_submission_failure_details @@ -292,13 +295,19 @@ def send_failure_email api_key = Settings.vanotify.services.health_apps_1010.api_key salutation = first_name ? "Dear #{first_name}," : '' + metadata = + { + callback_metadata: { + notification_type: 'error', + form_number: FORM_ID, + statsd_tags: DD_ZSF_TAGS + } + } - VANotify::EmailJob.perform_async( - email, - template_id, - { 'salutation' => salutation }, - api_key - ) + params = [email, template_id, { 'salutation' => salutation }, api_key] + params << metadata if Flipper.enabled?(:hca_zero_silent_failures) + + VANotify::EmailJob.perform_async(*params) StatsD.increment("#{HCA::Service::STATSD_KEY_PREFIX}.submission_failure_email_sent") rescue => e log_exception_to_sentry(e) diff --git a/config/features.yml b/config/features.yml index ef8e771bc7d..561628c9f0e 100644 --- a/config/features.yml +++ b/config/features.yml @@ -132,6 +132,9 @@ features: hca_retrieve_facilities_without_repopulating: actor_type: user description: Constrain facilities endpoint to only return existing facilities values - even if the table is empty, do not rerun the Job to populate it. + hca_zero_silent_failures: + actor_type: user + description: Pass callback metadata to vanotify sidekiq job cg1010_oauth_2_enabled: actor_type: user description: Use OAuth 2.0 Authentication for 10-10CG Form Mulesoft integration. diff --git a/spec/models/health_care_application_spec.rb b/spec/models/health_care_application_spec.rb index cbd34504c78..d782813e331 100644 --- a/spec/models/health_care_application_spec.rb +++ b/spec/models/health_care_application_spec.rb @@ -11,6 +11,9 @@ short_form end let(:inelig_character_of_discharge) { HCA::EnrollmentEligibility::Constants::INELIG_CHARACTER_OF_DISCHARGE } + let(:statsd_key_prefix) { HCA::Service::STATSD_KEY_PREFIX } + let(:zsf_tags) { described_class::DD_ZSF_TAGS } + let(:form_id) { described_class::FORM_ID } describe 'LOCKBOX' do it 'can encrypt strings over 4kb' do @@ -419,7 +422,7 @@ expect do described_class.new(form: { mothersMaidenName: 'm' }.to_json).process! end.to raise_error(Common::Exceptions::ValidationErrors) - end.to trigger_statsd_increment('api.1010ez.validation_error_short_form') + end.to trigger_statsd_increment("#{statsd_key_prefix}.validation_error_short_form") end it 'triggers statsd' do @@ -427,7 +430,7 @@ expect do described_class.new(form: {}.to_json).process! end.to raise_error(Common::Exceptions::ValidationErrors) - end.to trigger_statsd_increment('api.1010ez.validation_error') + end.to trigger_statsd_increment("#{statsd_key_prefix}.validation_error") end end @@ -501,7 +504,7 @@ def self.expect_job_submission(job) end it 'increments statsd' do - expect(StatsD).to receive(:increment).with('api.1010ez.sync_submission_failed') + expect(StatsD).to receive(:increment).with("#{statsd_key_prefix}.sync_submission_failed") expect do health_care_application.process! @@ -515,8 +518,8 @@ def self.expect_job_submission(job) end it 'increments statsd and short_form statsd' do - expect(StatsD).to receive(:increment).with('api.1010ez.sync_submission_failed') - expect(StatsD).to receive(:increment).with('api.1010ez.sync_submission_failed_short_form') + expect(StatsD).to receive(:increment).with("#{statsd_key_prefix}.sync_submission_failed") + expect(StatsD).to receive(:increment).with("#{statsd_key_prefix}.sync_submission_failed_short_form") expect do health_care_application.process! @@ -542,6 +545,7 @@ def self.expect_job_submission(job) before do allow(VANotify::EmailJob).to receive(:perform_async) + allow(Flipper).to receive(:enabled?).with(:hca_zero_silent_failures).and_return(false) end describe '#send_failure_email' do @@ -563,6 +567,27 @@ def self.expect_job_submission(job) let(:standard_error) { StandardError.new('Test error') } + context ':hca_zero_silent_failures enabled' do + before do + allow(Flipper).to receive(:enabled?).with(:hca_zero_silent_failures).and_return(true) + end + + let(:template_params_with_callback_metadata) do + template_params << { + callback_metadata: { + notification_type: 'error', + form_number: form_id, + statsd_tags: zsf_tags + } + } + end + + it 'sends a failure email to the email address provided on the form with callback metadata' do + subject + expect(VANotify::EmailJob).to have_received(:perform_async).with(*template_params_with_callback_metadata) + end + end + it 'sends a failure email to the email address provided on the form' do subject expect(VANotify::EmailJob).to have_received(:perform_async).with(*template_params) @@ -575,7 +600,7 @@ def self.expect_job_submission(job) end it 'increments statsd' do - expect { subject }.to trigger_statsd_increment('api.1010ez.submission_failure_email_sent') + expect { subject }.to trigger_statsd_increment("#{statsd_key_prefix}.submission_failure_email_sent") end context 'without first name' do @@ -597,6 +622,29 @@ def self.expect_job_submission(job) let(:standard_error) { StandardError.new('Test error') } + context ':hca_zero_silent_failures enabled' do + before do + allow(Flipper).to receive(:enabled?).with(:hca_zero_silent_failures).and_return(true) + end + + let(:template_params_no_name_with_callback_metadata) do + template_params_no_name << { + callback_metadata: { + notification_type: 'error', + form_number: form_id, + statsd_tags: zsf_tags + } + } + end + + it 'sends a failure email to the email address provided on the form with callback metadata' do + subject + expect(VANotify::EmailJob).to have_received(:perform_async).with( + *template_params_no_name_with_callback_metadata + ) + end + end + it 'sends a failure email without personalisations to the email address provided on the form' do subject expect(VANotify::EmailJob).to have_received(:perform_async).with(*template_params_no_name) @@ -652,7 +700,7 @@ def self.expect_job_submission(job) describe '#log_async_submission_failure' do it 'triggers failed_wont_retry statsd' do - expect { subject }.to trigger_statsd_increment('api.1010ez.failed_wont_retry') + expect { subject }.to trigger_statsd_increment("#{statsd_key_prefix}.failed_wont_retry") end it 'triggers zero silent failures statsd' do @@ -666,8 +714,8 @@ def self.expect_job_submission(job) end it 'triggers statsd' do - expect { subject }.to trigger_statsd_increment('api.1010ez.failed_wont_retry') - .and trigger_statsd_increment('api.1010ez.failed_wont_retry_short_form') + expect { subject }.to trigger_statsd_increment("#{statsd_key_prefix}.failed_wont_retry") + .and trigger_statsd_increment("#{statsd_key_prefix}.failed_wont_retry_short_form") end end