diff --git a/app/controllers/concerns/idv_session.rb b/app/controllers/concerns/idv_session.rb index 071f48e2789..89d6764c36a 100644 --- a/app/controllers/concerns/idv_session.rb +++ b/app/controllers/concerns/idv_session.rb @@ -24,6 +24,14 @@ def liveness_upgrade_required? sp_session[:ial2_strict] && !current_user.active_profile&.includes_liveness_check? end + def liveness_checking_enabled? + FeatureManagement.liveness_checking_enabled? && (no_sp? || sp_session[:ial2_strict]) + end + + def no_sp? + sp_session[:issuer].blank? + end + def confirm_idv_vendor_session_started return if flash[:allow_confirmations_continue] redirect_to idv_doc_auth_url unless idv_session.proofing_started? diff --git a/app/controllers/idv/image_upload_controller.rb b/app/controllers/idv/image_upload_controller.rb new file mode 100644 index 00000000000..c9dca63de73 --- /dev/null +++ b/app/controllers/idv/image_upload_controller.rb @@ -0,0 +1,73 @@ +module Idv + class ImageUploadController < ApplicationController + include IdvSession + + def upload + validation_error = validate_request(request) + response = validation_error || upload_and_check_images + render json: response + end + + private + + def upload_and_check_images + doc_response = client.post_images(front_image: @front_image, + back_image: @back_image, + selfie_image: @selfie_image, + liveness_checking_enabled: liveness_checking_enabled?) + return error_json(doc_response.errors.first) unless doc_response.success? + upload_info = { + documents: doc_response.to_h, + } + store_pii(doc_response) + user_session['idv/doc_auth']['api_upload'] = upload_info + success_json('Uploaded images') + end + + def store_pii(doc_response) + user_session['idv/doc_auth'][:pii_from_doc] = doc_response.pii_from_doc.merge( + uuid: current_user.uuid, + phone: current_user.phone_configurations.take&.phone, + ) + end + + def validate_request(request) + steps = %i[check_content_type check_image_fields] + steps.each do |step| + err = method(step).call(request) + return error_json(err) if err + end + nil + end + + def check_content_type(request) + "Invalid content type #{request.content_type}" if request.content_type != 'application/json' + end + + def check_image_fields(request) + data = request.body.read + @front_image = data['front'] + @back_image = data['back'] + @selfie_image = data['selfie'] + 'Missing image keys' unless [@front_image, @back_image, @selfie_image].all? + end + + def error_json(reason) + { + status: 'error', + message: reason, + } + end + + def success_json(reason) + { + status: 'success', + message: reason, + } + end + + def client + @client ||= DocAuthClient.client + end + end +end diff --git a/app/javascript/app/document-capture/components/documents-step.jsx b/app/javascript/app/document-capture/components/documents-step.jsx index 399f88dfcbc..7bf93ec6ff5 100644 --- a/app/javascript/app/document-capture/components/documents-step.jsx +++ b/app/javascript/app/document-capture/components/documents-step.jsx @@ -9,8 +9,8 @@ import DeviceContext from '../context/device'; /** * @typedef DocumentsStepValue * - * @prop {DataURLFile=} front_image Front image value. - * @prop {DataURLFile=} back_image Back image value. + * @prop {DataURLFile=} front Front image value. + * @prop {DataURLFile=} back Back image value. */ /** @@ -46,24 +46,20 @@ function DocumentsStep({ value = {}, onChange = () => {} }) {
  • {t('doc_auth.tips.document_capture_id_text3')}
  • {!isMobile &&
  • {t('doc_auth.tips.document_capture_id_text4')}
  • } - {DOCUMENT_SIDES.map((side) => { - const inputKey = `${side}_image`; - - return ( - onChange({ [inputKey]: nextValue })} - className="id-card-file-input" - /> - ); - })} + {DOCUMENT_SIDES.map((side) => ( + onChange({ [side]: nextValue })} + className="id-card-file-input" + /> + ))} ); } @@ -75,6 +71,6 @@ function DocumentsStep({ value = {}, onChange = () => {} }) { * * @return {boolean} Whether step is valid. */ -export const isValid = (value) => Boolean(value.front_image && value.back_image); +export const isValid = (value) => Boolean(value.front && value.back); export default DocumentsStep; diff --git a/app/javascript/app/document-capture/context/upload.js b/app/javascript/app/document-capture/context/upload.js deleted file mode 100644 index e54b9d3c7b7..00000000000 --- a/app/javascript/app/document-capture/context/upload.js +++ /dev/null @@ -1,6 +0,0 @@ -import { createContext } from 'react'; -import upload from '../services/upload'; - -const UploadContext = createContext(upload); - -export default UploadContext; diff --git a/app/javascript/app/document-capture/context/upload.jsx b/app/javascript/app/document-capture/context/upload.jsx new file mode 100644 index 00000000000..19c83936b28 --- /dev/null +++ b/app/javascript/app/document-capture/context/upload.jsx @@ -0,0 +1,30 @@ +import React, { createContext } from 'react'; +import defaultUpload from '../services/upload'; + +const UploadContext = createContext(defaultUpload); + +/** @typedef {import('react').ReactNode} ReactNode */ + +/** + * @typedef {(payload:Record,csrf?:string)=>Promise} UploadImplementation + */ + +/** + * @typedef UploadContextProviderProps + * + * @prop {UploadImplementation=} upload Custom upload implementation. + * @prop {string=} csrf CSRF token to send as parameter to upload implementation. + * @prop {ReactNode} children Child elements. + */ + +/** + * @param {UploadContextProviderProps} props Props object. + */ +function UploadContextProvider({ upload = defaultUpload, csrf, children }) { + const uploadWithCSRF = (payload) => upload(payload, csrf); + + return {children}; +} + +export default UploadContext; +export { UploadContextProvider as Provider }; diff --git a/app/javascript/app/document-capture/services/upload.js b/app/javascript/app/document-capture/services/upload.js index b7b8bb109df..bf185e0621b 100644 --- a/app/javascript/app/document-capture/services/upload.js +++ b/app/javascript/app/document-capture/services/upload.js @@ -1,8 +1,30 @@ -function upload(payload) { - return new Promise((resolve, reject) => { - const isFailure = window.location.search === '?fail'; - setTimeout(isFailure ? reject : () => resolve({ ...payload, saved: true }), 2000); - }); +/** + * Endpoint to which payload is submitted. + * + * @type {string} + */ +const ENDPOINT = '/api/verify/upload'; + +/** + * @type {import('../context/upload').UploadImplementation} + */ +function upload(payload, csrf) { + return fetch(ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrf, + }, + body: JSON.stringify(payload), + }) + .then((response) => response.json()) + .then((result) => { + if (!['success', 'error'].includes(result.status)) { + throw Error('Malformed response'); + } + + return result; + }); } export default upload; diff --git a/app/javascript/packs/document-capture.jsx b/app/javascript/packs/document-capture.jsx index 2786431b4c7..997f4d9d114 100644 --- a/app/javascript/packs/document-capture.jsx +++ b/app/javascript/packs/document-capture.jsx @@ -5,6 +5,7 @@ import AssetContext from '../app/document-capture/context/asset'; import I18nContext from '../app/document-capture/context/i18n'; import DeviceContext from '../app/document-capture/context/device'; import { Provider as AcuantProvider } from '../app/document-capture/context/acuant'; +import { Provider as UploadContextProvider } from '../app/document-capture/context/upload'; const { I18n: i18n, assets } = window.LoginGov; @@ -27,11 +28,13 @@ render( endpoint={getMetaContent('acuant-sdk-initialization-endpoint')} > - - - - - + + + + + + + , appRoot, diff --git a/app/services/idv/steps/doc_auth_base_step.rb b/app/services/idv/steps/doc_auth_base_step.rb index 9da137d70e4..cd43459deb7 100644 --- a/app/services/idv/steps/doc_auth_base_step.rb +++ b/app/services/idv/steps/doc_auth_base_step.rb @@ -2,6 +2,8 @@ module Idv module Steps class DocAuthBaseStep < Flow::BaseStep + include IdvSession + def initialize(flow) super(flow, :doc_auth) end @@ -97,13 +99,13 @@ def post_back_image result end - def post_images + def post_images(front, back, selfie) return throttled_response if throttled_else_increment result = DocAuthClient.client.post_images( - front_image: front_image.read, - back_image: back_image.read, - selfie_image: selfie_image&.read, + front_image: front, + back_image: back, + selfie_image: selfie, liveness_checking_enabled: liveness_checking_enabled?, ) # DP: should these cost recordings happen in the doc_auth_client? @@ -171,14 +173,6 @@ def mark_document_capture_or_image_upload_steps_complete end end - def liveness_checking_enabled? - FeatureManagement.liveness_checking_enabled? && (no_sp? || sp_session[:ial2_strict]) - end - - def no_sp? - sp_session[:issuer].blank? - end - def mobile? client = DeviceDetector.new(request.user_agent) client.device_type != 'desktop' diff --git a/app/services/idv/steps/document_capture_step.rb b/app/services/idv/steps/document_capture_step.rb index b5170a20bbb..632e85f21fa 100644 --- a/app/services/idv/steps/document_capture_step.rb +++ b/app/services/idv/steps/document_capture_step.rb @@ -2,18 +2,28 @@ module Idv module Steps class DocumentCaptureStep < DocAuthBaseStep def call - response = post_images - if response.success? + api_upload = flow_session['api_upload'] + response = (api_upload && api_upload['documents']) || post_form_images + handle_response(response) + end + + private + + def post_form_images + selfie = selfie_image ? selfie_image.read : nil + post_images(front_image.read, back_image.read, selfie) + end + + def handle_response(response) + if response.to_h[:success] save_proofing_components - extract_pii_from_doc(response) + extract_pii_from_doc(response) unless flow_session[:pii_from_doc] response else handle_document_verification_failure(response) end end - private - def handle_document_verification_failure(response) mark_step_incomplete(:document_capture) notice = if liveness_checking_enabled? diff --git a/app/services/idv/steps/selfie_step.rb b/app/services/idv/steps/selfie_step.rb index e45d54f000a..7dba97f3749 100644 --- a/app/services/idv/steps/selfie_step.rb +++ b/app/services/idv/steps/selfie_step.rb @@ -46,6 +46,7 @@ def handle_selfie_step_failure(failure_response) failure(failure_response.errors.first, failure_response.to_h) end + # rubocop:disable Naming/AccessorMethodName def results_response @results_response ||= DocAuthClient.client.get_results( instance_id: flow_session[:instance_id], diff --git a/config/routes.rb b/config/routes.rb index 08359683f86..53ff03f5efb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -20,6 +20,9 @@ # Twilio Request URL for inbound SMS post '/api/sms/receive' => 'sms#receive' + # Image upload API for doc auth + post '/api/verify/upload' => 'idv/image_upload#upload' + post '/api/service_provider' => 'service_provider#update' match '/api/voice/otp' => 'voice/otp#show', via: %i[get post], diff --git a/spec/controllers/idv/image_upload_controller_spec.rb b/spec/controllers/idv/image_upload_controller_spec.rb new file mode 100644 index 00000000000..a9730a3b00a --- /dev/null +++ b/spec/controllers/idv/image_upload_controller_spec.rb @@ -0,0 +1,69 @@ +require 'rails_helper' + +describe Idv::ImageUploadController do + describe '#upload' do + let(:content_type) { 'application/json' } + let(:upload_errors) { [] } + before do + sign_in_as_user + request.content_type = content_type + response_mock = instance_double(Acuant::Responses::ResponseWithPii, + success?: upload_errors.empty?, + errors: upload_errors, + to_h: { + success: upload_errors.empty?, + errors: upload_errors, + }, + pii_from_doc: {}) + client_mock = instance_double(Acuant::AcuantClient, post_images: response_mock) + allow(subject).to receive(:client).and_return client_mock + subject.user_session['idv/doc_auth'] = {} unless subject.user_session['idv/doc_auth'] + end + context 'with an invalid content type' do + let(:content_type) { 'text/plain' } + it 'supplies an error status' do + post :upload, params: {} + response_json = JSON.parse(response.body) + expect(response_json['status']).to eq('error') + expect(response_json['message']).to eq("Invalid content type #{request.content_type}") + end + end + it 'returns error status when not provided image fields' do + post :upload, params: { + 'not': 'right', + 'back': 'back_image', + }, format: :json + response_json = JSON.parse(response.body) + expect(response_json['status']).to eq('error') + expect(response_json['message']).to eq('Missing image keys') + end + + context 'when image upload succeeds' do + it 'returns a successful response and modifies the session' do + post :upload, params: { + 'front': 'front_image', + 'back': 'back_image', + 'selfie': 'selfie_image', + }, format: :json + response_json = JSON.parse(response.body) + expect(response_json['status']).to eq('success') + expect(response_json['message']).to eq('Uploaded images') + expect(subject.user_session['idv/doc_auth']).to include('api_upload') + end + end + context 'when image upload fails' do + let(:upload_errors) { ['Too blurry', 'Wrong document'] } + it 'returns an error response and does not modify the session' do + post :upload, params: { + 'front': 'front_image', + 'back': 'back_image', + 'selfie': 'selfie_image', + }, format: :json + response_json = JSON.parse(response.body) + expect(response_json['status']).to eq('error') + expect(response_json['message']).to eq('Too blurry') + expect(subject.user_session['idv/doc_auth']).not_to include('api_upload') + end + end + end +end diff --git a/spec/javascripts/app/document-capture/components/document-capture-spec.jsx b/spec/javascripts/app/document-capture/components/document-capture-spec.jsx index 69cf26e0cce..67a8a81d900 100644 --- a/spec/javascripts/app/document-capture/components/document-capture-spec.jsx +++ b/spec/javascripts/app/document-capture/components/document-capture-spec.jsx @@ -46,7 +46,7 @@ describe('document-capture/components/document-capture', () => { userEvent.click(submitButton); const confirmation = await findByText( - 'Finished sending: {"front_image":"data:image/png;base64,","back_image":"data:image/png;base64,","selfie":"data:image/png;base64,"}', + 'Finished sending: {"front":"data:image/png;base64,","back":"data:image/png;base64,","selfie":"data:image/png;base64,"}', ); expect(confirmation).to.be.ok(); diff --git a/spec/javascripts/app/document-capture/components/documents-step-spec.jsx b/spec/javascripts/app/document-capture/components/documents-step-spec.jsx index e65f88aaac6..450ab7dbc55 100644 --- a/spec/javascripts/app/document-capture/components/documents-step-spec.jsx +++ b/spec/javascripts/app/document-capture/components/documents-step-spec.jsx @@ -26,7 +26,7 @@ describe('document-capture/components/documents-step', () => { onChange.callsFake((nextValue) => { expect(nextValue).to.deep.equal({ - front_image: new DataURLFile('data:image/png;base64,', 'upload.png'), + front: new DataURLFile('data:image/png;base64,', 'upload.png'), }); done(); }); diff --git a/spec/javascripts/app/document-capture/context/upload-spec.jsx b/spec/javascripts/app/document-capture/context/upload-spec.jsx new file mode 100644 index 00000000000..07876907c0a --- /dev/null +++ b/spec/javascripts/app/document-capture/context/upload-spec.jsx @@ -0,0 +1,56 @@ +import React, { createElement, useContext, useEffect } from 'react'; +import { render as baseRender } from '@testing-library/react'; +import render from '../../../support/render'; +import UploadContext, { + Provider as UploadContextProvider, +} from '../../../../../app/javascript/app/document-capture/context/upload'; +import defaultUpload from '../../../../../app/javascript/app/document-capture/services/upload'; + +describe('document-capture/context/upload', () => { + it('defaults to the default upload service', () => { + baseRender( + createElement(() => { + const upload = useContext(UploadContext); + expect(upload).to.equal(defaultUpload); + return null; + }), + ); + }); + + it('can be overridden with custom upload behavior', (done) => { + render( + Promise.resolve({ ...payload, received: true })}> + {createElement(() => { + const upload = useContext(UploadContext); + useEffect(() => { + upload({ sent: true }).then((result) => { + expect(result).to.deep.equal({ sent: true, received: true }); + done(); + }); + }, [upload]); + return null; + })} + , + ); + }); + + it('can be provide csrf to make available to uploader', (done) => { + render( + Promise.resolve({ ...payload, receivedCSRF: csrf })} + csrf="example" + > + {createElement(() => { + const upload = useContext(UploadContext); + useEffect(() => { + upload({ sent: true }).then((result) => { + expect(result).to.deep.equal({ sent: true, receivedCSRF: 'example' }); + done(); + }); + }, [upload]); + return null; + })} + , + ); + }); +}); diff --git a/spec/javascripts/support/render.jsx b/spec/javascripts/support/render.jsx index f408bb1f42e..59a37fed956 100644 --- a/spec/javascripts/support/render.jsx +++ b/spec/javascripts/support/render.jsx @@ -1,7 +1,7 @@ import React from 'react'; import { render } from '@testing-library/react'; import sinon from 'sinon'; -import UploadContext from '../../../app/javascript/app/document-capture/context/upload'; +import { Provider as UploadContextProvider } from '../../../app/javascript/app/document-capture/context/upload'; /** * Pass-through to React Testing Library, which applies default context values @@ -21,7 +21,7 @@ function renderWithDefaultContext(element) { .onCall(1) .throws(); - return render({element}); + return render({element}); } export default renderWithDefaultContext;