diff --git a/app/javascript/packages/captcha-submit-button/captcha-submit-button-element.spec.ts b/app/javascript/packages/captcha-submit-button/captcha-submit-button-element.spec.ts index f0bdece5bb7..b8bc9bf6f1b 100644 --- a/app/javascript/packages/captcha-submit-button/captcha-submit-button-element.spec.ts +++ b/app/javascript/packages/captcha-submit-button/captcha-submit-button-element.spec.ts @@ -1,17 +1,20 @@ import quibble from 'quibble'; import type { SinonStub } from 'sinon'; -import userEvent from '@testing-library/user-event'; +import baseUserEvent from '@testing-library/user-event'; import { screen, waitFor, fireEvent } from '@testing-library/dom'; import { useSandbox, useDefineProperty } from '@18f/identity-test-helpers'; import '@18f/identity-spinner-button/spinner-button-element'; describe('CaptchaSubmitButtonElement', () => { - const sandbox = useSandbox(); + let FAILED_LOAD_DELAY_MS: number; + const sandbox = useSandbox({ useFakeTimers: true }); + const { clock } = sandbox; + const userEvent = baseUserEvent.setup({ advanceTimers: clock.tick }); const trackError = sandbox.stub(); before(async () => { quibble('@18f/identity-analytics', { trackError }); - await import('./captcha-submit-button-element'); + ({ FAILED_LOAD_DELAY_MS } = await import('./captcha-submit-button-element')); }); afterEach(() => { @@ -117,7 +120,6 @@ describe('CaptchaSubmitButtonElement', () => { await userEvent.click(button); await waitFor(() => expect((form.submit as SinonStub).called).to.be.true()); - expect(grecaptcha.ready).to.have.been.called(); expect(grecaptcha.execute).to.have.been.calledWith(RECAPTCHA_SITE_KEY, { action: RECAPTCHA_ACTION_NAME, }); @@ -126,6 +128,57 @@ describe('CaptchaSubmitButtonElement', () => { }); }); + context('with recaptcha not loaded by time of submission', () => { + beforeEach(() => { + delete (global as any).grecaptcha; + }); + + it('enqueues the challenge callback to be run once recaptcha loads', async () => { + const button = screen.getByRole('button', { name: 'Submit' }); + const form = document.querySelector('form')!; + sandbox.stub(form, 'submit'); + + await userEvent.click(button); + + expect(form.submit).not.to.have.been.called(); + /* eslint-disable no-underscore-dangle */ + expect((globalThis as any).___grecaptcha_cfg).to.have.keys('fns'); + expect((globalThis as any).___grecaptcha_cfg.fns) + .to.be.an('array') + .with.lengthOf.greaterThan(0); + (globalThis as any).___grecaptcha_cfg.fns.forEach((callback) => callback()); + /* eslint-enable no-underscore-dangle */ + + await expect(form.submit).to.eventually.be.called(); + }); + }); + + context('with only recaptcha loader script loaded by time of submission', () => { + // The loader script will define the `grecaptcha` global and `ready` function, but it will + // not define `execute`. + beforeEach(() => { + delete (global as any).grecaptcha.execute; + delete (global as any).grecaptcha.enterprise.execute; + }); + + it('enqueues the challenge callback to be run once recaptcha loads', async () => { + const button = screen.getByRole('button', { name: 'Submit' }); + const form = document.querySelector('form')!; + sandbox.stub(form, 'submit'); + + await userEvent.click(button); + + expect(grecaptcha.ready).to.have.been.called(); + + // Simulate reCAPTCHA full script loaded + (global as any).grecaptcha.execute = sandbox.stub().resolves(RECAPTCHA_TOKEN_VALUE); + const callback = (grecaptcha.ready as SinonStub).getCall(0).args[0]; + callback(); + + await expect(form.submit).to.eventually.be.called(); + }); + }); + context('with recaptcha enterprise', () => { beforeEach(() => { const element = document.querySelector('lg-captcha-submit-button')!; @@ -141,7 +194,6 @@ describe('CaptchaSubmitButtonElement', () => { await userEvent.click(button); await waitFor(() => expect((form.submit as SinonStub).called).to.be.true()); - expect(grecaptcha.enterprise.ready).to.have.been.called(); expect(grecaptcha.enterprise.execute).to.have.been.calledWith(RECAPTCHA_SITE_KEY, { action: RECAPTCHA_ACTION_NAME, }); @@ -156,19 +208,18 @@ describe('CaptchaSubmitButtonElement', () => { delete (global as any).grecaptcha; }); - it('does not prevent default form submission', async () => { + it('submits the form if recaptcha is still not loaded after reasonable delay', async () => { const button = screen.getByRole('button', { name: 'Submit' }); const form = document.querySelector('form')!; - - let didSubmit = false; - form.addEventListener('submit', (event) => { - expect(event.defaultPrevented).to.equal(false); - event.preventDefault(); - didSubmit = true; - }); + sandbox.stub(form, 'submit'); await userEvent.click(button); - await waitFor(() => expect(didSubmit).to.be.true()); + + expect(form.submit).not.to.have.been.called(); + clock.tick(FAILED_LOAD_DELAY_MS - 1); + expect(form.submit).not.to.have.been.called(); + clock.tick(1); + expect(form.submit).to.have.been.called(); }); }); diff --git a/app/javascript/packages/captcha-submit-button/captcha-submit-button-element.ts b/app/javascript/packages/captcha-submit-button/captcha-submit-button-element.ts index 6177b63732a..6017da75fb7 100644 --- a/app/javascript/packages/captcha-submit-button/captcha-submit-button-element.ts +++ b/app/javascript/packages/captcha-submit-button/captcha-submit-button-element.ts @@ -1,5 +1,11 @@ import { trackError } from '@18f/identity-analytics'; +/** + * Maximum time (in milliseconds) to wait on reCAPTCHA to finish loading once a form is submitted + * before considering reCAPTCHA as having failed to load. + */ +export const FAILED_LOAD_DELAY_MS = 5_000; + class CaptchaSubmitButtonElement extends HTMLElement { form: HTMLFormElement | null; @@ -46,7 +52,7 @@ class CaptchaSubmitButtonElement extends HTMLElement { } invokeChallenge() { - this.recaptchaClient!.ready(async () => { + this.#onReady(async () => { const { recaptchaSiteKey: siteKey, recaptchaAction: action } = this; let token; @@ -62,7 +68,7 @@ class CaptchaSubmitButtonElement extends HTMLElement { } shouldInvokeChallenge(): boolean { - return !!(this.recaptchaSiteKey && this.recaptchaClient); + return !!this.recaptchaSiteKey; } handleFormSubmit = (event: SubmitEvent) => { @@ -71,6 +77,25 @@ class CaptchaSubmitButtonElement extends HTMLElement { this.invokeChallenge(); } }; + + #onReady(callback: Parameters[0]) { + if (this.recaptchaClient) { + this.recaptchaClient.ready(callback); + } else { + // If reCAPTCHA hasn't finished loading by the time the form is submitted, we can enqueue the + // callback to be invoked once loaded by appending a callback to the ___grecaptcha_cfg global. + // + // See: https://developers.google.com/recaptcha/docs/loading + + const failedLoadTimeoutId = setTimeout(() => this.submit(), FAILED_LOAD_DELAY_MS); + const clearFailedLoadTimeout = () => clearTimeout(failedLoadTimeoutId); + + /* eslint-disable no-underscore-dangle */ + globalThis.___grecaptcha_cfg ??= { fns: [] }; + globalThis.___grecaptcha_cfg.fns.push(clearFailedLoadTimeout, callback); + /* eslint-enable no-underscore-dangle */ + } + } } declare global {