diff --git a/app/controllers/api/verify/password_confirm_controller.rb b/app/controllers/api/verify/password_confirm_controller.rb index 8fcc4cf5b25..0f347dd1f15 100644 --- a/app/controllers/api/verify/password_confirm_controller.rb +++ b/app/controllers/api/verify/password_confirm_controller.rb @@ -10,6 +10,7 @@ def create user = User.find_by(uuid: result.extra[:user_uuid]) add_proofing_component(user) store_session_last_gpo_code(form.gpo_code) + save_in_person_enrollment(user, form.profile) render json: { personal_key: personal_key, completion_url: completion_url(result, user), @@ -59,6 +60,52 @@ def in_person_enrollment?(user) # WILLFIX: After LG-6872 and we have enrollment saved, reference enrollment instead. ProofingComponent.find_by(user: user)&.document_check == Idp::Constants::Vendors::USPS end + + def usps_proofer + if IdentityConfig.store.usps_mock_fallback + UspsInPersonProofing::Mock::Proofer.new + else + UspsInPersonProofing::Proofer.new + end + end + + def create_usps_enrollment(enrollment) + pii = user_session[:idv][:pii] + applicant = UspsInPersonProofing::Applicant.new( + { + unique_id: enrollment.usps_unique_id, + first_name: pii.first_name, + last_name: pii.last_name, + address: pii.address1, + # do we need address2? + city: pii.city, + state: pii.state, + zip_code: pii.zipcode, + email: 'no-reply@login.gov', + }, + ) + proofer = usps_proofer + + response = proofer.request_enroll(applicant) + response['enrollmentCode'] + end + + def save_in_person_enrollment(user, profile) + return unless in_person_enrollment?(user) + + enrollment = InPersonEnrollment.create!( + profile: profile, + user: user, + ) + + enrollment_code = create_usps_enrollment(enrollment) + return unless enrollment_code + + # update the enrollment to status pending + enrollment.enrollment_code = enrollment_code + enrollment.status = :pending + enrollment.save! + end end end end diff --git a/app/jobs/get_usps_proofing_results_job.rb b/app/jobs/get_usps_proofing_results_job.rb index 66ea17be5ba..1b50362a69f 100644 --- a/app/jobs/get_usps_proofing_results_job.rb +++ b/app/jobs/get_usps_proofing_results_job.rb @@ -21,7 +21,7 @@ class GetUspsProofingResultsJob < ApplicationJob def perform(_now) return true unless IdentityConfig.store.in_person_proofing_enabled - proofer = UspsInPersonProofer.new + proofer = UspsInPersonProofing::Proofer.new InPersonEnrollment.needs_usps_status_check(...5.minutes.ago).each do |enrollment| # Record and commit attempt to check enrollment status to database diff --git a/app/models/in_person_enrollment.rb b/app/models/in_person_enrollment.rb index 78fd1e94719..ea2800e7010 100644 --- a/app/models/in_person_enrollment.rb +++ b/app/models/in_person_enrollment.rb @@ -34,7 +34,7 @@ def needs_usps_status_check?(check_interval) # Returns the value to use for the USPS enrollment ID def usps_unique_id - user_id.to_s + user.uuid.delete('-').slice(0, 18) end private diff --git a/app/services/usps_in_person_proofer.rb b/app/services/usps_in_person_proofer.rb deleted file mode 100644 index 3fc000e41ec..00000000000 --- a/app/services/usps_in_person_proofer.rb +++ /dev/null @@ -1,184 +0,0 @@ -class UspsInPersonProofer - attr_reader :token, :token_expires_at - - PostOffice = Struct.new( - :distance, :address, :city, :phone, :name, :zip_code, :state, keyword_init: true - ) - - # Makes HTTP request to get nearby in-person proofing facilities - # Requires address, city, state and zip code. - # The PostOffice objects have a subset of the fields - # returned by the API. - # @param location [Object] - # @return [Array] Facility locations - def request_facilities(location) - url = "#{root_url}/ivs-ippaas-api/IPPRest/resources/rest/getIppFacilityList" - body = { - sponsorID: sponsor_id, - streetAddress: location.address, - city: location.city, - state: location.state, - zipCode: location.zip_code, - } - - resp = faraday.post(url, body, dynamic_headers) - - resp.body['postOffices'].map do |post_office| - PostOffice.new( - distance: post_office['distance'], - address: post_office['streetAddress'], - city: post_office['city'], - phone: post_office['phone'], - name: post_office['name'], - zip_code: post_office['zip5'], - state: post_office['state'], - ) - end - end - - # Makes HTTP request to enroll an applicant in in-person proofing. - # Requires first name, last name, address, city, state, zip code, email address and a generated - # unique ID. The unique ID must be no longer than 18 characters. - # USPS sends an email to the email address with instructions and the enrollment code. - # The API response also includes the enrollment code which should be - # stored with the unique ID to be able to request the status of proofing. - # @param applicant [Object] - # @return [Hash] API response - def request_enroll(applicant) - url = "#{root_url}/ivs-ippaas-api/IPPRest/resources/rest/optInIPPApplicant" - body = { - sponsorID: sponsor_id, - uniqueID: applicant.unique_id, - firstName: applicant.first_name, - lastName: applicant.last_name, - streetAddress: applicant.address, - city: applicant.city, - state: applicant.state, - zipCode: applicant.zip_code, - emailAddress: applicant.email, - IPPAssuranceLevel: '1.5', - } - - faraday.post(url, body, dynamic_headers).body - end - - # Makes HTTP request to retrieve proofing status - # Requires the applicant's enrollment code and unique ID. - # When proofing is complete the API returns 200 status. - # If the applicant has not been to the post office, has proofed recently, - # or there is another issue, the API returns a 400 status with an error message. - # @param unique_id [String] - # @param enrollment_code [String] - # @return [Hash] API response - def request_proofing_results(unique_id, enrollment_code) - url = "#{root_url}/ivs-ippaas-api/IPPRest/resources/rest/getProofingResults" - body = { - sponsorID: sponsor_id, - uniqueID: unique_id, - enrollmentCode: enrollment_code, - } - - faraday.post(url, body, dynamic_headers).body - end - - # Makes HTTP request to retrieve enrollment code - # If an applicant has a currently valid enrollment code, it will be returned. - # If they do not, a new one will be generated and returned. USPS sends the applicant an email with - # instructions and the enrollment code. - # Requires the applicant's unique ID. - # @param unique_id [String] - # @return [Hash] API response - def request_enrollment_code(unique_id) - url = "#{root_url}/ivs-ippaas-api/IPPRest/resources/rest/requestEnrollmentCode" - body = { - sponsorID: sponsor_id, - uniqueID: unique_id, - } - - faraday.post(url, body, dynamic_headers).body - end - - # Makes a request to retrieve a new OAuth token - # and modifies self to store the token and when - # it expires (15 minutes). - # @return [String] the token - def retrieve_token! - body = request_token - @token_expires_at = Time.zone.now + body['expires_in'] - @token = "#{body['token_type']} #{body['access_token']}" - end - - def token_valid? - @token.present? && @token_expires_at.present? && @token_expires_at.future? - end - - private - - def faraday - Faraday.new(headers: request_headers) do |conn| - conn.options.timeout = IdentityConfig.store.usps_ipp_request_timeout - conn.options.read_timeout = IdentityConfig.store.usps_ipp_request_timeout - conn.options.open_timeout = IdentityConfig.store.usps_ipp_request_timeout - conn.options.write_timeout = IdentityConfig.store.usps_ipp_request_timeout - - # Raise an error subclassing Faraday::Error on 4xx, 5xx, and malformed responses - # Note: The order of this matters for parsing the error response body. - conn.response :raise_error - - # Log request method and URL, excluding headers and body - conn.response :logger, nil, { headers: false, bodies: false } - - # Convert body to JSON - conn.request :json - - # Parse JSON responses - conn.response :json - end - end - - # Retrieve the OAuth2 token (if needed) and then pass - # the headers to an arbitrary block of code as a Hash. - # - # Returns the same value returned by that block of code. - def dynamic_headers - retrieve_token! unless token_valid? - - { - 'Authorization' => @token, - 'RequestID' => request_id, - } - end - - # Makes HTTP request to authentication endpoint and - # returns the token and when it expires (15 minutes). - # @return [Hash] API response - def request_token - url = "#{root_url}/oauth/authenticate" - body = { - username: IdentityConfig.store.usps_ipp_username, - password: IdentityConfig.store.usps_ipp_password, - grant_type: 'implicit', - response_type: 'token', - client_id: '424ada78-62ae-4c53-8e3a-0b737708a9db', - scope: 'ivs.ippaas.apis', - } - - faraday.post(url, body).body - end - - def root_url - IdentityConfig.store.usps_ipp_root_url - end - - def sponsor_id - IdentityConfig.store.usps_ipp_sponsor_id.to_i - end - - def request_id - SecureRandom.uuid - end - - def request_headers - { 'Content-Type' => 'application/json; charset=utf-8' } - end -end diff --git a/app/services/usps_in_person_proofing/applicant.rb b/app/services/usps_in_person_proofing/applicant.rb new file mode 100644 index 00000000000..e5c3ae43c1f --- /dev/null +++ b/app/services/usps_in_person_proofing/applicant.rb @@ -0,0 +1,6 @@ +module UspsInPersonProofing + Applicant = Struct.new( + :unique_id, :first_name, :last_name, :address, :city, :state, :zip_code, + :email, keyword_init: true + ) +end diff --git a/app/services/usps_in_person_proofing/mock.rb b/app/services/usps_in_person_proofing/mock.rb new file mode 100644 index 00000000000..6e20843562b --- /dev/null +++ b/app/services/usps_in_person_proofing/mock.rb @@ -0,0 +1,11 @@ +module UspsInPersonProofing + module Mock + class Proofer + def request_enroll(_applicant) + JSON.load_file( + Rails.root.join('spec/fixtures/usps_ipp_responses/request_enroll_response.json'), + ) + end + end + end +end diff --git a/app/services/usps_in_person_proofing/post_office.rb b/app/services/usps_in_person_proofing/post_office.rb new file mode 100644 index 00000000000..20ae83c5e4f --- /dev/null +++ b/app/services/usps_in_person_proofing/post_office.rb @@ -0,0 +1,5 @@ +module UspsInPersonProofing + PostOffice = Struct.new( + :distance, :address, :city, :phone, :name, :zip_code, :state, keyword_init: true + ) +end diff --git a/app/services/usps_in_person_proofing/proofer.rb b/app/services/usps_in_person_proofing/proofer.rb new file mode 100644 index 00000000000..e4579cf6a03 --- /dev/null +++ b/app/services/usps_in_person_proofing/proofer.rb @@ -0,0 +1,182 @@ +module UspsInPersonProofing + class Proofer + attr_reader :token, :token_expires_at + + # Makes HTTP request to get nearby in-person proofing facilities + # Requires address, city, state and zip code. + # The PostOffice objects have a subset of the fields + # returned by the API. + # @param location [Object] + # @return [Array] Facility locations + def request_facilities(location) + url = "#{root_url}/ivs-ippaas-api/IPPRest/resources/rest/getIppFacilityList" + body = { + sponsorID: sponsor_id, + streetAddress: location.address, + city: location.city, + state: location.state, + zipCode: location.zip_code, + } + + resp = faraday.post(url, body, dynamic_headers) + + resp.body['postOffices'].map do |post_office| + PostOffice.new( + distance: post_office['distance'], + address: post_office['streetAddress'], + city: post_office['city'], + phone: post_office['phone'], + name: post_office['name'], + zip_code: post_office['zip5'], + state: post_office['state'], + ) + end + end + + # Makes HTTP request to enroll an applicant in in-person proofing. + # Requires first name, last name, address, city, state, zip code, email address and a generated + # unique ID. The unique ID must be no longer than 18 characters. + # USPS sends an email to the email address with instructions and the enrollment code. + # The API response also includes the enrollment code which should be + # stored with the unique ID to be able to request the status of proofing. + # @param applicant [Object] + # @return [Hash] API response + def request_enroll(applicant) + url = "#{root_url}/ivs-ippaas-api/IPPRest/resources/rest/optInIPPApplicant" + body = { + sponsorID: sponsor_id, + uniqueID: applicant.unique_id, + firstName: applicant.first_name, + lastName: applicant.last_name, + streetAddress: applicant.address, + city: applicant.city, + state: applicant.state, + zipCode: applicant.zip_code, + emailAddress: applicant.email, + IPPAssuranceLevel: '1.5', + } + + faraday.post(url, body, dynamic_headers).body + end + + # Makes HTTP request to retrieve proofing status + # Requires the applicant's enrollment code and unique ID. + # When proofing is complete the API returns 200 status. + # If the applicant has not been to the post office, has proofed recently, + # or there is another issue, the API returns a 400 status with an error message. + # @param unique_id [String] + # @param enrollment_code [String] + # @return [Hash] API response + def request_proofing_results(unique_id, enrollment_code) + url = "#{root_url}/ivs-ippaas-api/IPPRest/resources/rest/getProofingResults" + body = { + sponsorID: sponsor_id, + uniqueID: unique_id, + enrollmentCode: enrollment_code, + } + + faraday.post(url, body, dynamic_headers).body + end + + # Makes HTTP request to retrieve enrollment code + # If an applicant has a currently valid enrollment code, it will be returned. + # If they do not, a new one will be generated and returned. USPS sends the applicant an email + # with instructions and the enrollment code. + # Requires the applicant's unique ID. + # @param unique_id [String] + # @return [Hash] API response + def request_enrollment_code(unique_id) + url = "#{root_url}/ivs-ippaas-api/IPPRest/resources/rest/requestEnrollmentCode" + body = { + sponsorID: sponsor_id, + uniqueID: unique_id, + } + + faraday.post(url, body, dynamic_headers).body + end + + # Makes a request to retrieve a new OAuth token + # and modifies self to store the token and when + # it expires (15 minutes). + # @return [String] the token + def retrieve_token! + body = request_token + @token_expires_at = Time.zone.now + body['expires_in'] + @token = "#{body['token_type']} #{body['access_token']}" + end + + def token_valid? + @token.present? && @token_expires_at.present? && @token_expires_at.future? + end + + private + + def faraday + Faraday.new(headers: request_headers) do |conn| + conn.options.timeout = IdentityConfig.store.usps_ipp_request_timeout + conn.options.read_timeout = IdentityConfig.store.usps_ipp_request_timeout + conn.options.open_timeout = IdentityConfig.store.usps_ipp_request_timeout + conn.options.write_timeout = IdentityConfig.store.usps_ipp_request_timeout + + # Raise an error subclassing Faraday::Error on 4xx, 5xx, and malformed responses + # Note: The order of this matters for parsing the error response body. + conn.response :raise_error + + # Log request method and URL, excluding headers and body + conn.response :logger, nil, { headers: false, bodies: false } + + # Convert body to JSON + conn.request :json + + # Parse JSON responses + conn.response :json + end + end + + # Retrieve the OAuth2 token (if needed) and then pass + # the headers to an arbitrary block of code as a Hash. + # + # Returns the same value returned by that block of code. + def dynamic_headers + retrieve_token! unless token_valid? + + { + 'Authorization' => @token, + 'RequestID' => request_id, + } + end + + # Makes HTTP request to authentication endpoint and + # returns the token and when it expires (15 minutes). + # @return [Hash] API response + def request_token + url = "#{root_url}/oauth/authenticate" + body = { + username: IdentityConfig.store.usps_ipp_username, + password: IdentityConfig.store.usps_ipp_password, + grant_type: 'implicit', + response_type: 'token', + client_id: '424ada78-62ae-4c53-8e3a-0b737708a9db', + scope: 'ivs.ippaas.apis', + } + + faraday.post(url, body).body + end + + def root_url + IdentityConfig.store.usps_ipp_root_url + end + + def sponsor_id + IdentityConfig.store.usps_ipp_sponsor_id.to_i + end + + def request_id + SecureRandom.uuid + end + + def request_headers + { 'Content-Type' => 'application/json; charset=utf-8' } + end + end +end diff --git a/config/application.yml.default b/config/application.yml.default index 9b7f8c57803..d0705f770a4 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -265,6 +265,7 @@ usps_ipp_root_url: '' usps_ipp_request_timeout: 10 usps_ipp_sponsor_id: '' usps_ipp_username: '' +usps_mock_fallback: true gpo_allowed_for_strict_ial2: true voice_otp_pause_time: '0.5s' voice_otp_speech_rate: 'slow' diff --git a/lib/identity_config.rb b/lib/identity_config.rb index a107ce4b884..3487bf8eeb2 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -341,6 +341,7 @@ def self.build_store(config_map) config.add(:unauthorized_scope_enabled, type: :boolean) config.add(:use_dashboard_service_providers, type: :boolean) config.add(:use_kms, type: :boolean) + config.add(:usps_mock_fallback, type: :boolean) config.add(:usps_confirmation_max_days, type: :integer) config.add(:usps_ipp_password, type: :string) config.add(:usps_ipp_root_url, type: :string) diff --git a/spec/controllers/api/verify/password_confirm_controller_spec.rb b/spec/controllers/api/verify/password_confirm_controller_spec.rb index 0b613484ba3..8b235e1e3b4 100644 --- a/spec/controllers/api/verify/password_confirm_controller_spec.rb +++ b/spec/controllers/api/verify/password_confirm_controller_spec.rb @@ -65,6 +65,18 @@ def stub_idv_session allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) end + context 'when in-person mocking is disabled' do + before do + allow(IdentityConfig.store).to receive(:usps_mock_fallback).and_return(false) + end + + it 'uses a real proofer' do + expect(UspsInPersonProofing::Proofer).to receive(:new). + and_return(UspsInPersonProofing::Mock::Proofer.new) + post :create, params: { password: password, user_bundle_token: jwt } + end + end + it 'creates a profile and returns completion url' do post :create, params: { password: password, user_bundle_token: jwt } @@ -72,6 +84,53 @@ def stub_idv_session idv_in_person_ready_to_verify_url, ) end + + it 'creates a USPS enrollment' do + proofer = UspsInPersonProofing::Mock::Proofer.new + mock = double + + expect(UspsInPersonProofing::Mock::Proofer).to receive(:new).and_return(mock) + expect(mock).to receive(:request_enroll) do |applicant| + expect(applicant.first_name).to eq(Idp::Constants::MOCK_IDV_APPLICANT[:first_name]) + expect(applicant.last_name).to eq(Idp::Constants::MOCK_IDV_APPLICANT[:last_name]) + expect(applicant.address).to eq(Idp::Constants::MOCK_IDV_APPLICANT[:address1]) + expect(applicant.city).to eq(Idp::Constants::MOCK_IDV_APPLICANT[:city]) + expect(applicant.state).to eq(Idp::Constants::MOCK_IDV_APPLICANT[:state]) + expect(applicant.zip_code).to eq(Idp::Constants::MOCK_IDV_APPLICANT[:zipcode]) + expect(applicant.email).to eq('no-reply@login.gov') + expect(applicant.unique_id).to be_a(String) + + proofer.request_enroll(applicant) + end + + post :create, params: { password: password, user_bundle_token: jwt } + end + + it 'creates an in-person enrollment record' do + expect(InPersonEnrollment.count).to be(0) + post :create, params: { password: password, user_bundle_token: jwt } + + expect(InPersonEnrollment.count).to be(1) + enrollment = InPersonEnrollment.where(user_id: user.id).first + expect(enrollment.status).to eq('pending') + expect(enrollment.user_id).to eq(user.id) + expect(enrollment.enrollment_code).to be_a(String) + end + + it 'leaves the enrollment in establishing when no enrollment code is returned' do + proofer = UspsInPersonProofing::Mock::Proofer.new + expect(UspsInPersonProofing::Mock::Proofer).to receive(:new).and_return(proofer) + expect(proofer).to receive(:request_enroll).and_return({}) + expect(InPersonEnrollment.count).to be(0) + + post :create, params: { password: password, user_bundle_token: jwt } + + expect(InPersonEnrollment.count).to be(1) + enrollment = InPersonEnrollment.where(user_id: user.id).first + expect(enrollment.status).to eq('establishing') + expect(enrollment.user_id).to eq(user.id) + expect(enrollment.enrollment_code).to be_nil + end end context 'with associated sp session' do diff --git a/spec/jobs/get_usps_proofing_results_job_spec.rb b/spec/jobs/get_usps_proofing_results_job_spec.rb index 392a18be6b2..a21a88b65a2 100644 --- a/spec/jobs/get_usps_proofing_results_job_spec.rb +++ b/spec/jobs/get_usps_proofing_results_job_spec.rb @@ -286,7 +286,7 @@ end it 'does not request any enrollment records' do - # no stubbing means this test will fail if the UspsInPersonProofer + # no stubbing means this test will fail if the UspsInPersonProofing::Proofer # tries to connect to the USPS API job.perform Time.zone.now end diff --git a/spec/services/usps_in_person_proofer_spec.rb b/spec/services/usps_in_person_proofer_spec.rb index c61b86a1bb6..cb242644f77 100644 --- a/spec/services/usps_in_person_proofer_spec.rb +++ b/spec/services/usps_in_person_proofer_spec.rb @@ -1,9 +1,9 @@ require 'rails_helper' -RSpec.describe UspsInPersonProofer do +RSpec.describe UspsInPersonProofing::Proofer do include UspsIppHelper - let(:subject) { UspsInPersonProofer.new } + let(:subject) { UspsInPersonProofing::Proofer.new } describe '#retrieve_token!' do it 'sets token and token_expires_at' do