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;