diff --git a/package-lock.json b/package-lock.json index cb78b4cc9..121556462 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.597", + "@defra/forms-model": "^3.0.603", "@defra/hapi-tracing": "^1.29.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", @@ -48,6 +48,7 @@ "lodash": "^4.17.21", "marked": "^15.0.12", "nunjucks": "^3.2.4", + "obscenity": "^0.4.5", "outdent": "^0.8.0", "pino": "^9.14.0", "pino-pretty": "^13.1.2", @@ -2218,9 +2219,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.597", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.597.tgz", - "integrity": "sha512-msdGKxl4L3GxPeF4dAFyTOwGgNExAeQWh5SkuzESdzRa9IQhlDzNh6fniT7D02b6vjGbHoy8jMQ5E/gjuvyBCw==", + "version": "3.0.603", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.603.tgz", + "integrity": "sha512-EZBateAzf7GfPl1HmfYl3vPf384eLQEqFAXKmAtNgaxvLMT2qhkvwcluYg0Jq5Lyx0aRQD4CEPV4Xm42phTOzg==", "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", @@ -13488,6 +13489,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obscenity": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/obscenity/-/obscenity-0.4.5.tgz", + "integrity": "sha512-5NNZIolweauL3pDmSbScAa39LBm70ozdtffnUlPnM+MVNYx8KIchKVa7KY8aYOggWTF+O7Ih18UU5Y9kyIqBPQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", diff --git a/package.json b/package.json index 4df826a2a..ba08cd27e 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ }, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.597", + "@defra/forms-model": "^3.0.603", "@defra/hapi-tracing": "^1.29.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", @@ -108,6 +108,7 @@ "lodash": "^4.17.21", "marked": "^15.0.12", "nunjucks": "^3.2.4", + "obscenity": "^0.4.5", "outdent": "^0.8.0", "pino": "^9.14.0", "pino-pretty": "^13.1.2", diff --git a/src/server/plugins/engine/pageControllers/StatusPageController.ts b/src/server/plugins/engine/pageControllers/StatusPageController.ts index 08a57fac5..410973d0a 100644 --- a/src/server/plugins/engine/pageControllers/StatusPageController.ts +++ b/src/server/plugins/engine/pageControllers/StatusPageController.ts @@ -12,10 +12,12 @@ import { export class StatusPageController extends QuestionPageController { declare pageDef: PageStatus allowSaveAndExit = false + showReferenceNumber = false constructor(model: FormModel, pageDef: PageStatus) { super(model, pageDef) this.viewName = 'confirmation' + this.showReferenceNumber = model.def.options?.showReferenceNumber ?? false } getRelevantPath() { @@ -54,7 +56,9 @@ export class StatusPageController extends QuestionPageController { return h.view(viewName, { ...viewModel, submissionGuidance, - formName + formName, + showReferenceNumber: this.showReferenceNumber, + referenceNumber: context.referenceNumber }) } } diff --git a/src/server/plugins/engine/referenceNumbers.test.ts b/src/server/plugins/engine/referenceNumbers.test.ts index 609ff0988..17089a140 100644 --- a/src/server/plugins/engine/referenceNumbers.test.ts +++ b/src/server/plugins/engine/referenceNumbers.test.ts @@ -1,4 +1,7 @@ -import { generateUniqueReference } from '~/src/server/plugins/engine/referenceNumbers.js' +import { + convertToDecAlpha, + generateUniqueReference +} from '~/src/server/plugins/engine/referenceNumbers.js' describe('generateUniqueReference', () => { it('should generate a reference number with 3 segments when no prefix is provided', () => { @@ -30,4 +33,42 @@ describe('generateUniqueReference', () => { const referenceNumber2 = generateUniqueReference() expect(referenceNumber1).not.toBe(referenceNumber2) }) + + describe('convertToDecAlpha', () => { + it('should generate correct characters in string', () => { + const allValuesHexPairs = Array.from(Array(256).keys()) + expect(convertToDecAlpha(allValuesHexPairs)).toBe( + 'AAAAAAAAA' + + 'BBBBBBBBB' + + 'CCCCCCCC' + + 'DDDDDDDDD' + + 'EEEEEEEE' + + 'FFFFFFFFF' + + 'HHHHHHHH' + + 'JJJJJJJJJ' + + 'KKKKKKKK' + + 'LLLLLLLLL' + + 'MMMMMMMM' + + 'NNNNNNNNN' + + 'PPPPPPPP' + + 'RRRRRRRRR' + + 'SSSSSSSS' + + 'TTTTTTTTT' + + 'UUUUUUUUU' + + 'VVVVVVVV' + + 'WWWWWWWWW' + + 'XXXXXXXX' + + 'YYYYYYYYY' + + 'ZZZZZZZZ' + + '222222222' + + '33333333' + + '444444444' + + '55555555' + + '666666666' + + '77777777' + + '888888888' + + '99999999' + ) + }) + }) }) diff --git a/src/server/plugins/engine/referenceNumbers.ts b/src/server/plugins/engine/referenceNumbers.ts index 7f086c1b4..2b4fde674 100644 --- a/src/server/plugins/engine/referenceNumbers.ts +++ b/src/server/plugins/engine/referenceNumbers.ts @@ -1,18 +1,56 @@ import { randomBytes } from 'node:crypto' +import { + RegExpMatcher, + englishDataset, + englishRecommendedTransformers +} from 'obscenity' + +/** + * To prevent confusion to users reading the reference number, ambiguous letters and numbers are removed. + * @param strCodes - array of binary input values + */ +export function convertToDecAlpha(strCodes: number[]) { + const validChars = 'ABCDEFHJKLMNPRSTUVWXYZ23456789' + const strLen = validChars.length + const outArray = [] as string[] + + strCodes.forEach((code) => { + const pos = (code / 256) * strLen + outArray.push(validChars.charAt(pos)) + }) + + return outArray.join('') +} + /** * Generates a reference number in the format of `XXX-XXX-XXX`, or `PREFIX-XXX-XXX` if a prefix is provided. * Provides no guarantee on uniqueness. + * To prevent confusion to users reading the reference number, ambiguous letters and numbers are removed + * (see https://gunkies.org/wiki/DEC_alphabet ) */ export function generateUniqueReference(prefix?: string) { const segmentLength = 3 const segmentCount = prefix ? 2 : 3 prefix = prefix ? `${prefix}-` : '' - const segments = Array.from( - { length: segmentCount }, - () => randomBytes(segmentLength).toString('hex').slice(0, segmentLength) // 0-9a-f, might be good enough? - ) + const profanityMatcher = new RegExpMatcher({ + ...englishDataset.build(), + ...englishRecommendedTransformers + }) + + let referenceNumber + + do { + const segments = Array.from({ length: segmentCount }, () => + convertToDecAlpha([...randomBytes(segmentLength)]).slice( + 0, + segmentLength * 2 + ) + ) + + referenceNumber = `${prefix}${segments.join('-')}`.toUpperCase() + } while (profanityMatcher.hasMatch(referenceNumber.replaceAll('-', ''))) - return `${prefix}${segments.join('-')}`.toUpperCase() + return referenceNumber } diff --git a/src/server/plugins/engine/views/confirmation.html b/src/server/plugins/engine/views/confirmation.html index bcdc50720..d7df811d9 100644 --- a/src/server/plugins/engine/views/confirmation.html +++ b/src/server/plugins/engine/views/confirmation.html @@ -8,7 +8,8 @@