Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 2 additions & 10 deletions app/forms/idv/api_image_upload_form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -75,16 +75,8 @@ def validate_images
def validate_image(image_key)
file = params[image_key]

unless file.respond_to?(:content_type)
errors.add(image_key, t('doc_auth.errors.not_a_file'))
return
end

data = file.read
file.rewind

return if file.content_type.start_with?('image/') && data.present?
errors.add(image_key, t('doc_auth.errors.must_be_image'))
return if file.respond_to?(:read)
errors.add(image_key, t('doc_auth.errors.not_a_file'))
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import Button from './button';
import useI18n from '../hooks/use-i18n';
import DeviceContext from '../context/device';
import FileBase64CacheContext from '../context/file-base64-cache';
import UploadContext from '../context/upload';

/** @typedef {import('react').ReactNode} ReactNode */

Expand All @@ -28,9 +29,6 @@ import FileBase64CacheContext from '../context/file-base64-cache';
* @prop {'user'|'environment'=} capture Facing mode of capture. If capture is not specified and a
* camera is supported, defaults to the Acuant environment camera capture.
* @prop {string=} className Optional additional class names.
* @prop {number=} minimumGlareScore Minimum glare score to be considered acceptable.
* @prop {number=} minimumSharpnessScore Minimum sharpness score to be considered acceptable.
* @prop {number=} minimumFileSize Minimum file size (in bytes) to be considered acceptable.
* @prop {boolean=} allowUpload Whether to allow file upload. Defaults to `true`.
* @prop {ReactNode=} errorMessage Error to show.
*/
Expand All @@ -40,24 +38,33 @@ import FileBase64CacheContext from '../context/file-base64-cache';
*
* @type {number}
*/
const DEFAULT_ACCEPTABLE_GLARE_SCORE = 50;
const ACCEPTABLE_GLARE_SCORE = 50;

/**
* The minimum sharpness score value to be considered acceptable.
*
* @type {number}
*/
const DEFAULT_ACCEPTABLE_SHARPNESS_SCORE = 50;
const ACCEPTABLE_SHARPNESS_SCORE = 50;

/**
* The minimum file size (bytes) for an image to be considered acceptable.
*
* @type {number}
*/
const DEFAULT_ACCEPTABLE_FILE_SIZE_BYTES =
process.env.ACUANT_MINIMUM_FILE_SIZE === undefined
? 250 * 1024
: Number(process.env.ACUANT_MINIMUM_FILE_SIZE);
export const ACCEPTABLE_FILE_SIZE_BYTES = 250 * 1024;

/**
* Given a file, returns minimum acceptable file size in bytes, depending on the type of file and
* the current environment.
*
* @param {Blob} file File to assess.
*
* @return {number} Minimum file size, in bytes.
*/
export function getMinimumFileSize(file) {
return file.type.startsWith('image/') ? ACCEPTABLE_FILE_SIZE_BYTES : 0;
}

/**
* Returns an instance of File representing the given data URL.
Expand Down Expand Up @@ -90,16 +97,14 @@ function AcuantCapture(
onChange = () => {},
capture,
className,
minimumGlareScore = DEFAULT_ACCEPTABLE_GLARE_SCORE,
minimumSharpnessScore = DEFAULT_ACCEPTABLE_SHARPNESS_SCORE,
minimumFileSize = DEFAULT_ACCEPTABLE_FILE_SIZE_BYTES,
allowUpload = true,
errorMessage,
},
ref,
) {
const fileCache = useContext(FileBase64CacheContext);
const { isReady, isError, isCameraSupported } = useContext(AcuantContext);
const { isMockClient } = useContext(UploadContext);
const inputRef = useRef(/** @type {?HTMLInputElement} */ (null));
const isForceUploading = useRef(false);
const [isCapturing, setIsCapturing] = useState(false);
Expand Down Expand Up @@ -151,7 +156,7 @@ function AcuantCapture(
* @param {Blob?} nextValue Next value candidate.
*/
function onChangeIfValid(nextValue) {
if (nextValue && nextValue.size < minimumFileSize) {
if (nextValue && nextValue.size < getMinimumFileSize(nextValue)) {
setOwnErrorMessage(t('errors.doc_auth.photo_file_size'));
} else {
setOwnErrorMessage(null);
Expand Down Expand Up @@ -190,9 +195,9 @@ function AcuantCapture(
<FullScreen onRequestClose={() => setIsCapturing(false)}>
<AcuantCaptureCanvas
onImageCaptureSuccess={(nextCapture) => {
if (nextCapture.glare < minimumGlareScore) {
if (nextCapture.glare < ACCEPTABLE_GLARE_SCORE) {
setOwnErrorMessage(t('errors.doc_auth.photo_glare'));
} else if (nextCapture.sharpness < minimumSharpnessScore) {
} else if (nextCapture.sharpness < ACCEPTABLE_SHARPNESS_SCORE) {
setOwnErrorMessage(t('errors.doc_auth.photo_blurry'));
} else {
const dataAsBlob = toBlob(nextCapture.image.data);
Expand All @@ -214,7 +219,7 @@ function AcuantCapture(
label={label}
hint={hasCapture || !allowUpload ? undefined : t('doc_auth.tips.document_capture_hint')}
bannerText={bannerText}
accept={['image/*']}
accept={isMockClient ? undefined : ['image/*']}
capture={capture}
value={value}
errorMessage={ownErrorMessage ?? errorMessage}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import CallbackOnMount from './callback-on-mount';
* @param {SubmissionProps} props Props object.
*/
function Submission({ payload, onError }) {
const upload = useContext(UploadContext);
const { upload } = useContext(UploadContext);
const resource = useAsync(upload, payload);

return (
Expand Down
20 changes: 16 additions & 4 deletions app/javascript/packages/document-capture/context/upload.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import React, { createContext } from 'react';
import React, { createContext, useMemo } from 'react';
import defaultUpload from '../services/upload';

const UploadContext = createContext(defaultUpload);
const UploadContext = createContext({
upload: defaultUpload,
isMockClient: false,
});

/** @typedef {import('react').ReactNode} ReactNode */

Expand Down Expand Up @@ -45,6 +48,7 @@ const UploadContext = createContext(defaultUpload);
* @typedef UploadContextProviderProps
*
* @prop {UploadImplementation=} upload Custom upload implementation.
* @prop {boolean=} isMockClient Whether to treat upload as a mock implementation.
* @prop {string} endpoint Endpoint to which payload should be sent.
* @prop {string} csrf CSRF token to send as parameter to upload implementation.
* @prop {Record<string,any>} formData Extra form data to merge into the payload before uploading
Expand All @@ -54,10 +58,18 @@ const UploadContext = createContext(defaultUpload);
/**
* @param {UploadContextProviderProps} props Props object.
*/
function UploadContextProvider({ upload = defaultUpload, endpoint, csrf, formData, children }) {
function UploadContextProvider({
upload = defaultUpload,
isMockClient = false,
endpoint,
csrf,
formData,
children,
}) {
const uploadWithCSRF = (payload) => upload({ ...payload, ...formData }, { endpoint, csrf });
const value = useMemo(() => ({ upload: uploadWithCSRF, isMockClient }), [upload, isMockClient]);

return <UploadContext.Provider value={uploadWithCSRF}>{children}</UploadContext.Provider>;
return <UploadContext.Provider value={value}>{children}</UploadContext.Provider>;
}

export default UploadContext;
Expand Down
2 changes: 2 additions & 0 deletions app/javascript/packs/document-capture.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ document.body.classList.add('js-skip-form-validation');
loadPolyfills(['fetch']).then(() => {
const appRoot = document.getElementById('document-capture-form');
const isLivenessEnabled = appRoot.hasAttribute('data-liveness');
const isMockClient = appRoot.hasAttribute('data-mock-client');

render(
<AcuantProvider
Expand All @@ -36,6 +37,7 @@ loadPolyfills(['fetch']).then(() => {
<UploadContextProvider
endpoint={appRoot.getAttribute('data-endpoint')}
csrf={getMetaContent('csrf-token')}
isMockClient={isMockClient}
formData={{
document_capture_session_uuid: appRoot.getAttribute('data-document-capture-session-uuid'),
locale: i18n.currentLocale(),
Expand Down
1 change: 1 addition & 0 deletions app/views/idv/shared/_document_capture.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<% end %>
<%= tag.div id: 'document-capture-form', data: {
liveness: liveness_checking_enabled?.presence,
mock_client: (DocAuth::Client.doc_auth_vendor == 'mock').presence,
document_capture_session_uuid: flow_session[:document_capture_session_uuid],
endpoint: api_verify_images_url
} %>
Expand Down
1 change: 0 additions & 1 deletion config/locales/doc_auth/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ en:
upload_picture: Upload a photo
use_phone: Use your phone
errors:
must_be_image: File must be an image
not_a_file: The selection was not a valid file
forms:
address1: Address
Expand Down
1 change: 0 additions & 1 deletion config/locales/doc_auth/es.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ es:
upload_picture: Sube una foto
use_phone: Usa tu telefono
errors:
must_be_image: El archivo debe ser una imagen
not_a_file: La selección no era un archivo válido
forms:
address1: Dirección
Expand Down
1 change: 0 additions & 1 deletion config/locales/doc_auth/fr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ fr:
upload_picture: Télécharger une photo
use_phone: Utilisez votre téléphone
errors:
must_be_image: Le fichier doit être une image
not_a_file: La sélection n'était pas un fichier valide
forms:
address1: Adresse
Expand Down
16 changes: 16 additions & 0 deletions spec/controllers/idv/image_uploads_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,22 @@
]
end
end

context 'when a value is an error-formatted yaml file' do
before { params.merge!(back: DocAuthImageFixtures.error_yaml_multipart) }

it 'returns error from yaml file' do
action

json = JSON.parse(response.body, symbolize_names: true)
expect(json[:errors]).to eq [
{
field: 'results',
message: I18n.t('friendly_errors.doc_auth.barcode_could_not_be_read'),
},
]
end
end
end
end
end
27 changes: 0 additions & 27 deletions spec/forms/idv/api_image_upload_form_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,33 +59,6 @@
end
end

context 'when file does not have an image content type' do
let(:tempfile) do
Tempfile.new.tap do |f|
f.write('test')
f.close
end
end
let(:selfie_image) { Rack::Test::UploadedFile.new(tempfile.path, 'text/plain') }

it 'is not valid' do
expect(form.valid?).to eq(false)
expect(form.errors[:selfie]).to eq(['File must be an image'])
end
end

context 'when file is empty' do
let(:tempfile) { Tempfile.new }
let(:selfie_image) { Rack::Test::UploadedFile.new(tempfile.path, 'image/jpeg') }

it 'is not valid' do
expect(form.valid?).to eq(false)
expect(form.errors[:selfie]).to eq(['File must be an image'])
end

after { tempfile.unlink }
end

context 'when document_capture_session_uuid param is missing' do
let(:document_capture_session_uuid) { nil }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,32 @@ import { fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { waitForElementToBeRemoved } from '@testing-library/dom';
import sinon from 'sinon';
import AcuantCapture from '@18f/identity-document-capture/components/acuant-capture';
import AcuantCapture, {
getMinimumFileSize,
ACCEPTABLE_FILE_SIZE_BYTES,
} from '@18f/identity-document-capture/components/acuant-capture';
import { Provider as AcuantContextProvider } from '@18f/identity-document-capture/context/acuant';
import DeviceContext from '@18f/identity-document-capture/context/device';
import I18nContext from '@18f/identity-document-capture/context/i18n';
import render from '../../../support/render';
import { useAcuant } from '../../../support/acuant';
import { useSandbox } from '../../../support/sinon';

describe('document-capture/components/acuant-capture', () => {
const { initialize } = useAcuant();
const sandbox = useSandbox();

describe('getMinimumFileSize', () => {
it('returns zero for non-image file', () => {
const file = new window.File([], 'file.yml', { type: 'application/x-yaml' });
expect(getMinimumFileSize(file)).to.equal(0);
});

it('returns non-zero for image file', () => {
const file = new window.File([], 'file.png', { type: 'image/png' });
expect(getMinimumFileSize(file)).to.be.gt(0);
});
});

context('mobile', () => {
it('renders with assumed capture button support while acuant is not ready and on mobile', () => {
Expand Down Expand Up @@ -146,6 +163,8 @@ describe('document-capture/components/acuant-capture', () => {
});

it('calls onChange with the captured image on successful capture', async () => {
sandbox.stub(window.Blob.prototype, 'size').value(ACCEPTABLE_FILE_SIZE_BYTES);

const onChange = sinon.mock();
const { getByText } = render(
<DeviceContext.Provider value={{ isMobile: true }}>
Expand Down Expand Up @@ -311,6 +330,7 @@ describe('document-capture/components/acuant-capture', () => {
<AcuantCapture label="Image" />
</AcuantContextProvider>
</DeviceContext.Provider>,
{ isMockClient: false },
);

initialize({
Expand Down Expand Up @@ -343,6 +363,8 @@ describe('document-capture/components/acuant-capture', () => {
});

it('removes error message once image is corrected', async () => {
sandbox.stub(window.Blob.prototype, 'size').value(ACCEPTABLE_FILE_SIZE_BYTES);

const { getByText, findByText } = render(
<DeviceContext.Provider value={{ isMobile: true }}>
<AcuantContextProvider sdkSrc="about:blank">
Expand Down Expand Up @@ -602,4 +624,17 @@ describe('document-capture/components/acuant-capture', () => {
expect(defaultPrevented).to.be.false();
expect(window.AcuantCameraUI.start.called).to.be.false();
});

it('restricts accepted file types', () => {
const { getByLabelText } = render(
<AcuantContextProvider sdkSrc="about:blank">
<AcuantCapture label="Image" capture="environment" />
</AcuantContextProvider>,
{ isMockClient: false },
);

const input = getByLabelText('Image');

expect(input.getAttribute('accept')).to.equal('image/*');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@ import React from 'react';
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/dom';
import { fireEvent } from '@testing-library/react';
import { ACCEPTABLE_FILE_SIZE_BYTES } from '@18f/identity-document-capture/components/acuant-capture';
import { UploadFormEntriesError } from '@18f/identity-document-capture/services/upload';
import { AcuantProvider, DeviceContext } from '@18f/identity-document-capture';
import DocumentCapture, {
getFormattedErrorMessages,
} from '@18f/identity-document-capture/components/document-capture';
import render from '../../../support/render';
import { useAcuant } from '../../../support/acuant';
import { useSandbox } from '../../../support/sinon';

describe('document-capture/components/document-capture', () => {
const { initialize } = useAcuant();
const sandbox = useSandbox();

function isFormValid(form) {
return [...form.querySelectorAll('input')].every((input) => input.checkValidity());
Expand All @@ -21,6 +24,7 @@ describe('document-capture/components/document-capture', () => {

beforeEach(() => {
originalHash = window.location.hash;
sandbox.stub(window.Blob.prototype, 'size').value(ACCEPTABLE_FILE_SIZE_BYTES);
});

afterEach(() => {
Expand Down
Loading