From 8b1ca3466fc34fb3dd1b1dc80b36b68caef2e3f5 Mon Sep 17 00:00:00 2001 From: nayij Date: Mon, 21 Sep 2026 16:27:30 +0100 Subject: [PATCH] Restore submit button double-click guard on summary page The prevent-multiple-clicks class added in #413 was dropped when the i18n-linting branch (#436) was merged, leaving the summary submit button with preventDoubleClick: false and no client-side guard at all. Users clicking Submit repeatedly on a slow submission (e.g. many file uploads) could trigger duplicate form submissions. Re-add the class so debounce-click.js ignores repeat clicks for 10s, and add a test that renders the real summary page, runs initDebounceClick() against it and asserts a double click only submits the form once. Co-Authored-By: Claude Sonnet 5 --- src/server/plugins/engine/views/summary.html | 1 + test/form/summary-submit-button.test.js | 92 ++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 test/form/summary-submit-button.test.js diff --git a/src/server/plugins/engine/views/summary.html b/src/server/plugins/engine/views/summary.html index e18ffd654..582346f84 100644 --- a/src/server/plugins/engine/views/summary.html +++ b/src/server/plugins/engine/views/summary.html @@ -88,6 +88,7 @@

{{ t('pages.summary.declaration') } When a better fix is implemented, this class can be removed and preventDoubleClick should be set back to true. #} {{ govukButton({ text: submitButtonText, + classes: "prevent-multiple-clicks", name: "action", value: "send", preventDoubleClick: false diff --git a/test/form/summary-submit-button.test.js b/test/form/summary-submit-button.test.js new file mode 100644 index 000000000..3bfcd3e1f --- /dev/null +++ b/test/form/summary-submit-button.test.js @@ -0,0 +1,92 @@ +import { join } from 'node:path' + +import { initDebounceClick } from '~/src/client/javascripts/debounce-click.js' +import { FORM_PREFIX } from '~/src/server/constants.js' +import { createServer } from '~/src/server/index.js' +import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' +import * as fixtures from '~/test/fixtures/index.js' +import { renderResponse } from '~/test/helpers/component-helpers.js' +import { getCookie, getCookieHeader } from '~/test/utils/get-cookie.js' + +const basePath = `${FORM_PREFIX}/minimal` + +jest.mock('~/src/server/plugins/engine/services/formsService.js') +jest.mock('~/src/server/plugins/engine/services/formSubmissionService.js') + +describe('Page: /summary submit button', () => { + /** @type {Server} */ + let server + + /** @type {ReturnType} */ + let headers + + beforeAll(async () => { + server = await createServer({ + formFileName: 'minimal.js', + formFilePath: join(import.meta.dirname, 'definitions'), + enforceCsrf: true + }) + + await server.initialize() + + const response = await server.inject({ + url: `${basePath}/start` + }) + + const csrfToken = getCookie(response, 'crumb') + headers = getCookieHeader(response, ['session', 'crumb']) + + await server.inject({ + url: `${basePath}/start`, + method: 'POST', + headers, + payload: { + crumb: csrfToken, + field: 'value' + } + }) + }) + + beforeEach(() => { + jest.mocked(getFormMetadata).mockResolvedValue(fixtures.form.metadata) + }) + + afterAll(async () => { + await server.stop() + }) + + afterEach(() => { + document.body.innerHTML = '' + }) + + it('only submits the form once when the submit button is clicked twice in quick succession', async () => { + const { container } = await renderResponse(server, { + url: `${basePath}/summary`, + headers + }) + + // Wire up the client-side JS against the real rendered page, as + // application.js does in the browser via initAll() + initDebounceClick() + + const $form = /** @type {HTMLFormElement} */ ( + container.getByRole('button', { name: 'Submit' }).closest('form') + ) + const $submit = container.getByRole('button', { name: 'Submit' }) + + const onSubmit = jest.fn((/** @type {Event} */ event) => + // Stop jsdom attempting a real navigation + event.preventDefault() + ) + $form.addEventListener('submit', onSubmit) + + $submit.click() + $submit.click() + + expect(onSubmit).toHaveBeenCalledTimes(1) + }) +}) + +/** + * @import { Server } from '@hapi/hapi' + */