From d8f349b23c969de10674017e78468f913225576c Mon Sep 17 00:00:00 2001 From: James Reed <109068236+ReedSoftware@users.noreply.github.com> Date: Fri, 20 Dec 2024 13:02:43 +0000 Subject: [PATCH] feat: enable strict mode and other stricter tsconfig settings, fix issues (#7) --- eslint.config.mjs | 9 +- integration_tests/e2e/health.cy.ts | 2 + integration_tests/index.d.ts | 2 +- integration_tests/mockApis/wiremock.ts | 2 +- integration_tests/support/commands.ts | 4 +- integration_tests/tsconfig.json | 11 - jest.setup.ts | 100 ++ package-lock.json | 931 +++++++++++++++++- package.json | 36 +- server/@types/environment.d.ts | 14 + server/@types/express/index.d.ts | 15 + server/app.ts | 8 +- server/config.ts | 4 +- server/data/hmppsAuditClient.test.ts | 2 +- server/data/hmppsAuthClient.test.ts | 11 +- server/data/hmppsAuthClient.ts | 5 - server/data/restClient.ts | 19 +- server/data/tokenStore/inMemoryTokenStore.ts | 14 +- .../data/tokenStore/redisTokenStore.test.ts | 6 +- server/data/tokenStore/redisTokenStore.ts | 7 +- server/data/tokenStore/tokenStore.ts | 3 +- server/data/tokenVerification.ts | 4 + server/errorHandler.cy.ts | 25 + server/errorHandler.test.ts | 37 - server/errorHandler.ts | 20 +- server/interfaces/hmppsUser.ts | 6 +- server/jest.config.js | 24 + .../authorisationMiddleware.test.ts | 2 +- server/middleware/setUpAuthentication.ts | 6 +- server/middleware/setUpCurrentUser.ts | 4 +- server/middleware/setUpWebSecurity.ts | 16 +- server/middleware/setUpWebSession.ts | 2 +- server/routes/index.test.ts | 40 - server/routes/index.ts | 2 +- server/routes/journeys/jest.config.js | 25 + server/routes/testutils/appSetup.ts | 20 +- server/sanitisedError.ts | 2 +- server/services/auditService.test.ts | 7 +- server/utils/azureAppInsights.ts | 47 +- server/utils/utils.test.ts | 8 +- server/utils/utils.ts | 17 +- server/views/pages/errorServiceProblem.njk | 15 + server/views/pages/not-found.njk | 15 + tsconfig.json | 28 +- 44 files changed, 1390 insertions(+), 187 deletions(-) delete mode 100644 integration_tests/tsconfig.json create mode 100644 jest.setup.ts create mode 100644 server/@types/environment.d.ts create mode 100644 server/errorHandler.cy.ts delete mode 100644 server/errorHandler.test.ts create mode 100644 server/jest.config.js delete mode 100644 server/routes/index.test.ts create mode 100644 server/routes/journeys/jest.config.js create mode 100644 server/views/pages/errorServiceProblem.njk create mode 100755 server/views/pages/not-found.njk diff --git a/eslint.config.mjs b/eslint.config.mjs index 91d53fb..c082f42 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,3 +1,10 @@ import hmppsConfig from '@ministryofjustice/eslint-config-hmpps' -export default hmppsConfig() +export default [ + ...hmppsConfig(), + { + rules: { + 'dot-notation': 'off', + }, + }, +] diff --git a/integration_tests/e2e/health.cy.ts b/integration_tests/e2e/health.cy.ts index 40f5e6f..dfa1cdd 100644 --- a/integration_tests/e2e/health.cy.ts +++ b/integration_tests/e2e/health.cy.ts @@ -1,3 +1,5 @@ +import { expect } from 'chai' + context('Healthcheck', () => { context('All healthy', () => { beforeEach(() => { diff --git a/integration_tests/index.d.ts b/integration_tests/index.d.ts index ce64a17..ad6b275 100644 --- a/integration_tests/index.d.ts +++ b/integration_tests/index.d.ts @@ -4,6 +4,6 @@ declare namespace Cypress { * Custom command to signIn. Set failOnStatusCode to false if you expect and non 200 return code * @example cy.signIn({ failOnStatusCode: boolean }) */ - signIn(options?: { failOnStatusCode: boolean }): Chainable + signIn(options?: { failOnStatusCode: boolean }): Chainable } } diff --git a/integration_tests/mockApis/wiremock.ts b/integration_tests/mockApis/wiremock.ts index 5820007..092b659 100644 --- a/integration_tests/mockApis/wiremock.ts +++ b/integration_tests/mockApis/wiremock.ts @@ -5,7 +5,7 @@ const url = 'http://localhost:9091/__admin' const stubFor = (mapping: Record): SuperAgentRequest => superagent.post(`${url}/mappings`).send(mapping) -const getMatchingRequests = body => superagent.post(`${url}/requests/find`).send(body) +const getMatchingRequests = (body: string | object | undefined) => superagent.post(`${url}/requests/find`).send(body) const resetStubs = (): Promise> => Promise.all([superagent.delete(`${url}/mappings`), superagent.delete(`${url}/requests`)]) diff --git a/integration_tests/support/commands.ts b/integration_tests/support/commands.ts index e8d0a00..274514f 100644 --- a/integration_tests/support/commands.ts +++ b/integration_tests/support/commands.ts @@ -1,4 +1,6 @@ Cypress.Commands.add('signIn', (options = { failOnStatusCode: true }) => { cy.request('/') - return cy.task('getSignInUrl').then((url: string) => cy.visit(url, options)) + return cy.task('getSignInUrl').then((url: string) => { + cy.visit(url, options) + }) }) diff --git a/integration_tests/tsconfig.json b/integration_tests/tsconfig.json deleted file mode 100644 index b26470b..0000000 --- a/integration_tests/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "target": "es5", - "noEmit": true, - "lib": ["es5", "dom", "es2015.promise"], - "types": ["cypress", "express", "express-session"], - "esModuleInterop": true, - "skipLibCheck": true - }, - "include": ["**/*.ts"] -} diff --git a/jest.setup.ts b/jest.setup.ts new file mode 100644 index 0000000..2aff650 --- /dev/null +++ b/jest.setup.ts @@ -0,0 +1,100 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import '@testing-library/jest-dom' + +// @ts-expect-error setImmediate args have any types +globalThis.setImmediate = globalThis.setImmediate || ((fn, ...args) => global.setTimeout(fn, 0, ...args)) + +// Reset JSDOM + +type AddEventListenerParams = Parameters +const sideEffects = { + document: { + addEventListener: { + fn: document.addEventListener, + refs: [] as { + type: AddEventListenerParams['0'] + listener: AddEventListenerParams['1'] + options: AddEventListenerParams['2'] + }[], + }, + keys: Object.keys(document) as unknown as (keyof typeof document)[], + }, + window: { + addEventListener: { + fn: window.addEventListener, + refs: [] as { + type: AddEventListenerParams['0'] + listener: AddEventListenerParams['1'] + options: AddEventListenerParams['2'] + }[], + }, + keys: Object.keys(window) as unknown as (keyof typeof window)[], + }, +} + +// Lifecycle Hooks +// ----------------------------------------------------------------------------- +beforeAll(async () => { + // Spy addEventListener + ;['document', 'window'].forEach(_obj => { + const obj: 'document' | 'window' = _obj as 'document' | 'window' + const { fn } = sideEffects[obj].addEventListener + const { refs } = sideEffects[obj].addEventListener + + const addEventListenerSpy: Document['addEventListener'] = ( + type: string, + listener: EventListenerOrEventListenerObject, + options: boolean | EventListenerOptions, + ) => { + // Store listener reference so it can be removed during reset + refs.push({ type, listener, options }) + // Call original window.addEventListener + fn(type, listener, options) + } + + // Add to default key array to prevent removal during reset + sideEffects[obj].keys.push('addEventListener') + + // Replace addEventListener with mock + global[obj].addEventListener = addEventListenerSpy + }) +}) + +// Reset JSDOM. This attempts to remove side effects from tests, however it does +// not reset all changes made to globals like the window and document +// objects. Tests requiring a full JSDOM reset should be stored in separate +// files, which is only way to do a complete JSDOM reset with Jest. +beforeEach(async () => { + const rootElm = document.documentElement + + // Remove attributes on root element + ;[...rootElm.attributes].forEach(attr => rootElm.removeAttribute(attr.name)) + + // Remove elements (faster than setting innerHTML) + while (rootElm.firstChild) { + rootElm.removeChild(rootElm.firstChild) + } + + // Remove global listeners and keys + ;['document', 'window'].forEach(_obj => { + const obj: 'document' | 'window' = _obj as 'document' | 'window' + const { refs } = sideEffects[obj].addEventListener + + refs.forEach(ref => { + const { type, listener, options } = ref + global[obj].removeEventListener(type, listener, options) + }) + + // need a semicolon to start here because we have semis turned off and ASI won't work and eslint tries to blend the two together + ;(Object.keys(global[obj]) as unknown as (keyof (typeof global)[typeof obj])[]) + .filter(key => !sideEffects[obj].keys.includes(key) && !key.includes('coverage')) + .forEach(key => { + delete global[obj][key] + }) + }) + + // Restore base elements + rootElm.innerHTML = '' +}) + +export {} diff --git a/package-lock.json b/package-lock.json index 9a366d5..6b7498e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,11 +32,16 @@ "passport": "^0.7.0", "passport-oauth2": "^1.8.0", "redis": "^4.7.0", - "superagent": "^10.1.1" + "superagent": "^10.1.1", + "uuid": "^11.0.3" }, "devDependencies": { "@jgoz/esbuild-plugin-typecheck": "^4.0.2", "@ministryofjustice/eslint-config-hmpps": "^0.0.1-beta.1", + "@testing-library/cypress": "^10.0.2", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/user-event": "^14.5.2", "@tsconfig/node22": "^22.0.0", "@types/bunyan": "^1.8.11", "@types/bunyan-format": "^0.2.9", @@ -56,6 +61,7 @@ "@typescript-eslint/parser": "^8.18.0", "audit-ci": "^7.1.0", "aws-sdk-client-mock": "^4.1.0", + "chai": "^5.1.2", "chokidar": "^3.6.0", "concurrently": "^9.1.0", "cypress": "^13.16.1", @@ -68,6 +74,7 @@ "glob": "^11.0.0", "husky": "^9.1.7", "jest": "^29.7.0", + "jest-fixed-jsdom": "^0.0.9", "jest-html-reporter": "^3.10.2", "jest-junit": "^16.0.0", "jsonwebtoken": "^9.0.2", @@ -85,6 +92,13 @@ "npm": "^10" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.1.tgz", + "integrity": "sha512-12WGKBQzjUAI4ayyF4IAtfw2QR/IDoqk6jTddXDhtYTJF9ASmoE1zst7cVtP0aL/F1jUJL5r+JxKXKEgHNbEUQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -1335,6 +1349,19 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", + "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", @@ -3562,6 +3589,19 @@ "node": ">=16.0.0" } }, + "node_modules/@smithy/middleware-retry/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@smithy/middleware-serde": { "version": "3.0.11", "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-3.0.11.tgz", @@ -3948,6 +3988,159 @@ "node": ">=16.0.0" } }, + "node_modules/@testing-library/cypress": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@testing-library/cypress/-/cypress-10.0.2.tgz", + "integrity": "sha512-dKv95Bre5fDmNb9tOIuWedhGUryxGu1GWYWtXDqUsDPcr9Ekld0fiTb+pcBvSsFpYXAZSpmyEjhoXzLbhh06yQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.14.6", + "@testing-library/dom": "^10.1.0" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "cypress": "^12.0.0 || ^13.0.0" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz", + "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.21", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/jest-dom/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.5.2", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", + "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 10" + } + }, "node_modules/@tsconfig/node22": { "version": "22.0.0", "resolved": "https://registry.npmjs.org/@tsconfig/node22/-/node22-22.0.0.tgz", @@ -3955,6 +4148,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -4165,6 +4365,19 @@ "pretty-format": "^29.0.0" } }, + "node_modules/@types/jsdom": { + "version": "20.0.1", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", + "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4349,6 +4562,14 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -4576,6 +4797,15 @@ "integrity": "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==", "license": "MIT" }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "dev": true, + "license": "BSD-3-Clause", + "peer": true + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -4610,6 +4840,18 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-globals": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", + "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "acorn": "^8.1.0", + "acorn-walk": "^8.0.2" + } + }, "node_modules/acorn-import-attributes": { "version": "1.9.5", "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", @@ -4629,6 +4871,20 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", @@ -4834,6 +5090,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", @@ -4996,6 +5262,16 @@ "node": ">=0.8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -5636,6 +5912,23 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/chai": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz", + "integrity": "sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5686,6 +5979,16 @@ "node": "*" } }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/check-more-types": { "version": "2.24.0", "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", @@ -6185,6 +6488,43 @@ "http-errors": "^2.0.0" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "cssom": "~0.3.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/cypress": { "version": "13.16.1", "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.16.1.tgz", @@ -6281,6 +6621,22 @@ "node": ">=0.10" } }, + "node_modules/data-urls": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", + "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", @@ -6383,6 +6739,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dedent": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", @@ -6398,6 +6762,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -6491,6 +6865,16 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -6586,6 +6970,28 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/dtrace-provider": { "version": "0.8.8", "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.8.8.tgz", @@ -6740,6 +7146,20 @@ "node": ">=8.6" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -7043,7 +7463,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint": { + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/eslint": { "version": "9.16.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.16.0.tgz", "integrity": "sha512-whp8mSQI4C8VXd+fLgSM0lh3UlmcFtVwUQjyKCFfsp+2ItAIYhlq/hqGahGqHE6cv9unM41VlqKk2VtKYR2TaA==", @@ -8616,6 +9059,20 @@ "node": ">=8" } }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -9225,6 +9682,14 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/is-regex": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.0.tgz", @@ -9873,6 +10338,35 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-environment-jsdom": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz", + "integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/jsdom": "^20.0.0", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0", + "jsdom": "^20.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jest-environment-node": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", @@ -9891,6 +10385,19 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-fixed-jsdom": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/jest-fixed-jsdom/-/jest-fixed-jsdom-0.0.9.tgz", + "integrity": "sha512-KPfqh2+sn5q2B+7LZktwDcwhCpOpUSue8a1I+BcixWLOQoEVyAjAGfH+IYZGoxZsziNojoHGRTC8xRbB1wDD4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "jest-environment-jsdom": ">=28.0.0" + } + }, "node_modules/jest-get-type": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", @@ -10391,6 +10898,126 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", + "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "abab": "^2.0.6", + "acorn": "^8.8.1", + "acorn-globals": "^7.0.0", + "cssom": "^0.5.0", + "cssstyle": "^2.3.0", + "data-urls": "^3.0.2", + "decimal.js": "^10.4.2", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.2", + "parse5": "^7.1.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0", + "ws": "^8.11.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/jsdom/node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/jsdom/node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsdom/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/jsesc": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", @@ -11322,6 +11949,13 @@ "node": ">=8" } }, + "node_modules/loupe": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", + "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -11332,6 +11966,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -11507,6 +12151,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -12049,6 +12703,14 @@ "node": ">= 6" } }, + "node_modules/nwsapi": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.16.tgz", + "integrity": "sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/oauth": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.0.tgz", @@ -12316,6 +12978,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", + "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "entities": "^4.5.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -12450,6 +13126,16 @@ "node": ">=8" } }, + "node_modules/pathval": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/pause": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", @@ -12770,6 +13456,20 @@ "dev": true, "license": "MIT" }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/pump": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", @@ -12823,6 +13523,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -12933,6 +13641,20 @@ "node": ">=6" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/redis": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.0.tgz", @@ -12972,6 +13694,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "dev": true, + "license": "MIT" + }, "node_modules/regexp.prototype.flags": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz", @@ -13025,6 +13754,14 @@ "node": ">=8.6.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -13768,6 +14505,20 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", @@ -14331,6 +15082,19 @@ "node": ">=6" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -14458,6 +15222,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/sync-child-process": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", @@ -14670,6 +15442,20 @@ "node": ">=16" } }, + "node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -15053,6 +15839,18 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -15070,16 +15868,16 @@ } }, "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz", + "integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/v8-to-istanbul": { @@ -15129,6 +15927,20 @@ "extsprintf": "^1.2.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -15139,6 +15951,71 @@ "makeerror": "1.0.12" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -15384,6 +16261,29 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", @@ -15391,6 +16291,17 @@ "dev": true, "license": "MIT" }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=12" + } + }, "node_modules/xmlbuilder": { "version": "15.0.0", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.0.0.tgz", @@ -15401,6 +16312,14 @@ "node": ">=8.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/xtend": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.1.2.tgz", diff --git a/package.json b/package.json index 7fb3245..d9c7b54 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "start-feature:dev": "concurrently -k -p \"[{name}]\" -n \"ESBuild,Node\" -c \"yellow.bold,cyan.bold\" \"node esbuild/esbuild.config.js --build --watch \" \"node esbuild/esbuild.config.js --dev-test-server\"", "lint": "eslint . --cache --max-warnings 0", "lint-fix": "eslint . --cache --max-warnings 0 --fix", - "typecheck": "tsc && tsc -p integration_tests", + "typecheck": "tsc", "test": "jest", "test:ci": "jest --runInBand", "security_audit": "npx audit-ci --config audit-ci.json", @@ -28,21 +28,15 @@ "npm": "^10" }, "jest": { - "transform": { - "^.+\\.tsx?$": [ - "ts-jest", - { - "isolatedModules": true - } - ] - }, + "collectCoverage": true, "collectCoverageFrom": [ "server/**/*.{ts,js,jsx,mjs}" ], - "testMatch": [ - "/(server|job)/**/?(*.)(cy|test).{ts,js,jsx,mjs}" + "coverageDirectory": "test_results/jest/", + "coverageReporters": [ + "json", + "lcov" ], - "testEnvironment": "node", "reporters": [ "default", [ @@ -58,12 +52,9 @@ } ] ], - "moduleFileExtensions": [ - "web.js", - "js", - "json", - "node", - "ts" + "projects": [ + "./server", + "./server/routes/journeys" ] }, "lint-staged": { @@ -99,11 +90,16 @@ "passport": "^0.7.0", "passport-oauth2": "^1.8.0", "redis": "^4.7.0", - "superagent": "^10.1.1" + "superagent": "^10.1.1", + "uuid": "^11.0.3" }, "devDependencies": { "@jgoz/esbuild-plugin-typecheck": "^4.0.2", "@ministryofjustice/eslint-config-hmpps": "^0.0.1-beta.1", + "@testing-library/cypress": "^10.0.2", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/user-event": "^14.5.2", "@tsconfig/node22": "^22.0.0", "@types/bunyan": "^1.8.11", "@types/bunyan-format": "^0.2.9", @@ -123,6 +119,7 @@ "@typescript-eslint/parser": "^8.18.0", "audit-ci": "^7.1.0", "aws-sdk-client-mock": "^4.1.0", + "chai": "^5.1.2", "chokidar": "^3.6.0", "concurrently": "^9.1.0", "cypress": "^13.16.1", @@ -135,6 +132,7 @@ "glob": "^11.0.0", "husky": "^9.1.7", "jest": "^29.7.0", + "jest-fixed-jsdom": "^0.0.9", "jest-html-reporter": "^3.10.2", "jest-junit": "^16.0.0", "jsonwebtoken": "^9.0.2", diff --git a/server/@types/environment.d.ts b/server/@types/environment.d.ts new file mode 100644 index 0000000..97b434d --- /dev/null +++ b/server/@types/environment.d.ts @@ -0,0 +1,14 @@ +declare global { + namespace NodeJS { + interface ProcessEnv { + NODE_ENV: string + PORT?: string + REDIS_PORT: string + REDIS_AUTH_TOKEN: string + APPLICATIONINSIGHTS_CONNECTION_STRING: string + NO_HTTPS?: string + } + } +} + +export {} diff --git a/server/@types/express/index.d.ts b/server/@types/express/index.d.ts index ebd11d0..a69481f 100644 --- a/server/@types/express/index.d.ts +++ b/server/@types/express/index.d.ts @@ -1,3 +1,4 @@ +import { CsrfTokenGenerator } from 'csrf-sync' import { HmppsUser } from '../../interfaces/hmppsUser' export declare module 'express-session' { @@ -22,8 +23,22 @@ export declare global { logout(done: (err: unknown) => void): void } + interface Response { + notFound(): void + } + interface Locals { + cspNonce: string + csrfToken: ReturnType user: HmppsUser + digitalPrisonServicesUrl: string + breadcrumbs: Breadcrumbs + prisoner?: PrisonerSummary + buildNumber?: string + asset_path: string + applicationName: string + environmentName: string + environmentNameColour: string } } } diff --git a/server/app.ts b/server/app.ts index eddaa67..12a8673 100755 --- a/server/app.ts +++ b/server/app.ts @@ -1,7 +1,5 @@ import express from 'express' -import createError from 'http-errors' - import nunjucksSetup from './utils/nunjucksSetup' import errorHandler from './errorHandler' import { appInsightsMiddleware } from './utils/azureAppInsights' @@ -37,10 +35,14 @@ export default function createApp(services: Services): express.Application { app.use(authorisationMiddleware()) app.use(setUpCsrf()) app.use(setUpCurrentUser()) + app.use((_req, res, next) => { + res.notFound = () => res.status(404).render('pages/not-found') + next() + }) app.use(routes(services)) - app.use((req, res, next) => next(createError(404, 'Not found'))) + app.use((_req, res) => res.notFound()) app.use(errorHandler(process.env.NODE_ENV === 'production')) return app diff --git a/server/config.ts b/server/config.ts index 9d652c4..40cc869 100755 --- a/server/config.ts +++ b/server/config.ts @@ -41,9 +41,9 @@ const auditConfig = () => { queueUrl: get( 'AUDIT_SQS_QUEUE_URL', 'http://localhost:4566/000000000000/mainQueue', - auditEnabled && requiredInProduction, + auditEnabled ? requiredInProduction : undefined, ), - serviceName: get('AUDIT_SERVICE_NAME', 'UNASSIGNED', auditEnabled && requiredInProduction), + serviceName: get('AUDIT_SERVICE_NAME', 'UNASSIGNED', auditEnabled ? requiredInProduction : undefined), region: get('AUDIT_SQS_REGION', 'eu-west-2'), } } diff --git a/server/data/hmppsAuditClient.test.ts b/server/data/hmppsAuditClient.test.ts index dd5c50c..b5e98e9 100644 --- a/server/data/hmppsAuditClient.test.ts +++ b/server/data/hmppsAuditClient.test.ts @@ -53,7 +53,7 @@ describe('hmppsAuditClient', () => { expect(actualMessageInput.QueueUrl).toEqual('http://localhost:4566/000000000000/mainQueue') - const actualMessageBody = JSON.parse(actualMessageInput.MessageBody) + const actualMessageBody = JSON.parse(actualMessageInput.MessageBody!) expect(actualMessageBody).toEqual(expectedSqsMessageBody) const eventTime = Date.parse(actualMessageBody.when) diff --git a/server/data/hmppsAuthClient.test.ts b/server/data/hmppsAuthClient.test.ts index cd02976..8dc061f 100644 --- a/server/data/hmppsAuthClient.test.ts +++ b/server/data/hmppsAuthClient.test.ts @@ -3,10 +3,19 @@ import nock from 'nock' import config from '../config' import HmppsAuthClient from './hmppsAuthClient' import TokenStore from './tokenStore/redisTokenStore' +import { RedisClient } from './redisClient' jest.mock('./tokenStore/redisTokenStore') -const tokenStore = new TokenStore(null) as jest.Mocked +const redisClient = { + get: jest.fn(), + set: jest.fn(), + on: jest.fn(), + connect: jest.fn(), + isOpen: true, +} as unknown as jest.Mocked + +const tokenStore = new TokenStore(redisClient) as jest.Mocked const username = 'Bob' const token = { access_token: 'token-1', expires_in: 300 } diff --git a/server/data/hmppsAuthClient.ts b/server/data/hmppsAuthClient.ts index cd8df71..6b1898b 100644 --- a/server/data/hmppsAuthClient.ts +++ b/server/data/hmppsAuthClient.ts @@ -6,7 +6,6 @@ import type TokenStore from './tokenStore/tokenStore' import logger from '../../logger' import config from '../config' import generateOauthClientToken from '../authentication/clientCredentials' -import RestClient from './restClient' const timeoutSpec = config.apis.hmppsAuth.timeout const hmppsAuthUrl = config.apis.hmppsAuth.url @@ -35,10 +34,6 @@ function getSystemClientTokenFromHmppsAuth(username?: string): Promise { const key = username || '%ANONYMOUS%' diff --git a/server/data/restClient.ts b/server/data/restClient.ts index d25e75c..039b227 100644 --- a/server/data/restClient.ts +++ b/server/data/restClient.ts @@ -59,7 +59,7 @@ export default class RestClient { .get(`${this.apiUrl()}${path}`) .query(query) .agent(this.agent) - .retry(2, (err, res) => { + .retry(2, err => { if (err) logger.info(`Retry handler found ${this.name} API error with ${err.code} ${err.message}`) return undefined // retry handler only for logging retries, not to influence retry logic }) @@ -70,6 +70,9 @@ export default class RestClient { return raw ? result : result.body } catch (error) { + if (!(error instanceof Error)) { + throw error + } const sanitisedError = sanitiseError(error) logger.warn({ ...sanitisedError }, `Error calling ${this.name}, path: '${path}', verb: 'GET'`) throw sanitisedError @@ -86,7 +89,7 @@ export default class RestClient { .query(query) .send(data) .agent(this.agent) - .retry(2, (err, res) => { + .retry(2, err => { if (retry === false) { return false } @@ -100,6 +103,9 @@ export default class RestClient { return raw ? result : result.body } catch (error) { + if (!(error instanceof Error)) { + throw error + } const sanitisedError = sanitiseError(error) logger.warn({ ...sanitisedError }, `Error calling ${this.name}, path: '${path}', verb: '${method.toUpperCase()}'`) throw sanitisedError @@ -131,7 +137,7 @@ export default class RestClient { .delete(`${this.apiUrl()}${path}`) .query(query) .agent(this.agent) - .retry(2, (err, res) => { + .retry(2, err => { if (err) logger.info(`Retry handler found ${this.name} API error with ${err.code} ${err.message}`) return undefined // retry handler only for logging retries, not to influence retry logic }) @@ -142,20 +148,23 @@ export default class RestClient { return raw ? result : result.body } catch (error) { + if (!(error instanceof Error)) { + throw error + } const sanitisedError = sanitiseError(error) logger.warn({ ...sanitisedError }, `Error calling ${this.name}, path: '${path}', verb: 'DELETE'`) throw sanitisedError } } - async stream({ path = null, headers = {} }: StreamRequest = {}): Promise { + async stream({ path = undefined, headers = {} }: StreamRequest = {}): Promise { logger.info(`${this.name} streaming: ${path}`) return new Promise((resolve, reject) => { superagent .get(`${this.apiUrl()}${path}`) .agent(this.agent) .auth(this.token, { type: 'bearer' }) - .retry(2, (err, res) => { + .retry(2, err => { if (err) logger.info(`Retry handler found ${this.name} API error with ${err.code} ${err.message}`) return undefined // retry handler only for logging retries, not to influence retry logic }) diff --git a/server/data/tokenStore/inMemoryTokenStore.ts b/server/data/tokenStore/inMemoryTokenStore.ts index 2b6a1b4..681b6f2 100644 --- a/server/data/tokenStore/inMemoryTokenStore.ts +++ b/server/data/tokenStore/inMemoryTokenStore.ts @@ -8,10 +8,18 @@ export default class InMemoryTokenStore implements TokenStore { return Promise.resolve() } - public async getToken(key: string): Promise { - if (!this.map.has(key) || this.map.get(key).expiry.getTime() < Date.now()) { + public async getToken(key: string): Promise { + const tokenEntry = this.map.get(key) + if (!tokenEntry || tokenEntry.expiry.getTime() < Date.now()) { return Promise.resolve(null) } - return Promise.resolve(this.map.get(key).token) + return Promise.resolve(tokenEntry.token) + } + + public async delToken(key: string): Promise { + if (this.map.has(key)) { + this.map.delete(key) + } + return Promise.resolve() } } diff --git a/server/data/tokenStore/redisTokenStore.test.ts b/server/data/tokenStore/redisTokenStore.test.ts index ef98d6c..5fb8df6 100644 --- a/server/data/tokenStore/redisTokenStore.test.ts +++ b/server/data/tokenStore/redisTokenStore.test.ts @@ -6,7 +6,7 @@ const redisClient = { set: jest.fn(), on: jest.fn(), connect: jest.fn(), - isOpen: true, + isOpen: false, } as unknown as jest.Mocked describe('tokenStore', () => { @@ -30,8 +30,6 @@ describe('tokenStore', () => { }) it('Connects when no connection calling getToken', async () => { - ;(redisClient as unknown as Record).isOpen = false - await tokenStore.getToken('user-1') expect(redisClient.connect).toHaveBeenCalledWith() @@ -46,8 +44,6 @@ describe('tokenStore', () => { }) it('Connects when no connection calling set token', async () => { - ;(redisClient as unknown as Record).isOpen = false - await tokenStore.setToken('user-1', 'token-1', 10) expect(redisClient.connect).toHaveBeenCalledWith() diff --git a/server/data/tokenStore/redisTokenStore.ts b/server/data/tokenStore/redisTokenStore.ts index 52032f7..e7b9fc7 100644 --- a/server/data/tokenStore/redisTokenStore.ts +++ b/server/data/tokenStore/redisTokenStore.ts @@ -23,8 +23,13 @@ export default class RedisTokenStore implements TokenStore { await this.client.set(`${this.prefix}${key}`, token, { EX: durationSeconds }) } - public async getToken(key: string): Promise { + public async getToken(key: string): Promise { await this.ensureConnected() return this.client.get(`${this.prefix}${key}`) } + + public async delToken(key: string): Promise { + await this.ensureConnected() + await this.client.del(`${this.prefix}${key}`) + } } diff --git a/server/data/tokenStore/tokenStore.ts b/server/data/tokenStore/tokenStore.ts index 60f3a28..efdfa2c 100644 --- a/server/data/tokenStore/tokenStore.ts +++ b/server/data/tokenStore/tokenStore.ts @@ -1,4 +1,5 @@ export default interface TokenStore { setToken(key: string, token: string, durationSeconds: number): Promise - getToken(key: string): Promise + getToken(key: string): Promise + delToken(key: string): Promise } diff --git a/server/data/tokenVerification.ts b/server/data/tokenVerification.ts index dafa0fd..1c7a93a 100644 --- a/server/data/tokenVerification.ts +++ b/server/data/tokenVerification.ts @@ -29,6 +29,10 @@ const tokenVerifier: TokenVerifier = async request => { return true } + if (!user) { + return false + } + logger.debug(`token request for user "${user.username}'`) const result = await getApiClientToken(user.token) diff --git a/server/errorHandler.cy.ts b/server/errorHandler.cy.ts new file mode 100644 index 0000000..9b58a28 --- /dev/null +++ b/server/errorHandler.cy.ts @@ -0,0 +1,25 @@ +import { v4 as uuidV4 } from 'uuid' + +context('test errorHandler', () => { + const uuid = uuidV4() + + beforeEach(() => { + cy.task('reset') + cy.task('stubSignIn') + cy.task('stubComponents') + }) + + it('should go to the custom error page when an API 500s', () => { + cy.task('stubGetPrisoner500') + cy.task('stubGetPrisonerImage') + cy.signIn() + cy.visit(`${uuid}/prisoners/A1111AA/referral/start`, { failOnStatusCode: false }) + cy.findByText(/sorry, there is a problem with the service/i).should('be.visible') + }) + + it('should say page not found when 404', () => { + cy.signIn() + cy.visit(`${uuid}/foobar`, { failOnStatusCode: false }) + cy.findByRole('heading', { name: /Page not found/i }).should('be.visible') + }) +}) diff --git a/server/errorHandler.test.ts b/server/errorHandler.test.ts deleted file mode 100644 index fef68e4..0000000 --- a/server/errorHandler.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Express } from 'express' -import request from 'supertest' -import { appWithAllRoutes } from './routes/testutils/appSetup' - -let app: Express - -beforeEach(() => { - app = appWithAllRoutes({}) -}) - -afterEach(() => { - jest.resetAllMocks() -}) - -describe('GET 404', () => { - it('should render content with stack in dev mode', () => { - return request(app) - .get('/unknown') - .expect(404) - .expect('Content-Type', /html/) - .expect(res => { - expect(res.text).toContain('NotFoundError: Not Found') - expect(res.text).not.toContain('Something went wrong. The error has been logged. Please try again') - }) - }) - - it('should render content without stack in production mode', () => { - return request(appWithAllRoutes({ production: true })) - .get('/unknown') - .expect(404) - .expect('Content-Type', /html/) - .expect(res => { - expect(res.text).toContain('Something went wrong. The error has been logged. Please try again') - expect(res.text).not.toContain('NotFoundError: Not Found') - }) - }) -}) diff --git a/server/errorHandler.ts b/server/errorHandler.ts index 2fa4fbd..aa9c544 100644 --- a/server/errorHandler.ts +++ b/server/errorHandler.ts @@ -1,21 +1,31 @@ -import type { Request, Response, NextFunction } from 'express' +import type { NextFunction, Request, Response } from 'express' import type { HTTPError } from 'superagent' import logger from '../logger' export default function createErrorHandler(production: boolean) { - return (error: HTTPError, req: Request, res: Response, next: NextFunction): void => { + return (error: HTTPError, req: Request, res: Response, _next: NextFunction): void => { logger.error(`Error handling request for '${req.originalUrl}', user '${res.locals.user?.username}'`, error) + if (error.status === 404) { + return res.notFound() + } + if (error.status === 401 || error.status === 403) { logger.info('Logging user out') return res.redirect('/sign-out') } - res.locals.message = production + if (production && ((error.status >= 500 && error.status < 600) || error.name?.includes('Error'))) { + return res.render('pages/errorServiceProblem', { + showBreadcrumbs: true, + }) + } + + res.locals['message'] = production ? 'Something went wrong. The error has been logged. Please try again' : error.message - res.locals.status = error.status - res.locals.stack = production ? null : error.stack + res.locals['status'] = error.status + res.locals['stack'] = production ? null : error.stack res.status(error.status || 500) diff --git a/server/interfaces/hmppsUser.ts b/server/interfaces/hmppsUser.ts index e0e0c59..42f63d4 100644 --- a/server/interfaces/hmppsUser.ts +++ b/server/interfaces/hmppsUser.ts @@ -6,8 +6,8 @@ export type AuthSource = 'nomis' | 'delius' | 'external' | 'azuread' export interface BaseUser { authSource: AuthSource username: string - userId: string - name: string + userId: string | undefined + name: string | undefined displayName: string userRoles: string[] token: string @@ -23,7 +23,7 @@ export interface BaseUser { */ export interface PrisonUser extends BaseUser { authSource: 'nomis' - staffId: number + staffId: number | undefined } /** diff --git a/server/jest.config.js b/server/jest.config.js new file mode 100644 index 0000000..16e9115 --- /dev/null +++ b/server/jest.config.js @@ -0,0 +1,24 @@ +module.exports = { + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + isolatedModules: true, + }, + ], + }, + coveragePathIgnorePatterns: [ + '.*.test.ts', + 'node_modules', + 'server/@types', + '.*jest.config.js', + 'server/app.ts', + 'server/index.ts', + '.*.cy.ts', + ], + testMatch: ['/(server|job)/**/?(*.)(test).{ts,js,jsx,mjs}'], + testPathIgnorePatterns: ['/server/routes/journeys', '/server/routes/csip-records', 'node_modules'], + testEnvironment: 'node', + rootDir: '../', + moduleFileExtensions: ['web.js', 'js', 'json', 'node', 'ts'], +} diff --git a/server/middleware/authorisationMiddleware.test.ts b/server/middleware/authorisationMiddleware.test.ts index fbf7a06..7082d0c 100644 --- a/server/middleware/authorisationMiddleware.test.ts +++ b/server/middleware/authorisationMiddleware.test.ts @@ -17,7 +17,7 @@ function createToken(authorities: string[]) { } describe('authorisationMiddleware', () => { - let req: Request + const req: Request = {} as jest.Mocked const next = jest.fn() function createResWithToken({ authorities }: { authorities: string[] }): Response { diff --git a/server/middleware/setUpAuthentication.ts b/server/middleware/setUpAuthentication.ts index b3efa7a..62d242f 100644 --- a/server/middleware/setUpAuthentication.ts +++ b/server/middleware/setUpAuthentication.ts @@ -28,7 +28,7 @@ passport.use( state: true, customHeaders: { Authorization: generateOauthClientToken() }, }, - (token, refreshToken, params, profile, done) => { + (token, _refreshToken, params, _profile, done) => { return done(null, { token, username: params.user_name, authSource: params.auth_source }) }, ), @@ -41,7 +41,7 @@ export default function setupAuthentication() { router.use(passport.session()) router.use(flash()) - router.get('/autherror', (req, res) => { + router.get('/autherror', (_req, res) => { res.status(401) return res.render('autherror') }) @@ -68,7 +68,7 @@ export default function setupAuthentication() { } else res.redirect(authSignOutUrl) }) - router.use('/account-details', (req, res) => { + router.use('/account-details', (_req, res) => { res.redirect(`${authUrl}/account-details?${authParameters}`) }) diff --git a/server/middleware/setUpCurrentUser.ts b/server/middleware/setUpCurrentUser.ts index 00cc45a..a6bd457 100644 --- a/server/middleware/setUpCurrentUser.ts +++ b/server/middleware/setUpCurrentUser.ts @@ -6,7 +6,7 @@ import logger from '../../logger' export default function setUpCurrentUser() { const router = express.Router() - router.use((req, res, next) => { + router.use((_req, res, next) => { try { const { name, @@ -27,7 +27,7 @@ export default function setUpCurrentUser() { } if (res.locals.user.authSource === 'nomis') { - res.locals.user.staffId = parseInt(userId, 10) || undefined + res.locals.user.staffId = userId !== undefined ? parseInt(userId, 10) : undefined } next() diff --git a/server/middleware/setUpWebSecurity.ts b/server/middleware/setUpWebSecurity.ts index 0a25aae..9c24a30 100644 --- a/server/middleware/setUpWebSecurity.ts +++ b/server/middleware/setUpWebSecurity.ts @@ -1,6 +1,7 @@ import crypto from 'crypto' import express, { Router, Request, Response, NextFunction } from 'express' import helmet from 'helmet' +import { IncomingMessage, ServerResponse } from 'http' import config from '../config' export default function setUpWebSecurity(): Router { @@ -15,6 +16,7 @@ export default function setUpWebSecurity(): Router { }) router.use( helmet({ + referrerPolicy: { policy: 'same-origin' }, contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], @@ -24,8 +26,18 @@ export default function setUpWebSecurity(): Router { // // This ensures only scripts we trust are loaded, and not anything injected into the // page by an attacker. - scriptSrc: ["'self'", (_req: Request, res: Response) => `'nonce-${res.locals.cspNonce}'`], - styleSrc: ["'self'", (_req: Request, res: Response) => `'nonce-${res.locals.cspNonce}'`], + scriptSrc: [ + "'self' https://browser.sentry-cdn.com https://js.sentry-cdn.com", + (_req: IncomingMessage, res: ServerResponse) => `'nonce-${(res as Response).locals.cspNonce}'`, + ], + connectSrc: [ + "'self' https://*.sentry.io https://northeurope-0.in.applicationinsights.azure.com https://js.monitor.azure.com", + ], + workerSrc: ["'self' blob:"], + styleSrc: [ + "'self'", + (_req: IncomingMessage, res: ServerResponse) => `'nonce-${(res as Response).locals.cspNonce}'`, + ], fontSrc: ["'self'"], formAction: [`'self' ${config.apis.hmppsAuth.externalUrl}`], }, diff --git a/server/middleware/setUpWebSession.ts b/server/middleware/setUpWebSession.ts index d4fd880..a528300 100644 --- a/server/middleware/setUpWebSession.ts +++ b/server/middleware/setUpWebSession.ts @@ -31,7 +31,7 @@ export default function setUpWebSession(): Router { // Update a value in the cookie so that the set-cookie will be sent. // Only changes every minute so that it's not sent with every request. - router.use((req, res, next) => { + router.use((req, _res, next) => { req.session.nowInMinutes = Math.floor(Date.now() / 60e3) next() }) diff --git a/server/routes/index.test.ts b/server/routes/index.test.ts deleted file mode 100644 index d8cb5a8..0000000 --- a/server/routes/index.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { Express } from 'express' -import request from 'supertest' -import { appWithAllRoutes, user } from './testutils/appSetup' -import AuditService, { Page } from '../services/auditService' - -jest.mock('../services/auditService') - -const auditService = new AuditService(null) as jest.Mocked - -let app: Express - -beforeEach(() => { - app = appWithAllRoutes({ - services: { - auditService, - }, - userSupplier: () => user, - }) -}) - -afterEach(() => { - jest.resetAllMocks() -}) - -describe('GET /', () => { - it('should render index page', () => { - auditService.logPageView.mockResolvedValue(null) - - return request(app) - .get('/') - .expect('Content-Type', /html/) - .expect(res => { - expect(res.text).toContain('This site is under construction...') - expect(auditService.logPageView).toHaveBeenCalledWith(Page.EXAMPLE_PAGE, { - who: user.username, - correlationId: expect.any(String), - }) - }) - }) -}) diff --git a/server/routes/index.ts b/server/routes/index.ts index 2fa71df..d5e2718 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -8,7 +8,7 @@ export default function routes({ auditService }: Services): Router { const router = Router() const get = (path: string | string[], handler: RequestHandler) => router.get(path, asyncMiddleware(handler)) - get('/', async (req, res, next) => { + get('/', async (req, res) => { await auditService.logPageView(Page.EXAMPLE_PAGE, { who: res.locals.user.username, correlationId: req.id }) res.render('pages/index') diff --git a/server/routes/journeys/jest.config.js b/server/routes/journeys/jest.config.js new file mode 100644 index 0000000..1a039bb --- /dev/null +++ b/server/routes/journeys/jest.config.js @@ -0,0 +1,25 @@ +module.exports = { + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + isolatedModules: true, + }, + ], + }, + rootDir: '../../../', + setupFilesAfterEnv: ['jest.setup.ts'], + coveragePathIgnorePatterns: [ + '.*.test.ts', + 'node_modules', + 'server/@types', + '.*jest.config.js', + 'server/app.ts', + 'server/index.ts', + '.*.cy.ts', + ], + moduleFileExtensions: ['web.js', 'js', 'json', 'node', 'ts'], + testMatch: ['/server/routes/**/*.test.ts'], + testPathIgnorePatterns: ['node_modules', '/server/routes/index.test.ts'], + testEnvironment: 'jest-fixed-jsdom', +} diff --git a/server/routes/testutils/appSetup.ts b/server/routes/testutils/appSetup.ts index 130c128..88cd707 100644 --- a/server/routes/testutils/appSetup.ts +++ b/server/routes/testutils/appSetup.ts @@ -1,5 +1,4 @@ import express, { Express } from 'express' -import { NotFound } from 'http-errors' import { randomUUID } from 'crypto' import routes from '../index' @@ -9,6 +8,7 @@ import type { Services } from '../../services' import AuditService from '../../services/auditService' import { HmppsUser } from '../../interfaces/hmppsUser' import setUpWebSession from '../../middleware/setUpWebSession' +import { HmppsAuditClient } from '../../data' jest.mock('../../services/auditService') @@ -36,18 +36,23 @@ function appSetup(services: Services, production: boolean, userSupplier: () => H req.user = userSupplier() as Express.User req.flash = flashProvider res.locals = { + ...res.locals, user: { ...req.user } as HmppsUser, } next() }) - app.use((req, res, next) => { + app.use((req, _res, next) => { req.id = randomUUID() next() }) app.use(express.json()) app.use(express.urlencoded({ extended: true })) + app.use((_req, res, next) => { + res.notFound = () => res.status(404).render('pages/not-found') + next() + }) app.use(routes(services)) - app.use((req, res, next) => next(new NotFound())) + app.use((_req, res) => res.notFound()) app.use(errorHandler(production)) return app @@ -56,7 +61,14 @@ function appSetup(services: Services, production: boolean, userSupplier: () => H export function appWithAllRoutes({ production = false, services = { - auditService: new AuditService(null) as jest.Mocked, + auditService: new AuditService( + new HmppsAuditClient({ + enabled: false, + queueUrl: '', + region: '', + serviceName: '', + }), + ) as jest.Mocked, }, userSupplier = () => user, }: { diff --git a/server/sanitisedError.ts b/server/sanitisedError.ts index 7172c1a..69917c8 100644 --- a/server/sanitisedError.ts +++ b/server/sanitisedError.ts @@ -14,7 +14,7 @@ export type UnsanitisedError = ResponseError export default function sanitise(error: UnsanitisedError): SanitisedError { const e = new Error() as SanitisedError e.message = error.message - e.stack = error.stack + e.stack = error.stack || '' if (error.response) { e.text = error.response.text e.status = error.response.status diff --git a/server/services/auditService.test.ts b/server/services/auditService.test.ts index 42126f0..71a9c1a 100644 --- a/server/services/auditService.test.ts +++ b/server/services/auditService.test.ts @@ -8,7 +8,12 @@ describe('Audit service', () => { let auditService: AuditService beforeEach(() => { - hmppsAuditClient = new HmppsAuditClient(null) as jest.Mocked + hmppsAuditClient = new HmppsAuditClient({ + enabled: false, + queueUrl: '', + region: '', + serviceName: '', + }) as jest.Mocked auditService = new AuditService(hmppsAuditClient) }) diff --git a/server/utils/azureAppInsights.ts b/server/utils/azureAppInsights.ts index 8342ab2..0e063ea 100644 --- a/server/utils/azureAppInsights.ts +++ b/server/utils/azureAppInsights.ts @@ -1,11 +1,14 @@ import { + Contracts, defaultClient, DistributedTracingModes, getCorrelationContext, setup, - type TelemetryClient, + TelemetryClient, } from 'applicationinsights' -import { RequestHandler } from 'express' +import { Request, RequestHandler } from 'express' +import { v4 } from 'uuid' +import { EnvelopeTelemetry } from 'applicationinsights/out/Declarations/Contracts' import type { ApplicationInfo } from '../applicationInfo' export function initialiseAppInsights(): void { @@ -17,18 +20,49 @@ export function initialiseAppInsights(): void { } } +function addUserDataToRequests(envelope: EnvelopeTelemetry, contextObjects: Record | undefined) { + const isRequest = envelope.data.baseType === Contracts.TelemetryTypeString['Request'] + if (isRequest) { + const { username } = (contextObjects?.['http.ServerRequest'] as Request | undefined)?.res?.locals?.user || {} + if (username) { + const properties = envelope.data.baseData?.['properties'] + // eslint-disable-next-line no-param-reassign + envelope.data.baseData ??= {} + // eslint-disable-next-line no-param-reassign + envelope.data.baseData['properties'] = { + username, + ...properties, + } + } + } + return true +} + export function buildAppInsightsClient( { applicationName, buildNumber }: ApplicationInfo, overrideName?: string, -): TelemetryClient { +): TelemetryClient | null { if (process.env.APPLICATIONINSIGHTS_CONNECTION_STRING) { defaultClient.context.tags['ai.cloud.role'] = overrideName || applicationName defaultClient.context.tags['ai.application.ver'] = buildNumber + defaultClient.addTelemetryProcessor(({ data }) => { + const { url } = data.baseData! + return !url?.endsWith('/health') && !url?.endsWith('/ping') && !url?.endsWith('/metrics') + }) + + defaultClient.addTelemetryProcessor(addUserDataToRequests) + defaultClient.addTelemetryProcessor(({ tags, data }, contextObjects) => { - const operationNameOverride = contextObjects.correlationContext?.customProperties?.getProperty('operationName') - if (operationNameOverride) { - tags['ai.operation.name'] = data.baseData.name = operationNameOverride // eslint-disable-line no-param-reassign,no-multi-assign + const operationNameOverride = + contextObjects?.['correlationContext']?.customProperties?.getProperty('operationName') + if (operationNameOverride && tags) { + // eslint-disable-next-line no-param-reassign + tags['ai.operation.name'] = operationNameOverride + if (data?.baseData) { + // eslint-disable-next-line no-param-reassign + data.baseData['name'] = operationNameOverride + } } return true }) @@ -44,6 +78,7 @@ export function appInsightsMiddleware(): RequestHandler { const context = getCorrelationContext() if (context && req.route) { context.customProperties.setProperty('operationName', `${req.method} ${req.route?.path}`) + context.customProperties.setProperty('operationId', v4()) } }) next() diff --git a/server/utils/utils.test.ts b/server/utils/utils.test.ts index 19e5438..0ba5f5e 100644 --- a/server/utils/utils.test.ts +++ b/server/utils/utils.test.ts @@ -11,8 +11,8 @@ describe('convert to title case', () => { ['Leading spaces', ' RobeRT', ' Robert'], ['Trailing spaces', 'RobeRT ', 'Robert '], ['Hyphenated', 'Robert-John SmiTH-jONes-WILSON', 'Robert-John Smith-Jones-Wilson'], - ])('%s convertToTitleCase(%s, %s)', (_: string, a: string, expected: string) => { - expect(convertToTitleCase(a)).toEqual(expected) + ])('%s convertToTitleCase(%s, %s)', (_, input, expected) => { + expect(convertToTitleCase(input)).toEqual(expected) }) }) @@ -24,7 +24,7 @@ describe('initialise name', () => { ['Two words', 'Robert James', 'R. James'], ['Three words', 'Robert James Smith', 'R. Smith'], ['Double barrelled', 'Robert-John Smith-Jones-Wilson', 'R. Smith-Jones-Wilson'], - ])('%s initialiseName(%s, %s)', (_: string, a: string, expected: string) => { - expect(initialiseName(a)).toEqual(expected) + ])('%s initialiseName(%s, %s)', (_, input, expected) => { + expect(initialiseName(input)).toEqual(expected) }) }) diff --git a/server/utils/utils.ts b/server/utils/utils.ts index e722735..d6a2d19 100644 --- a/server/utils/utils.ts +++ b/server/utils/utils.ts @@ -1,5 +1,5 @@ const properCase = (word: string): string => - word.length >= 1 ? word[0].toUpperCase() + word.toLowerCase().slice(1) : word + word.length >= 1 && word[0] ? word[0].toUpperCase() + word.toLowerCase().slice(1) : word const isBlank = (str: string): boolean => !str || /^\s*$/.test(str) @@ -11,13 +11,20 @@ const isBlank = (str: string): boolean => !str || /^\s*$/.test(str) */ const properCaseName = (name: string): string => (isBlank(name) ? '' : name.split('-').map(properCase).join('-')) -export const convertToTitleCase = (sentence: string): string => - isBlank(sentence) ? '' : sentence.split(' ').map(properCaseName).join(' ') +export const convertToTitleCase = (sentence: string | null | undefined): string => + !sentence || isBlank(sentence) ? '' : sentence.split(' ').map(properCaseName).join(' ') -export const initialiseName = (fullName?: string): string | null => { +export const initialiseName = (fullName: string | undefined | null): string | null => { // this check is for the authError page if (!fullName) return null const array = fullName.split(' ') - return `${array[0][0]}. ${array.reverse()[0]}` + if (array.length < 1) { + return null + } + const firstName = array[0] + if (!firstName) { + return null + } + return `${firstName[0]}. ${array.reverse()[0]}` } diff --git a/server/views/pages/errorServiceProblem.njk b/server/views/pages/errorServiceProblem.njk new file mode 100644 index 0000000..6d166d0 --- /dev/null +++ b/server/views/pages/errorServiceProblem.njk @@ -0,0 +1,15 @@ +{% extends "../partials/layout.njk" %} + +{% set pageTitle = "Sorry, there is a problem - CSIP" %} +{% set mainClasses = "app-container govuk-body" %} + +{% block innerContent %} + +

Sorry, there is a problem with the service

+

The problem has been reported to the service team.

+

Try again later.

+

Get help

+

Contact the helpdesk on 0800 917 5148 or #6598 from inside an establishment.

+

Go to digital services home page

+ +{% endblock %} diff --git a/server/views/pages/not-found.njk b/server/views/pages/not-found.njk new file mode 100755 index 0000000..d4972da --- /dev/null +++ b/server/views/pages/not-found.njk @@ -0,0 +1,15 @@ +{% extends "../partials/layout.njk" %} + +{% set pageTitle = "Page not found - Allocate key workers" %} +{% set mainClasses = "app-container govuk-body" %} +{% set showBreadcrumbs = true %} + +{% block innerContent %} + +

Page not found

+

If you typed the web address, check it is correct.

+

If you pasted the web address, check you copied the entire address.

+

If the web address is correct or you selected a link or button, contact the DPS helpdesk on 0800 917 5148 or #6598 from inside an establishment.

+

Go to digital services home page

+ +{% endblock %} diff --git a/tsconfig.json b/tsconfig.json index 192a030..91af6fb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,15 +6,35 @@ "sourceMap": true, "skipLibCheck": true, "noEmit": false, - "allowJs": false, - "strict": false, + "lib": ["dom", "es2015.promise", "DOM.Iterable", "ESNext"], + "types": ["cypress", "express", "express-session", "@testing-library/cypress"], "resolveJsonModule": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "noImplicitAny": true, "experimentalDecorators": true, - "typeRoots": ["./server/@types", "./node_modules/@types"] + "strict": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noPropertyAccessFromIndexSignature": true, + "exactOptionalPropertyTypes": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "inlineSources": true, + // This improves issue grouping in Sentry. + "sourceRoot": "/" }, - "exclude": ["node_modules", "assets/**/*.js", "integration_tests", "dist", "cypress.config.ts", "esbuild", "eslint.config.mjs"], + "exclude": [ + "node_modules", + "assets/**/*.js", + "dist", + "cypress.config.ts", + "coverage", + "codecov_reports", + "esbuild", + "server/@types/csip/index.d.ts", + "test_results" + ], "include": ["**/*.js", "**/*.ts"] }