From e1c41ea003744f04e6e3530cf11c219380dd408a Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 28 Nov 2025 14:34:57 +0000 Subject: [PATCH 1/2] Always store checkboxes as arrays on the backend Prevents users changing their question from radio->checkbox and submitting without valid state --- .../forms/register-as-a-unicorn-breeder.yaml | 251 +----------------- .../engine/components/CheckboxesField.ts | 16 +- 2 files changed, 20 insertions(+), 247 deletions(-) diff --git a/src/server/forms/register-as-a-unicorn-breeder.yaml b/src/server/forms/register-as-a-unicorn-breeder.yaml index a92b727ab..c10044ac7 100644 --- a/src/server/forms/register-as-a-unicorn-breeder.yaml +++ b/src/server/forms/register-as-a-unicorn-breeder.yaml @@ -2,131 +2,6 @@ name: Register as a unicorn breeder declaration: "

All the answers you have provided are true to the best of your knowledge.

" pages: - - path: '/whats-your-name' - title: What's your name? - components: - - type: TextField - name: textField - title: Name - hint: - This is a single line text box. We use it to ask for information that's - likely to be 1 sentence - options: - required: true - schema: {} - next: - - path: '/whats-your-email-address' - section: section - - title: Summary - path: '/summary' - controller: './pages/summary.js' - components: [] - next: [] - - path: '/whats-your-email-address' - title: What's your email address? - components: - - name: MaTzaT - options: - required: true - type: EmailAddressField - title: Email adress - schema: {} - hint: This is an email address. An email address must contain an at sign @ - next: - - path: '/whats-your-phone-number' - section: section - - path: '/whats-your-phone-number' - title: What's your phone number? - components: - - name: BdKgCe - options: - required: true - type: TelephoneNumberField - title: Phone number - schema: {} - hint: - This is a telephone number. This field can only contain numbers and the - + symbol - next: - - path: '/whats-your-address' - section: section - - path: '/whats-your-address' - title: What's your address? - components: - - name: wZLWPy - options: - required: true - usePostcodeLookup: true - type: UkAddressField - title: What is your billing address - shortDescription: Billing address - schema: {} - hint: This is a UK billing address. Users must enter address line 1, town and a postcode - - name: dfTGhD - options: {} - schema: {} - type: MultilineTextField - title: Delivery notes - hint: - Enter some instructions for the delivery person - - name: drGHuj - options: - required: true - type: UkAddressField - title: What is your delivery address - schema: {} - hint: This is a UK delivery address. Users must enter address line 1, town and a postcode - next: - - path: '/do-you-want-your-unicorn-breeder-certificate-sent-to-this-address' - section: section - - path: '/do-you-want-your-unicorn-breeder-certificate-sent-to-this-address' - title: Do you want your unicorn breeder certificate sent to this address? - components: - - name: dBfuID - options: {} - type: YesNoField - title: Send certificate to same address - schema: {} - hint: - This is a yes or no question. We can branch to different questions based - on the answer - values: - type: listRef - next: - - path: '/what-address-do-you-want-the-certificate-sent-to' - condition: oyGPwP - - path: '/when-does-your-unicorn-insurance-policy-start' - condition: '' - section: section - - path: '/what-address-do-you-want-the-certificate-sent-to' - title: What address do you want the certificate sent to? - components: - - name: AegFro - options: {} - type: UkAddressField - title: Address to send certificate - schema: {} - hint: - This is a simple branch to an extra question - it's shown to users who select - 'no' when asked if this is the address where the certificate should be sent - next: - - path: '/when-does-your-unicorn-insurance-policy-start' - section: section - - title: When does your unicorn insurance policy start? - path: '/when-does-your-unicorn-insurance-policy-start' - section: Regnsa - next: - - path: '/upload-your-insurance-certificate' - components: - - name: mjAccr - options: {} - type: DatePartsField - title: Unicorn insurance policy start date - schema: {} - hint: - This is a date. We can add custom validation to the field based on your - requirements. For example, the date entered must be before or after a certain - date - title: How many unicorns do you expect to breed each year? path: '/how-many-unicorns-do-you-expect-to-breed-each-year' section: susaYr @@ -135,7 +10,7 @@ pages: components: - name: aitzzV options: {} - type: RadiosField + type: CheckboxesField list: IeFOkf title: Number of unicorns schema: {} @@ -146,7 +21,7 @@ pages: path: '/what-type-of-unicorns-will-you-breed' section: susaYr next: - - path: '/where-will-you-keep-the-unicorns' + - path: '/summary' components: - name: DyfjJC options: {} @@ -157,122 +32,12 @@ pages: hint: This is a check box. Users can select more than one option values: type: listRef - - title: Where will you keep the unicorns? - path: '/where-will-you-keep-the-unicorns' - section: susaYr - next: - - path: '/how-many-members-of-staff-will-look-after-the-unicorns' - components: - - name: dfGYuk - options: {} - schema: {} - type: EastingNorthingField - title: Easting and northing - shortDescription: Location - hint: - This is an Easting and Northing component - - name: seTThb - options: {} - schema: {} - type: LatLongField - title: Latitude and longitude - shortDescription: Position - hint: - This is an Latitude and Longitude component - - name: bhjloS - options: {} - schema: {} - type: NationalGridFieldNumberField - title: National grid field number - hint: - This is an National Grid Field Number component - - name: dfQQws - options: {} - schema: {} - type: OsGridRefField - title: Ordnance survey grid reference - hint: - This is an Ordnance survey Grid Reference component - - name: bClCvo - options: {} - schema: {} - type: MultilineTextField - title: Where you keep the unicorn - hint: - This is a multi-line text box. We use it when you expect the response to - be more than 1 sentence long - - title: How many members of staff will look after the unicorns? - path: '/how-many-members-of-staff-will-look-after-the-unicorns' - section: susaYr - next: - - path: '/declaration' - components: - - name: zhJMaM - options: - classes: govuk-!-width-one-quarter - type: NumberField - title: Number of staff - schema: {} - hint: - This is a number field. The answer must be a number. We can use custom validation - to set decimal places, minimum and maximum values - - title: Upload your insurance certificate - path: '/upload-your-insurance-certificate' - controller: FileUploadPageController - section: Regnsa - next: - - path: '/how-many-unicorns-do-you-expect-to-breed-each-year' - components: - - name: dLzALM - title: Documents - type: FileUploadField - hint: We can specify the format and number of uploaded files - options: - required: false - schema: - min: 1 - max: 3 - - title: Declaration - path: '/declaration' + - title: Summary + path: '/summary' + controller: SummaryPageController section: section - components: - - name: diLmal - title: Declaration - type: DeclarationField - content: 'Fill in this field' - options: - required: false - next: - - path: '/summary' -conditions: - - displayName: Address is different - name: IrVmYz - value: - name: Address is different - conditions: - - field: - name: dBfuID - type: YesNoField - display: 'Contact details: Send certificate to same address' - operator: is - value: - type: Value - value: 'false' - display: 'false' - - displayName: Address is not the same - name: oyGPwP - value: - name: Address is not the same - conditions: - - field: - name: dBfuID - type: YesNoField - display: 'Contact details: Send certificate to same address' - operator: is - value: - type: Value - value: 'false' - display: 'No' + components: [] +conditions: [] sections: - name: section title: Contact details @@ -307,4 +72,4 @@ lists: - text: Rainbow value: Rainbow outputEmail: defraforms@defra.gov.uk -startPage: '/whats-your-name' +startPage: '/how-many-unicorns-do-you-expect-to-breed-each-year' diff --git a/src/server/plugins/engine/components/CheckboxesField.ts b/src/server/plugins/engine/components/CheckboxesField.ts index c3c7579c6..a4068be42 100644 --- a/src/server/plugins/engine/components/CheckboxesField.ts +++ b/src/server/plugins/engine/components/CheckboxesField.ts @@ -25,25 +25,33 @@ export class CheckboxesField extends SelectionControlField { const { listType: type } = this const { options } = def - let formSchema = + const baseSchema = type === 'string' ? joi.array() : joi.array() const itemsSchema = joi[type]() .valid(...this.values) .label(this.label) - formSchema = formSchema + let formSchema = baseSchema .items(itemsSchema) .single() .label(this.label) .required() + .default([]) + + const stateSchema = baseSchema + .items(itemsSchema) + .label(this.label) + .required() + .default(null) + .allow(null) if (options.required === false) { formSchema = formSchema.optional() } - this.formSchema = formSchema.default([]) - this.stateSchema = formSchema.default(null).allow(null) + this.formSchema = formSchema + this.stateSchema = stateSchema this.options = options } From c74cf40c099c417e124d5b34ee2d33bb0f982a4a Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 28 Nov 2025 14:42:49 +0000 Subject: [PATCH 2/2] test that checkbox single/multiple submissions work --- .../forms/register-as-a-unicorn-breeder.yaml | 251 +++++++++++++++++- test/condition/checkboxes.test.js | 77 +++++- 2 files changed, 318 insertions(+), 10 deletions(-) diff --git a/src/server/forms/register-as-a-unicorn-breeder.yaml b/src/server/forms/register-as-a-unicorn-breeder.yaml index c10044ac7..a92b727ab 100644 --- a/src/server/forms/register-as-a-unicorn-breeder.yaml +++ b/src/server/forms/register-as-a-unicorn-breeder.yaml @@ -2,6 +2,131 @@ name: Register as a unicorn breeder declaration: "

All the answers you have provided are true to the best of your knowledge.

" pages: + - path: '/whats-your-name' + title: What's your name? + components: + - type: TextField + name: textField + title: Name + hint: + This is a single line text box. We use it to ask for information that's + likely to be 1 sentence + options: + required: true + schema: {} + next: + - path: '/whats-your-email-address' + section: section + - title: Summary + path: '/summary' + controller: './pages/summary.js' + components: [] + next: [] + - path: '/whats-your-email-address' + title: What's your email address? + components: + - name: MaTzaT + options: + required: true + type: EmailAddressField + title: Email adress + schema: {} + hint: This is an email address. An email address must contain an at sign @ + next: + - path: '/whats-your-phone-number' + section: section + - path: '/whats-your-phone-number' + title: What's your phone number? + components: + - name: BdKgCe + options: + required: true + type: TelephoneNumberField + title: Phone number + schema: {} + hint: + This is a telephone number. This field can only contain numbers and the + + symbol + next: + - path: '/whats-your-address' + section: section + - path: '/whats-your-address' + title: What's your address? + components: + - name: wZLWPy + options: + required: true + usePostcodeLookup: true + type: UkAddressField + title: What is your billing address + shortDescription: Billing address + schema: {} + hint: This is a UK billing address. Users must enter address line 1, town and a postcode + - name: dfTGhD + options: {} + schema: {} + type: MultilineTextField + title: Delivery notes + hint: + Enter some instructions for the delivery person + - name: drGHuj + options: + required: true + type: UkAddressField + title: What is your delivery address + schema: {} + hint: This is a UK delivery address. Users must enter address line 1, town and a postcode + next: + - path: '/do-you-want-your-unicorn-breeder-certificate-sent-to-this-address' + section: section + - path: '/do-you-want-your-unicorn-breeder-certificate-sent-to-this-address' + title: Do you want your unicorn breeder certificate sent to this address? + components: + - name: dBfuID + options: {} + type: YesNoField + title: Send certificate to same address + schema: {} + hint: + This is a yes or no question. We can branch to different questions based + on the answer + values: + type: listRef + next: + - path: '/what-address-do-you-want-the-certificate-sent-to' + condition: oyGPwP + - path: '/when-does-your-unicorn-insurance-policy-start' + condition: '' + section: section + - path: '/what-address-do-you-want-the-certificate-sent-to' + title: What address do you want the certificate sent to? + components: + - name: AegFro + options: {} + type: UkAddressField + title: Address to send certificate + schema: {} + hint: + This is a simple branch to an extra question - it's shown to users who select + 'no' when asked if this is the address where the certificate should be sent + next: + - path: '/when-does-your-unicorn-insurance-policy-start' + section: section + - title: When does your unicorn insurance policy start? + path: '/when-does-your-unicorn-insurance-policy-start' + section: Regnsa + next: + - path: '/upload-your-insurance-certificate' + components: + - name: mjAccr + options: {} + type: DatePartsField + title: Unicorn insurance policy start date + schema: {} + hint: + This is a date. We can add custom validation to the field based on your + requirements. For example, the date entered must be before or after a certain + date - title: How many unicorns do you expect to breed each year? path: '/how-many-unicorns-do-you-expect-to-breed-each-year' section: susaYr @@ -10,7 +135,7 @@ pages: components: - name: aitzzV options: {} - type: CheckboxesField + type: RadiosField list: IeFOkf title: Number of unicorns schema: {} @@ -21,7 +146,7 @@ pages: path: '/what-type-of-unicorns-will-you-breed' section: susaYr next: - - path: '/summary' + - path: '/where-will-you-keep-the-unicorns' components: - name: DyfjJC options: {} @@ -32,12 +157,122 @@ pages: hint: This is a check box. Users can select more than one option values: type: listRef - - title: Summary - path: '/summary' - controller: SummaryPageController + - title: Where will you keep the unicorns? + path: '/where-will-you-keep-the-unicorns' + section: susaYr + next: + - path: '/how-many-members-of-staff-will-look-after-the-unicorns' + components: + - name: dfGYuk + options: {} + schema: {} + type: EastingNorthingField + title: Easting and northing + shortDescription: Location + hint: + This is an Easting and Northing component + - name: seTThb + options: {} + schema: {} + type: LatLongField + title: Latitude and longitude + shortDescription: Position + hint: + This is an Latitude and Longitude component + - name: bhjloS + options: {} + schema: {} + type: NationalGridFieldNumberField + title: National grid field number + hint: + This is an National Grid Field Number component + - name: dfQQws + options: {} + schema: {} + type: OsGridRefField + title: Ordnance survey grid reference + hint: + This is an Ordnance survey Grid Reference component + - name: bClCvo + options: {} + schema: {} + type: MultilineTextField + title: Where you keep the unicorn + hint: + This is a multi-line text box. We use it when you expect the response to + be more than 1 sentence long + - title: How many members of staff will look after the unicorns? + path: '/how-many-members-of-staff-will-look-after-the-unicorns' + section: susaYr + next: + - path: '/declaration' + components: + - name: zhJMaM + options: + classes: govuk-!-width-one-quarter + type: NumberField + title: Number of staff + schema: {} + hint: + This is a number field. The answer must be a number. We can use custom validation + to set decimal places, minimum and maximum values + - title: Upload your insurance certificate + path: '/upload-your-insurance-certificate' + controller: FileUploadPageController + section: Regnsa + next: + - path: '/how-many-unicorns-do-you-expect-to-breed-each-year' + components: + - name: dLzALM + title: Documents + type: FileUploadField + hint: We can specify the format and number of uploaded files + options: + required: false + schema: + min: 1 + max: 3 + - title: Declaration + path: '/declaration' section: section - components: [] -conditions: [] + components: + - name: diLmal + title: Declaration + type: DeclarationField + content: 'Fill in this field' + options: + required: false + next: + - path: '/summary' +conditions: + - displayName: Address is different + name: IrVmYz + value: + name: Address is different + conditions: + - field: + name: dBfuID + type: YesNoField + display: 'Contact details: Send certificate to same address' + operator: is + value: + type: Value + value: 'false' + display: 'false' + - displayName: Address is not the same + name: oyGPwP + value: + name: Address is not the same + conditions: + - field: + name: dBfuID + type: YesNoField + display: 'Contact details: Send certificate to same address' + operator: is + value: + type: Value + value: 'false' + display: 'No' sections: - name: section title: Contact details @@ -72,4 +307,4 @@ lists: - text: Rainbow value: Rainbow outputEmail: defraforms@defra.gov.uk -startPage: '/how-many-unicorns-do-you-expect-to-breed-each-year' +startPage: '/whats-your-name' diff --git a/test/condition/checkboxes.test.js b/test/condition/checkboxes.test.js index 4e3f943f3..e61371bc1 100644 --- a/test/condition/checkboxes.test.js +++ b/test/condition/checkboxes.test.js @@ -5,8 +5,10 @@ import { StatusCodes } from 'http-status-codes' 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 { CacheService } from '~/src/server/services/cacheService.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}/checkboxes` const key = 'wqJmSf' @@ -16,6 +18,10 @@ jest.mock('~/src/server/plugins/engine/services/formsService.js') describe('Checkboxes based conditions', () => { /** @type {Server} */ let server + /** @type {string} */ + let csrfToken + /** @type {ReturnType} */ + let headers // Create server before each test beforeAll(async () => { @@ -25,6 +31,13 @@ describe('Checkboxes based conditions', () => { }) await server.initialize() + // Navigate to first page to establish a session and get CSRF + session cookies + const response = await server.inject({ + url: `${basePath}/first-page` + }) + + csrfToken = getCookie(response, 'crumb') + headers = getCookieHeader(response, ['session', 'crumb']) }) beforeEach(() => { @@ -79,12 +92,13 @@ describe('Checkboxes based conditions', () => { } }) - test('Testing POST /first-page with nothing checked redirects correctly', async () => { - const form = {} + test('Testing POST /first-page with nothing checked redirects correctly with single value', async () => { + const form = { crumb: csrfToken } const res = await server.inject({ url: `${basePath}/first-page`, method: 'POST', + headers, payload: form }) @@ -94,17 +108,76 @@ describe('Checkboxes based conditions', () => { test('Testing POST /first-page with "other" checked redirects correctly', async () => { const form = { + crumb: csrfToken, [key]: 'other' } + const setStateSpy = jest.spyOn(CacheService.prototype, 'setState') + const res = await server.inject({ url: `${basePath}/first-page`, method: 'POST', + headers, payload: form }) expect(res.statusCode).toBe(StatusCodes.SEE_OTHER) expect(res.headers.location).toBe(`${basePath}/third-page`) + + // Ensure the stored state contains an array for the checkbox key + expect(setStateSpy).toHaveBeenCalled() + const savedState = setStateSpy.mock.calls[0][1] + expect(Array.isArray(savedState[key])).toBe(true) + + setStateSpy.mockRestore() + }) + + test('Testing POST /first-page with "other" checked redirects correctly with multiple options', async () => { + const form = { + crumb: csrfToken, + [key]: ['other', 'shire'] + } + + const setStateSpy = jest.spyOn(CacheService.prototype, 'setState') + + const res = await server.inject({ + url: `${basePath}/first-page`, + method: 'POST', + headers, + payload: form + }) + + // Ensure the stored state contains an array for the checkbox key + expect(setStateSpy).toHaveBeenCalled() + const savedState = setStateSpy.mock.calls[0][1] + expect(Array.isArray(savedState[key])).toBe(true) + + expect(res.statusCode).toBe(StatusCodes.SEE_OTHER) + expect(res.headers.location).toBe(`${basePath}/third-page`) + }) + + test('DF-686 Test that when a radio question is changed to a checkbox question, the state validation forces the user to re-answer the question', async () => { + // Create a fresh session and extract cookies + const response = await server.inject({ url: `${basePath}/first-page` }) + const hdrs = getCookieHeader(response, ['session', 'crumb']) + + // Mock CacheService.getState to return a malformed value (string instead of array) + const malformedState = { [key]: 'other' } + const getStateSpy = jest + .spyOn(CacheService.prototype, 'getState') + .mockResolvedValueOnce(malformedState) + + // GET the summary - server should redirect back to first-page because value isn't an array + const res = await server.inject({ + url: `${basePath}/third-page`, + method: 'GET', + headers: hdrs + }) + + expect(res.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) + expect(res.headers.location).toBe(`${basePath}/first-page`) + + getStateSpy.mockRestore() }) })