diff --git a/modules/ivc_champva/app/services/ivc_champva/ves_data_validator.rb b/modules/ivc_champva/app/services/ivc_champva/ves_data_validator.rb new file mode 100644 index 00000000000..2e7bdb9fbf3 --- /dev/null +++ b/modules/ivc_champva/app/services/ivc_champva/ves_data_validator.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +# TODO: add validators for non-required, but structure constrained types: +# - validate phone number structure: ^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}$ +# - validate gender values +# - validate relationship to veteran + +module IvcChampva + class VesDataValidator + # This function will run through all the individual validators + def self.validate(request_body) + validate_application_type(request_body) + .then { |rb| validate_application_uuid(rb) } + .then { |rb| validate_sponsor(rb) } + .then { |rb| validate_beneficiaries(rb) } + .then { |rb| validate_certification(rb) } + end + + def self.validate_sponsor(request_body) + sponsor = request_body[:sponsor] + validate_first_name(sponsor) + .then { |s| validate_last_name(s) } + .then { |s| validate_sponsor_address(s) } + .then { |s| validate_date_of_birth(s) } + .then { |s| validate_person_uuid(s) } + .then { |s| validate_ssn(s) } + + request_body + end + + def self.validate_beneficiaries(request_body) + beneficiaries = request_body[:beneficiaries] + raise ArgumentError, 'beneficiaries is invalid. Must be an array' unless beneficiaries.is_a?(Array) + + beneficiaries.each do |beneficiary| + validate_beneficiary(beneficiary) + end + + request_body + end + + def self.validate_beneficiary(beneficiary) + validate_first_name(beneficiary) + .then { |b| validate_last_name(b) } + .then { |b| validate_date_of_birth(b) } + .then { |b| validate_person_uuid(b) } + .then { |b| validate_beneficiary_address(b) } + .then { |b| validate_ssn(b) } + + beneficiary + end + + def self.validate_certification(request_body) + certification = request_body[:certification] + + validate_presence_and_stringiness(certification[:signature], 'certification signature') + validate_date(certification[:signatureDate], 'certification signature date') + + request_body + end + + # ------------------------------------------------------------ # + # ------------------------------------------------------------ # + + def self.validate_application_type(request_body) + validate_presence_and_stringiness(request_body[:applicationType], 'application type') + + request_body + end + + def self.validate_application_uuid(request_body) + validate_presence_and_stringiness(request_body[:applicationUUID], 'application UUID') + validate_uuid_length(request_body[:applicationUUID], 'application UUID') + + request_body + end + + def self.validate_first_name(object) + validate_presence_and_stringiness(object[:firstName], 'first name') + object[:firstName] = transliterate_and_strip(object[:firstName]) + + object + end + + def self.validate_last_name(object) + validate_presence_and_stringiness(object[:lastName], 'last name') + object[:lastName] = transliterate_and_strip(object[:lastName]) + + object + end + + def self.transliterate_and_strip(text) + # Convert any special UTF-8 chars to nearest ASCII equivalents, drop whitespace + I18n.transliterate(text).gsub(%r{[^a-zA-Z\-\/\s]}, '').strip + end + + def self.validate_person_uuid(object) + validate_presence_and_stringiness(object[:personUUID], 'person uuid') + validate_uuid_length(object[:personUUID], 'person uuid') + + object + end + + def self.validate_date_of_birth(object) + validate_date(object[:dateOfBirth], 'date of birth') + + object + end + + def self.validate_beneficiary_address(beneficiary) + validate_address(beneficiary[:address], 'beneficiary') + + beneficiary + end + + def self.validate_sponsor_address(request_body) + validate_address(request_body[:address], 'sponsor') + + request_body + end + + def self.validate_address(address, name) + validate_presence_and_stringiness(address[:city], "#{name} city") + validate_presence_and_stringiness(address[:state], "#{name} state") + validate_presence_and_stringiness(address[:zipCode], "#{name} zip code") + validate_presence_and_stringiness(address[:streetAddress], "#{name} street address") + end + + def self.validate_ssn(request_body) + # TODO: strip out hyphens here? Or do that further up the chain? + validate_presence_and_stringiness(request_body[:ssn], 'ssn') + unless request_body[:ssn].match?(/^(?!(000|666|9))\d{3}(?!00)\d{2}(?!0000)\d{4}$/) + raise ArgumentError, 'ssn is invalid. Must be 9 digits (see regex for more detail)' + end + + request_body + end + + def self.validate_date(date, name) + validate_presence_and_stringiness(date, name) + raise ArgumentError, 'date is invalid. Must match YYYY-MM-DD' unless date.match?(/^\d{4}-\d{2}-\d{2}$/) + + # TODO: once we know the exact date format VES is expecting we can + # do further checks here. + date + end + + def self.validate_uuid_length(uuid, name) + # TODO: we may want a more sophisticated validation for uuids + raise ArgumentError, "#{name} is invalid. Must be 36 characters" unless uuid.length == 36 + end + + def self.validate_presence_and_stringiness(value, error_label) + raise ArgumentError, "#{error_label} is missing" unless value + raise ArgumentError, "#{error_label} is not a string" if value.class != String + end + end +end diff --git a/modules/ivc_champva/spec/services/ves_data_validator_spec.rb b/modules/ivc_champva/spec/services/ves_data_validator_spec.rb new file mode 100644 index 00000000000..1cad271feb9 --- /dev/null +++ b/modules/ivc_champva/spec/services/ves_data_validator_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'rails_helper' + +valid_data = { + applicationType: 'CHAMPVA Application', + applicationUUID: '12345678-1234-5678-1234-567812345678', + sponsor: { + personUUID: '52345678-1234-5678-1234-567812345678', + firstName: 'Joe', + lastName: 'Johnson', + middleInitial: 'X', + ssn: '123123123', + vaFileNumber: '', + dateOfBirth: '1999-01-01', + dateOfMarriage: '', + isDeceased: true, + dateOfDeath: '1999-01-01', + isDeathOnActiveService: false, + address: { + streetAddress: '123 Certifier Street ', + city: 'Citytown', + state: 'AL', + zipCode: '12312' + } + }, + beneficiaries: [ + { + personUUID: '62345678-1234-5678-1234-567812345678', + firstName: 'Johnny', + lastName: 'Alvin', + middleInitial: 'T', + ssn: '345345345', + emailAddress: 'johnny@alvin.gov', + phoneNumber: '+1 (555) 555-1234', + gender: 'MALE', + enrolledInMedicare: true, + hasOtherInsurance: true, + relationshipToSponsor: 'CHILD', + childtype: 'ADOPTED', + address: { + streetAddress: '456 Circle Street ', + city: 'Clinton', + state: 'AS', + zipCode: '56790' + }, + dateOfBirth: '2000-01-01' + } + ], + certification: { + signature: 'certifier jones', + signatureDate: '1999-01-01', + firstName: 'Certifier', + lastName: 'Jones', + middleInitial: 'X', + phoneNumber: '(123) 123-1234' + } +} + +describe IvcChampva::VesDataValidator do + describe 'data is valid' do + it 'returns unmodified data' do + # Deep copy our properly formatted data + request_body = Marshal.load(Marshal.dump(valid_data)) + + validated_data = IvcChampva::VesDataValidator.validate(request_body) + + expect(validated_data).to eq(request_body) + end + end + + describe 'request_body key has a missing value' do + it 'raises a missing exception' do + expect do + IvcChampva::VesDataValidator.validate_presence_and_stringiness(nil, 'sponsor first name') + end.to raise_error(ArgumentError, 'sponsor first name is missing') + end + end + + describe 'string key has a non-string value' do + it 'raises a non-string exception' do + expect do + IvcChampva::VesDataValidator.validate_presence_and_stringiness(12, 'sponsor first name') + end.to raise_error(ArgumentError, 'sponsor first name is not a string') + end + end + + describe 'sponsor first name is malformed' do + describe 'contains disallowed characters' do + it 'returns data with disallowed characters of sponsor first name stripped or corrected' do + # Deep copy our valid data + request_body = Marshal.load(Marshal.dump(valid_data)) + request_body[:sponsor][:firstName] = '2Jöhn~! - Jo/hn?\\' + expected_sponsor_name = 'John - Jo/hn' + + validated_data = IvcChampva::VesDataValidator.validate(request_body) + + expect(validated_data[:sponsor][:firstName]).to eq expected_sponsor_name + end + end + end + + describe 'sponsor last name is malformed' do + describe 'contains disallowed characters' do + it 'returns data with disallowed characters of sponsor last name stripped or corrected' do + # Deep copy our valid data + request_body = Marshal.load(Marshal.dump(valid_data)) + request_body[:sponsor][:lastName] = '2Jöhnşon~!\\' + expected_sponsor_name = 'Johnson' + + validated_data = IvcChampva::VesDataValidator.validate(request_body) + + expect(validated_data[:sponsor][:lastName]).to eq expected_sponsor_name + end + end + end + + describe 'social security number is malformed' do + describe 'too long' do + it 'raises an exception' do + # Deep copy our valid data + request_body = Marshal.load(Marshal.dump(valid_data)) + request_body[:sponsor][:ssn] = '1234567890' + + expect do + IvcChampva::VesDataValidator.validate(request_body) + end.to raise_error(ArgumentError, 'ssn is invalid. Must be 9 digits (see regex for more detail)') + end + end + end +end