diff --git a/app/controllers/v0/form22_1990n_controller.rb b/app/controllers/v0/form22_1990n_controller.rb new file mode 100644 index 000000000000..d3bd75b7078a --- /dev/null +++ b/app/controllers/v0/form22_1990n_controller.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module V0 + class Form22_1990nController < ApplicationController + before_action :authenticate + before_action :require_loa3 + + def create + submission = Form22_1990nSubmission.new( + form_data: form22_1990n_params[:form_data], + user_uuid: current_user.uuid + ) + + unless submission.valid? + render json: { errors: submission.errors.full_messages }, + status: :unprocessable_entity + return + end + + submission.save! + + StatsD.increment('edu.1990n.submission.attempt') + Lighthouse::SubmitForm22_1990nJob.perform_async(submission.id) + StatsD.increment('edu.1990n.submission.success') + + render json: Form22_1990nSerializer.new(submission).serializable_hash, + status: :ok + rescue => e + StatsD.increment('edu.1990n.submission.failure', tags: ["error:#{e.class}"]) + raise + end + + private + + def form22_1990n_params + params.require(:form22_1990n).permit(:form_data) + end + + def require_loa3 + unless current_user.loa3? + render json: { errors: ['LOA3 is required to submit this form'] }, + status: :forbidden + nil + end + end + end +end \ No newline at end of file diff --git a/app/models/form22_1990n_submission.rb b/app/models/form22_1990n_submission.rb new file mode 100644 index 000000000000..f1d2a59645f8 --- /dev/null +++ b/app/models/form22_1990n_submission.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class Form22_1990nSubmission < ApplicationRecord + self.table_name = 'form22_1990n_submissions' + + # KMS-encrypted storage of PII-bearing form payload. + # attr_encrypted delegates actual encryption to the KMS client + # wired up in config/initializers/attr_encrypted.rb. + attr_encrypted :form_data, + key: Settings.db_encryption_key, + mode: :per_attribute_iv, + encode: true, + encode_iv: true, + attribute: 'encrypted_form_data', + iv_attribute: 'encrypted_form_data_iv' + + validates :form_data, presence: true + validates :user_uuid, presence: true + + scope :pending, -> { where(submitted_at: nil) } + + # Returns a human-readable confirmation number surfaced to veterans + # on the confirmation page. Prefixed with '1990N-' so support staff + # can immediately identify the form type from the confirmation string. + def confirmation_number + "1990N-#{id}" + end + + # Convenience predicate used by the Sidekiq job and monitoring. + def submitted? + submitted_at.present? + end +end \ No newline at end of file diff --git a/app/serializers/form22_1990n_serializer.rb b/app/serializers/form22_1990n_serializer.rb new file mode 100644 index 000000000000..322e868af9dc --- /dev/null +++ b/app/serializers/form22_1990n_serializer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Form22_1990nSerializer < ActiveModel::Serializer + type 'form22_1990n_submission' + + attributes :status, :confirmation_number, :submitted_at + + def status + object.submitted? ? 'submitted' : 'pending' + end + + def confirmation_number + object.confirmation_number + end + + def submitted_at + object.submitted_at&.iso8601 + end +end \ No newline at end of file diff --git a/app/sidekiq/lighthouse/submit_form22_1990n_job.rb b/app/sidekiq/lighthouse/submit_form22_1990n_job.rb new file mode 100644 index 000000000000..a22f92d37da1 --- /dev/null +++ b/app/sidekiq/lighthouse/submit_form22_1990n_job.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'lighthouse/benefits_intake/service' + +module Lighthouse + class SubmitForm22_1990nJob + include Sidekiq::Worker + + sidekiq_options retry: 5, queue: 'default' + + STATS_KEY_BASE = 'edu.1990n.lighthouse' + FORM_ID = '22-1990N' + + def perform(submission_id) + submission = Form22_1990nSubmission.find(submission_id) + + if submission.submitted? + log_message_to_sentry( + "Form22_1990nSubmission #{submission_id} already submitted; skipping.", + :warn + ) + return + end + + StatsD.measure("#{STATS_KEY_BASE}.perform.duration") do + upload_to_benefits_intake(submission) + end + rescue ActiveRecord::RecordNotFound => e + StatsD.increment("#{STATS_KEY_BASE}.failure", + tags: ['error:record_not_found']) + raise e + rescue => e + StatsD.increment("#{STATS_KEY_BASE}.failure", + tags: ["error:#{e.class}"]) + raise e + end + + private + + def upload_to_benefits_intake(submission) + service = BenefitsIntakeService::Service.new + + response = service.upload_form( + form_path: build_form_payload(submission), + metadata_path: build_metadata_payload(submission), + form_id: FORM_ID + ) + + submission.update!(submitted_at: Time.current) + + StatsD.increment("#{STATS_KEY_BASE}.success") + + response + end + + # Writes the raw form_data JSON to a Tempfile and returns its path. + # BenefitsIntakeService expects a file path for multipart upload. + def build_form_payload(submission) + tmpfile = Tempfile.new(["form22_1990n_#{submission.id}", '.json']) + tmpfile.write(submission.form_data) + tmpfile.rewind + tmpfile.path + ensure + tmpfile&.close + end + + def build_metadata_payload(submission) + metadata = { + veteranFirstName: extract_first_name(submission), + veteranLastName: extract_last_name(submission), + fileNumber: extract_ssn(submission), + zipCode: extract_zip(submission), + source: 'VA.gov', + docType: FORM_ID, + businessLine: 'EDU' + } + + tmpfile = Tempfile.new(["form22_1990n_meta_#{submission.id}", '.json']) + tmpfile.write(metadata.to_json) + tmpfile.rewind + tmpfile.path + ensure + tmpfile&.close + end + + def parsed_form_data(submission) + @parsed_form_data ||= JSON.parse(submission.form_data) + end + + def extract_first_name(submission) + parsed_form_data(submission).dig( + 'applicantInformation', 'veteranFullName', 'first' + ).to_s + end + + def extract_last_name(submission) + parsed_form_data(submission).dig( + 'applicantInformation', 'veteranFullName', 'last' + ).to_s + end + + def extract_ssn(submission) + parsed_form_data(submission).dig( + 'applicantInformation', 'veteranSocialSecurityNumber' + ).to_s + end + + def extract_zip(submission) + parsed_form_data(submission).dig( + 'applicantInformation', 'veteranAddress', 'postalCode' + ).to_s[0, 5] + end + end +end \ No newline at end of file diff --git a/config/routes/form22_1990n.rb b/config/routes/form22_1990n.rb new file mode 100644 index 000000000000..7500274cb58a --- /dev/null +++ b/config/routes/form22_1990n.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# This file is drawn from config/routes.rb via: +# draw :form22_1990n +# which calls Rails.application.routes.draw { eval(IO.read(file)) }. +# Only the actions actually served are registered — no index/show/destroy. + +namespace :v0 do + post 'form22_1990n', to: 'form22_1990n#create' +end \ No newline at end of file diff --git a/db/migrate/20260426223952_create_form22_1990n_submissions.rb b/db/migrate/20260426223952_create_form22_1990n_submissions.rb new file mode 100644 index 000000000000..fc7062303e31 --- /dev/null +++ b/db/migrate/20260426223952_create_form22_1990n_submissions.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class CreateForm22_1990nSubmissions < ActiveRecord::Migration[7.0] + def change + create_table :form22_1990n_submissions do |t| + # User association — nullable so a record can be reconstructed + # even if the user row is expunged during account deletion. + t.uuid :user_uuid, null: false + + # Unencrypted jsonb column kept for schema completeness. + # In practice the application always reads/writes via the + # attr_encrypted virtual attribute; this column is blank. + t.jsonb :form_data + + # KMS-encrypted columns — hold the actual PII payload. + t.text :encrypted_form_data, null: true + t.text :encrypted_form_data_iv, null: true + + # NULL until the Sidekiq job successfully posts to Lighthouse. + t.datetime :submitted_at, null: true + + t.timestamps null: false + end + + add_index :form22_1990n_submissions, :user_uuid + add_index :form22_1990n_submissions, :submitted_at + add_index :form22_1990n_submissions, + :created_at, + name: 'index_form22_1990n_submissions_on_created_at' + end +end \ No newline at end of file diff --git a/spec/controllers/v0/form22_1990n_controller_spec.rb b/spec/controllers/v0/form22_1990n_controller_spec.rb new file mode 100644 index 000000000000..16a3899201f8 --- /dev/null +++ b/spec/controllers/v0/form22_1990n_controller_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe V0::Form22_1990nController, type: :controller do + let(:loa3_user) { build(:user, :loa3) } + let(:loa1_user) { build(:user, :loa1) } + + let(:valid_form_data) do + { + 'applicantInformation' => { + 'veteranSocialSecurityNumber' => '123456789', + 'gender' => 'M', + 'veteranDateOfBirth' => '1990-01-01', + 'veteranFullName' => { 'first' => 'John', 'last' => 'Doe' }, + 'veteranAddress' => { + 'street' => '123 Main St', + 'city' => 'Springfield', + 'state' => 'IL', + 'postalCode' => '62701', + 'country' => 'USA' + } + }, + 'educationTraining' => { + 'typeOfEducation' => ['collegeOrOtherSchool'], + 'schoolSelected' => false, + 'highestRateAuthorization' => true + }, + 'serviceInformation' => { + 'activeDuty' => false, + 'terminalLeave' => false, + 'servicePeriods' => [ + { + 'dateEnteredService' => '2010-06-01', + 'serviceComponent' => 'USMC', + 'serviceStatus' => 'Active duty' + } + ] + }, + 'concurrentBenefits' => { + 'seniorRotcScholarship' => false + }, + 'certification' => { + 'privacyAgreementAccepted' => true, + 'dateSigned' => '2026-04-26' + } + }.to_json + end + + describe '#create' do + context 'when the user is not authenticated' do + it 'returns 401 Unauthorized' do + post :create, params: { form22_1990n: { form_data: valid_form_data } } + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when the user is authenticated as LOA1 (identity not verified)' do + before { sign_in_as_user(loa1_user) } + + it 'returns 403 Forbidden' do + post :create, params: { form22_1990n: { form_data: valid_form_data } } + expect(response).to have_http_status(:forbidden) + end + end + + context 'when authenticated as an LOA3 user' do + before do + sign_in_as_user(loa3_user) + allow(Lighthouse::SubmitForm22_1990nJob) + .to receive(:perform_async) + .and_return(true) + end + + context 'with valid params' do + it 'returns HTTP 200' do + post :create, params: { form22_1990n: { form_data: valid_form_data } } + expect(response).to have_http_status(:ok) + end + + it 'includes status in the response body' do + post :create, params: { form22_1990n: { form_data: valid_form_data } } + json = JSON.parse(response.body) + expect(json.dig('data', 'attributes', 'status')).to eq('pending') + end + + it 'includes a confirmation_number in the response body' do + post :create, params: { form22_1990n: { form_data: valid_form_data } } + json = JSON.parse(response.body) + expect(json.dig('data', 'attributes', 'confirmation_number')).to match(/^1990N-\d+$/) + end + + it 'enqueues a Lighthouse submission job' do + expect(Lighthouse::SubmitForm22_1990nJob) + .to receive(:perform_async) + .once + post :create, params: { form22_1990n: { form_data: valid_form_data } } + end + + it 'persists a Form22_1990nSubmission record' do + expect do + post :create, params: { form22_1990n: { form_data: valid_form_data } } + end.to change(Form22_1990nSubmission, :count).by(1) + end + + it 'increments the submission attempt StatsD counter' do + expect(StatsD).to receive(:increment).with('edu.1990n.submission.attempt') + expect(StatsD).to receive(:increment).with('edu.1990n.submission.success') + post :create, params: { form22_1990n: { form_data: valid_form_data } } + end + end + + context 'with missing form_data param' do + it 'returns HTTP 422' do + post :create, params: {} + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'returns a descriptive error body' do + post :create, params: {} + json = JSON.parse(response.body) + expect(json).to have_key('errors') + end + end + + context 'with blank form_data' do + it 'returns HTTP 422 due to model validation' do + post :create, params: { form22_1990n: { form_data: '' } } + expect(response).to have_http_status(:unprocessable_entity) + end + end + end + end +end \ No newline at end of file diff --git a/spec/factories/form22_1990n_submissions.rb b/spec/factories/form22_1990n_submissions.rb new file mode 100644 index 000000000000..3b40027a8812 --- /dev/null +++ b/spec/factories/form22_1990n_submissions.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :form22_1990n_submission, class: 'Form22_1990nSubmission' do + user_uuid { SecureRandom.uuid } + form_data do + { + 'applicantInformation' => { + 'veteranSocialSecurityNumber' => '123456789', + 'gender' => 'M', + 'veteranDateOfBirth' => '1990-01-01', + 'veteranFullName' => { + 'first' => 'John', + 'middle' => 'Q', + 'last' => 'Doe' + }, + 'veteranAddress' => { + 'street' => '123 Main St', + 'city' => 'Springfield', + 'state' => 'IL', + 'postalCode' => '62701', + 'country' => 'USA' + }, + 'homePhone' => '2175551234', + 'mobilePhone' => '2175554321', + 'email' => 'john.doe@example.com' + }, + 'educationTraining' => { + 'typeOfEducation' => ['collegeOrOtherSchool'], + 'schoolSelected' => false, + 'highestRateAuthorization' => true + }, + 'serviceInformation' => { + 'activeDuty' => false, + 'terminalLeave' => false, + 'servicePeriods' => [ + { + 'dateEnteredService' => '2010-06-01', + 'dateSeparated' => '2014-05-31', + 'serviceComponent' => 'USMC', + 'serviceStatus' => 'Active duty' + } + ] + }, + 'concurrentBenefits' => { + 'seniorRotcScholarship' => false + }, + 'certification' => { + 'privacyAgreementAccepted' => true, + 'dateSigned' => Date.current.iso8601 + } + }.to_json + end + + submitted_at { nil } + + trait :submitted do + submitted_at { Time.current } + end + + trait :with_direct_deposit do + form_data do + base = JSON.parse(__send__(:form_data)) + base['directDeposit'] = { + 'noDirectDeposit' => false, + 'bankAccount' => { + 'accountType' => 'checking', + 'routingNumber' => '021000021', + 'accountNumber' => '12345678' + } + } + base.to_json + end + end + end +end \ No newline at end of file diff --git a/spec/models/form22_1990n_submission_spec.rb b/spec/models/form22_1990n_submission_spec.rb new file mode 100644 index 000000000000..8af0b2036d96 --- /dev/null +++ b/spec/models/form22_1990n_submission_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Form22_1990nSubmission, type: :model do + subject(:submission) { build(:form22_1990n_submission) } + + # ── Validations ───────────────────────────────────────────────────────────── + + it { is_expected.to be_valid } + it { is_expected.to validate_presence_of(:form_data) } + it { is_expected.to validate_presence_of(:user_uuid) } + + describe 'with blank form_data' do + subject { build(:form22_1990n_submission, form_data: '') } + + it { is_expected.not_to be_valid } + + it 'adds an error on form_data' do + subject.valid? + expect(subject.errors[:form_data]).not_to be_empty + end + end + + # ── Scopes ─────────────────────────────────────────────────────────────────── + + describe '.pending' do + let!(:pending_submission) { create(:form22_1990n_submission, submitted_at: nil) } + let!(:submitted_submission) { create(:form22_1990n_submission, submitted_at: Time.current) } + + it 'includes submissions without a submitted_at timestamp' do + expect(described_class.pending).to include(pending_submission) + end + + it 'excludes submissions that have been submitted' do + expect(described_class.pending).not_to include(submitted_submission) + end + end + + # ── Instance methods ───────────────────────────────────────────────────────── + + describe '#confirmation_number' do + let(:submission) { create(:form22_1990n_submission) } + + it 'is prefixed with 1990N-' do + expect(submission.confirmation_number).to start_with('1990N-') + end + + it 'incorporates the record id' do + expect(submission.confirmation_number).to eq("1990N-#{submission.id}") + end + end + + describe '#submitted?' do + it 'returns false when submitted_at is nil' do + submission = build(:form22_1990n_submission, submitted_at: nil) + expect(submission.submitted?).to be false + end + + it 'returns true when submitted_at is set' do + submission = build(:form22_1990n_submission, submitted_at: Time.current) + expect(submission.submitted?).to be true + end + end + + # ── Encryption ─────────────────────────────────────────────────────────────── + + describe 'encrypted form_data' do + let(:payload) { '{"applicantInformation":{"veteranSocialSecurityNumber":"123456789"}}' } + let(:record) { create(:form22_1990n_submission, form_data: payload) } + + it 'persists the encrypted_form_data column' do + expect(record.encrypted_form_data).to be_present + end + + it 'stores a different value than the plaintext payload' do + expect(record.encrypted_form_data).not_to eq(payload) + end + + it 'decrypts back to the original form_data' do + reloaded = described_class.find(record.id) + expect(reloaded.form_data).to eq(payload) + end + end +end \ No newline at end of file diff --git a/spec/serializers/form22_1990n_serializer_spec.rb b/spec/serializers/form22_1990n_serializer_spec.rb new file mode 100644 index 000000000000..d7aadddad4c9 --- /dev/null +++ b/spec/serializers/form22_1990n_serializer_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Form22_1990nSerializer, type: :serializer do + let(:submission) do + create( + :form22_1990n_submission, + submitted_at: Time.zone.parse('2026-04-26T14:00:00Z') + ) + end + + subject(:serialized) { described_class.new(submission).serializable_hash } + + it 'produces a hash' do + expect(serialized).to be_a(Hash) + end + + describe 'attributes' do + let(:attributes) { serialized.dig(:data, :attributes) } + + it 'includes the status key' do + expect(attributes).to have_key(:status) + end + + it 'includes the confirmation_number key' do + expect(attributes).to have_key(:confirmation_number) + end + + it 'includes the submitted_at key' do + expect(attributes).to have_key(:submitted_at) + end + end + + describe 'status attribute' do + context 'when the submission has been submitted' do + it 'returns "submitted"' do + expect(serialized.dig(:data, :attributes, :status)).to eq('submitted') + end + end + + context 'when the submission is still pending' do + let(:submission) { create(:form22_1990n_submission, submitted_at: nil) } + + it 'returns "pending"' do + expect(serialized.dig(:data, :attributes, :status)).to eq('pending') + end + end + end + + describe 'confirmation_number attribute' do + it 'matches the 1990N- format' do + expect(serialized.dig(:data, :attributes, :confirmation_number)) + .to match(/^1990N-\d+$/) + end + end + + describe 'submitted_at attribute' do + it 'is formatted as an ISO 8601 string' do + expect(serialized.dig(:data, :attributes, :submitted_at)) + .to eq('2026-04-26T14:00:00Z') + end + + context 'when submitted_at is nil' do + let(:submission) { create(:form22_1990n_submission, submitted_at: nil) } + + it 'returns nil' do + expect(serialized.dig(:data, :attributes, :submitted_at)).to be_nil + end + end + end + + describe 'type' do + it 'is form22_1990n_submission' do + expect(serialized.dig(:data, :type)).to eq(:form22_1990n_submission) + end + end +end \ No newline at end of file diff --git a/spec/sidekiq/lighthouse/submit_form22_1990n_job_spec.rb b/spec/sidekiq/lighthouse/submit_form22_1990n_job_spec.rb new file mode 100644 index 000000000000..ea37b8644381 --- /dev/null +++ b/spec/sidekiq/lighthouse/submit_form22_1990n_job_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Lighthouse::SubmitForm22_1990nJob, type: :job do + include Sidekiq::Testing + + Sidekiq::Testing.fake! + + let(:form_data_json) do + { + 'applicantInformation' => { + 'veteranSocialSecurityNumber' => '123456789', + 'gender' => 'M', + 'veteranDateOfBirth' => '1990-01-01', + 'veteranFullName' => { 'first' => 'John', 'last' => 'Doe' }, + 'veteranAddress' => { + 'street' => '123 Main St', + 'city' => 'Springfield', + 'state' => 'IL', + 'postalCode' => '62701', + 'country' => 'USA' + } + } + }.to_json + end + + let(:submission) { create(:form22_1990n_submission, form_data: form_data_json) } + + # ── Enqueueing ────────────────────────────────────────────────────────────── + + describe '.perform_async' do + it 'enqueues exactly one job' do + expect do + described_class.perform_async(submission.id) + end.to change(described_class.jobs, :size).by(1) + end + + it 'sets the correct argument on the enqueued job' do + described_class.perform_async(submission.id) + job_args = described_class.jobs.last['args'] + expect(job_args).to eq([submission.id]) + end + end + + # ── Execution ─────────────────────────────────────────────────────────────── + + describe '#perform' do + let(:intake_endpoint) do + %r{https://.*benefits[-_]intake.*va\.gov.*/uploads} + end + + before do + stub_request(:post, intake_endpoint) + .to_return( + status: 200, + body: { data: { id: 'abc-123', type: 'document_upload' } }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + # Stub the BenefitsIntakeService to avoid real HTTP calls and file I/O. + allow_any_instance_of(BenefitsIntakeService::Service) + .to receive(:upload_form) + .and_return({ status: 200 }) + end + + it 'completes without raising an error' do + expect { described_class.new.perform(submission.id) }.not_to raise_error + end + + it 'marks the submission as submitted' do + described_class.new.perform(submission.id) + expect(submission.reload.submitted_at).not_to be_nil + end + + it 'increments the success StatsD counter' do + expect(StatsD) + .to receive(:increment) + .with('edu.1990n.lighthouse.success') + described_class.new.perform(submission.id) + end + + context 'when the submission is already submitted' do + let(:submission) do + create(:form22_1990n_submission, + form_data: form_data_json, + submitted_at: Time.current) + end + + it 'skips the upload and does not raise' do + expect_any_instance_of(BenefitsIntakeService::Service) + .not_to receive(:upload_form) + expect { described_class.new.perform(submission.id) }.not_to raise_error + end + end + + context 'when the submission record does not exist' do + it 'raises ActiveRecord::RecordNotFound' do + expect do + described_class.new.perform(0) + end.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'increments the failure StatsD counter' do + expect(StatsD) + .to receive(:increment) + .with('edu.1990n.lighthouse.failure', tags: ['error:record_not_found']) + begin + described_class.new.perform(0) + rescue ActiveRecord::RecordNotFound + nil + end + end + end + + context 'when the Lighthouse API returns an error' do + before do + allow_any_instance_of(BenefitsIntakeService::Service) + .to receive(:upload_form) + .and_raise(StandardError, 'Lighthouse unavailable') + end + + it 'raises the error so Sidekiq can retry' do + expect do + described_class.new.perform(submission.id) + end.to raise_error(StandardError, 'Lighthouse unavailable') + end + + it 'increments the failure StatsD counter' do + expect(StatsD) + .to receive(:increment) + .with('edu.1990n.lighthouse.failure', tags: ['error:StandardError']) + begin + described_class.new.perform(submission.id) + rescue StandardError + nil + end + end + end + end + + # ── Sidekiq configuration ─────────────────────────────────────────────────── + + describe 'sidekiq_options' do + it 'retries up to 5 times' do + expect(described_class.sidekiq_options['retry']).to eq(5) + end + + it 'is assigned to the default queue' do + expect(described_class.sidekiq_options['queue']).to eq('default') + end + end +end \ No newline at end of file