diff --git a/.circleci/config.yml b/.circleci/config.yml index 7f18e20..ba25572 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,7 +1,7 @@ version: 2.1 orbs: - hmpps: ministryofjustice/hmpps@7 + hmpps: ministryofjustice/hmpps@8 slack: circleci/slack@4.12.5 parameters: @@ -15,7 +15,7 @@ parameters: node-version: type: string - default: 18.18-browsers + default: 20.8-browsers jobs: build: @@ -49,7 +49,7 @@ jobs: - node_modules - build - dist - - .cache/Cypress + - assets/stylesheets unit_test: executor: @@ -61,17 +61,16 @@ jobs: key: dependency-cache-{{ checksum "package-lock.json" }} - run: name: unit tests - command: npm run test + command: npm run test:ci - store_test_results: path: test_results - store_artifacts: - path: test-results/unit-test-reports.html + path: test_results/unit-test-reports.html integration_test: executor: - name: hmpps/node_redis - node_tag: << pipeline.parameters.node-version >> - redis_tag: "7.0" + name: hmpps/node + tag: << pipeline.parameters.node-version >> steps: - checkout - attach_workspace: @@ -83,7 +82,7 @@ jobs: key: dependency-cache-{{ checksum "package-lock.json" }} - run: name: Get wiremock - command: curl -o wiremock.jar https://repo1.maven.org/maven2/com/github/tomakehurst/wiremock-standalone/2.27.1/wiremock-standalone-2.27.1.jar + command: curl -o wiremock.jar https://repo1.maven.org/maven2/org/wiremock/wiremock-standalone/3.3.1/wiremock-standalone-3.3.1.jar - run: name: Run wiremock command: java -jar wiremock.jar --port 9091 @@ -101,18 +100,18 @@ jobs: - store_test_results: path: test_results - store_artifacts: - path: integration-tests/videos + path: integration_tests/videos - store_artifacts: - path: integration-tests/screenshots + path: integration_tests/screenshots workflows: version: 2 build-test-and-deploy: jobs: - build: - filters: - tags: - ignore: /.*/ + filters: + tags: + ignore: /.*/ - unit_test: requires: - build @@ -131,6 +130,8 @@ workflows: name: deploy_dev env: "dev" jira_update: true + pipeline_id: <> + pipeline_number: <> context: hmpps-common-vars filters: branches: @@ -151,6 +152,8 @@ workflows: env: "preprod" jira_update: true jira_env_type: staging + pipeline_id: <> + pipeline_number: <> context: - hmpps-common-vars - hmpps-audit-poc-ui-preprod @@ -166,6 +169,8 @@ workflows: env: "prod" jira_update: true jira_env_type: production + pipeline_id: <> + pipeline_number: <> slack_notification: true slack_channel_name: << pipeline.parameters.releases-slack-channel >> context: diff --git a/.nvmrc b/.nvmrc index 3c03207..209e3ef 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -18 +20 diff --git a/Dockerfile b/Dockerfile index cfc9020..f843f8f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Stage: base image -FROM node:18.18-bullseye-slim as base +FROM node:20.8-bullseye-slim as base ARG BUILD_NUMBER ARG GIT_REF diff --git a/README.md b/README.md index b830284..c1a4f9e 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ And then, to build the assets and start the app with nodemon: ### Running integration tests -For local running, start a test db, redis, and wiremock instance by: +For local running, start a test db and wiremock instance by: `docker compose -f docker-compose-test.yml up` @@ -48,7 +48,7 @@ Then run the server in test mode by: And then either, run tests in headless mode with: `npm run int-test` - + Or run tests with the cypress UI: `npm run int-test-ui` diff --git a/assets/js/mojFrontendInit.js b/assets/js/mojFrontendInit.js new file mode 100644 index 0000000..5dd458d --- /dev/null +++ b/assets/js/mojFrontendInit.js @@ -0,0 +1 @@ +window.MOJFrontend.initAll() diff --git a/cypress.config.ts b/cypress.config.ts index 75dcb70..a34c06c 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from 'cypress' import { resetStubs } from './integration_tests/mockApis/wiremock' import auth from './integration_tests/mockApis/auth' +import manageUsersApi from './integration_tests/mockApis/manageUsersApi' import tokenVerification from './integration_tests/mockApis/tokenVerification' export default defineConfig({ @@ -14,12 +15,11 @@ export default defineConfig({ }, taskTimeout: 60000, e2e: { - // We've imported your old cypress plugins here. - // You may want to clean this up later by importing these. setupNodeEvents(on) { on('task', { reset: resetStubs, ...auth, + ...manageUsersApi, ...tokenVerification, }) }, diff --git a/docker-compose-test.yml b/docker-compose-test.yml index bd7cf6b..92587d7 100644 --- a/docker-compose-test.yml +++ b/docker-compose-test.yml @@ -1,13 +1,6 @@ version: '3.1' services: - redis: - image: 'redis:7.2' - networks: - - hmpps_int - ports: - - '6379:6379' - wiremock: image: wiremock/wiremock networks: diff --git a/docker-compose.yml b/docker-compose.yml index cd5c1ea..9f3a314 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,6 @@ version: '3.1' services: - redis: - image: 'redis:7.2' - networks: - - hmpps - container_name: redis - environment: - - ALLOW_EMPTY_PASSWORD=yes - ports: - - '6379:6379' - hmpps-auth: image: quay.io/hmpps/hmpps-auth:latest networks: @@ -33,12 +23,11 @@ services: GIT_BRANCH: main networks: - hmpps - depends_on: [redis] ports: - "3000:3000" environment: - PRODUCT_ID=UNASSIGNED - - REDIS_HOST=redis + - REDIS_ENABLED=false - HMPPS_AUTH_EXTERNAL_URL=http://localhost:9090/auth - HMPPS_AUTH_URL=http://hmpps-auth:8080/auth # These will need to match new creds in the seed auth service auth diff --git a/feature.env b/feature.env index 3a6cde9..80994b4 100644 --- a/feature.env +++ b/feature.env @@ -1,7 +1,9 @@ PORT=3007 HMPPS_AUTH_URL=http://localhost:9091/auth +MANAGE_USERS_API_URL=http://localhost:9091/manage-users-api TOKEN_VERIFICATION_API_URL=http://localhost:9091/verification TOKEN_VERIFICATION_ENABLED=true +REDIS_ENABLED=false NODE_ENV=development API_CLIENT_ID=clientid API_CLIENT_SECRET=clientsecret diff --git a/helm_deploy/hmpps-audit-poc-ui/Chart.yaml b/helm_deploy/hmpps-audit-poc-ui/Chart.yaml index 1dac7d1..6191e19 100644 --- a/helm_deploy/hmpps-audit-poc-ui/Chart.yaml +++ b/helm_deploy/hmpps-audit-poc-ui/Chart.yaml @@ -8,5 +8,5 @@ dependencies: version: "2.8" repository: https://ministryofjustice.github.io/hmpps-helm-charts - name: generic-prometheus-alerts - version: 1.3.3 + version: "1.3" repository: https://ministryofjustice.github.io/hmpps-helm-charts diff --git a/helm_deploy/hmpps-audit-poc-ui/values.yaml b/helm_deploy/hmpps-audit-poc-ui/values.yaml index 32d1cc8..e44579a 100644 --- a/helm_deploy/hmpps-audit-poc-ui/values.yaml +++ b/helm_deploy/hmpps-audit-poc-ui/values.yaml @@ -1,20 +1,20 @@ +--- generic-service: nameOverride: hmpps-audit-poc-ui serviceAccountName: hmpps-audit-poc-ui productId: "DPS018" - replicaCount: 4 + replicaCount: 2 image: repository: quay.io/hmpps/hmpps-audit-poc-ui - tag: app_version # override at deployment time + tag: app_version # override at deployment time port: 3000 ingress: enabled: true - host: app-hostname.local # override per environment + host: app-hostname.local # override per environment tlsSecretName: hmpps-audit-poc-ui-cert - path: / livenessProbe: httpGet: diff --git a/helm_deploy/values-dev.yaml b/helm_deploy/values-dev.yaml index d3f4a8a..c9357c0 100644 --- a/helm_deploy/values-dev.yaml +++ b/helm_deploy/values-dev.yaml @@ -10,7 +10,9 @@ generic-service: env: INGRESS_URL: "https://hmpps-audit-poc-ui-dev.hmpps.service.justice.gov.uk" HMPPS_AUTH_URL: "https://sign-in-dev.hmpps.service.justice.gov.uk/auth" + MANAGE_USERS_API_URL: "https://manage-users-api-dev.hmpps.service.justice.gov.uk" TOKEN_VERIFICATION_API_URL: "https://token-verification-api-dev.prison.service.justice.gov.uk" + ENVIRONMENT_NAME: DEV generic-prometheus-alerts: alertSeverity: digital-prison-service-dev diff --git a/helm_deploy/values-preprod.yaml b/helm_deploy/values-preprod.yaml index 342ba81..7925076 100644 --- a/helm_deploy/values-preprod.yaml +++ b/helm_deploy/values-preprod.yaml @@ -10,7 +10,9 @@ generic-service: env: INGRESS_URL: "https://hmpps-audit-poc-ui-preprod.hmpps.service.justice.gov.uk" HMPPS_AUTH_URL: "https://sign-in-preprod.hmpps.service.justice.gov.uk/auth" + MANAGE_USERS_API_URL: "https://manage-users-api-preprod.hmpps.service.justice.gov.uk" TOKEN_VERIFICATION_API_URL: "https://token-verification-api-preprod.prison.service.justice.gov.uk" + ENVIRONMENT_NAME: PRE-PRODUCTION generic-prometheus-alerts: alertSeverity: digital-prison-service-dev diff --git a/helm_deploy/values-prod.yaml b/helm_deploy/values-prod.yaml index 23f1a75..24b1931 100644 --- a/helm_deploy/values-prod.yaml +++ b/helm_deploy/values-prod.yaml @@ -10,6 +10,7 @@ generic-service: env: INGRESS_URL: "https://hmpps-audit-poc-ui.hmpps.service.justice.gov.uk" HMPPS_AUTH_URL: "https://sign-in.hmpps.service.justice.gov.uk/auth" + MANAGE_USERS_API_URL: "https://manage-users-api.hmpps.service.justice.gov.uk" TOKEN_VERIFICATION_API_URL: "https://token-verification-api.prison.service.justice.gov.uk" generic-prometheus-alerts: diff --git a/integration_tests/e2e/health.cy.ts b/integration_tests/e2e/health.cy.ts index 932305b..1a7775d 100644 --- a/integration_tests/e2e/health.cy.ts +++ b/integration_tests/e2e/health.cy.ts @@ -3,10 +3,11 @@ context('Healthcheck', () => { beforeEach(() => { cy.task('reset') cy.task('stubAuthPing') + cy.task('stubManageUsersPing') cy.task('stubTokenVerificationPing') }) - it('Health check page is visible', () => { + it('Health check page is visible and UP', () => { cy.request('/health').its('body.status').should('equal', 'UP') }) @@ -20,16 +21,24 @@ context('Healthcheck', () => { }) context('Some unhealthy', () => { - it('Reports correctly when token verification down', () => { + beforeEach(() => { cy.task('reset') cy.task('stubAuthPing') + cy.task('stubManageUsersPing') cy.task('stubTokenVerificationPing', 500) + }) + it('Reports correctly when token verification down', () => { cy.request({ url: '/health', method: 'GET', failOnStatusCode: false }).then(response => { expect(response.body.components.hmppsAuth.status).to.equal('UP') + expect(response.body.components.manageUsersApi.status).to.equal('UP') expect(response.body.components.tokenVerification.status).to.equal('DOWN') expect(response.body.components.tokenVerification.details).to.contain({ status: 500, retries: 2 }) }) }) + + it('Health check page is visible and DOWN', () => { + cy.request({ url: '/health', method: 'GET', failOnStatusCode: false }).its('body.status').should('equal', 'DOWN') + }) }) }) diff --git a/integration_tests/e2e/login.cy.ts b/integration_tests/e2e/signIn.cy.ts similarity index 92% rename from integration_tests/e2e/login.cy.ts rename to integration_tests/e2e/signIn.cy.ts index 67b4cdb..8312414 100644 --- a/integration_tests/e2e/login.cy.ts +++ b/integration_tests/e2e/signIn.cy.ts @@ -3,11 +3,11 @@ import AuthSignInPage from '../pages/authSignIn' import Page from '../pages/page' import AuthManageDetailsPage from '../pages/authManageDetails' -context('SignIn', () => { +context('Sign In', () => { beforeEach(() => { cy.task('reset') cy.task('stubSignIn') - cy.task('stubAuthUser') + cy.task('stubManageUser') }) it('Unauthenticated user directed to auth', () => { @@ -32,7 +32,7 @@ context('SignIn', () => { indexPage.headerPhaseBanner().should('contain.text', 'dev') }) - it('User can log out', () => { + it('User can sign out', () => { cy.signIn() const indexPage = Page.verifyOnPage(IndexPage) indexPage.signOut().click() @@ -41,6 +41,7 @@ context('SignIn', () => { it('User can manage their details', () => { cy.signIn() + cy.task('stubAuthManageDetails') const indexPage = Page.verifyOnPage(IndexPage) indexPage.manageDetails().get('a').invoke('removeAttr', 'target') @@ -66,7 +67,7 @@ context('SignIn', () => { cy.request('/').its('body').should('contain', 'Sign in') cy.task('stubVerifyToken', true) - cy.task('stubAuthUser', 'bobby brown') + cy.task('stubManageUser', 'bobby brown') cy.signIn() indexPage.headerUserName().contains('B. Brown') diff --git a/integration_tests/mockApis/auth.ts b/integration_tests/mockApis/auth.ts index 40afa85..0ba104b 100644 --- a/integration_tests/mockApis/auth.ts +++ b/integration_tests/mockApis/auth.ts @@ -4,12 +4,14 @@ import { Response } from 'superagent' import { stubFor, getMatchingRequests } from './wiremock' import tokenVerification from './tokenVerification' -const createToken = () => { +const createToken = (roles: string[] = []) => { + // authorities in the session are always prefixed by ROLE. + const authorities = roles.map(role => (role.startsWith('ROLE_') ? role : `ROLE_${role}`)) const payload = { user_name: 'USER1', scope: ['read'], auth_source: 'nomis', - authorities: [], + authorities, jti: '83b50a10-cca6-41db-985f-e87efb303ddb', client_id: 'clientid', } @@ -61,7 +63,7 @@ const redirect = () => 'Content-Type': 'text/html', Location: 'http://localhost:3007/sign-in/callback?code=codexxxx&state=stateyyyy', }, - body: 'SignIn page

Sign in

', + body: 'Sign in page

Sign in

', }, }) @@ -76,7 +78,7 @@ const signOut = () => headers: { 'Content-Type': 'text/html', }, - body: 'SignIn page

Sign in

', + body: 'Sign in page

Sign in

', }, }) @@ -95,7 +97,7 @@ const manageDetails = () => }, }) -const token = () => +const token = (roles: string[] = []) => stubFor({ request: { method: 'POST', @@ -108,7 +110,7 @@ const token = () => Location: 'http://localhost:3007/sign-in/callback?code=codexxxx&state=stateyyyy', }, jsonBody: { - access_token: createToken(), + access_token: createToken(roles), token_type: 'bearer', user_name: 'USER1', expires_in: 599, @@ -117,46 +119,10 @@ const token = () => }, }, }) - -const stubUser = (name: string) => - stubFor({ - request: { - method: 'GET', - urlPattern: '/auth/api/user/me', - }, - response: { - status: 200, - headers: { - 'Content-Type': 'application/json;charset=UTF-8', - }, - jsonBody: { - staffId: 231232, - username: 'USER1', - active: true, - name, - }, - }, - }) - -const stubUserRoles = () => - stubFor({ - request: { - method: 'GET', - urlPattern: '/auth/api/user/me/roles', - }, - response: { - status: 200, - headers: { - 'Content-Type': 'application/json;charset=UTF-8', - }, - jsonBody: [{ roleCode: 'SOME_USER_ROLE' }], - }, - }) - export default { getSignInUrl, stubAuthPing: ping, - stubSignIn: (): Promise<[Response, Response, Response, Response, Response, Response]> => - Promise.all([favicon(), redirect(), signOut(), manageDetails(), token(), tokenVerification.stubVerifyToken()]), - stubAuthUser: (name = 'john smith'): Promise<[Response, Response]> => Promise.all([stubUser(name), stubUserRoles()]), + stubAuthManageDetails: manageDetails, + stubSignIn: (roles: string[]): Promise<[Response, Response, Response, Response, Response]> => + Promise.all([favicon(), redirect(), signOut(), token(roles), tokenVerification.stubVerifyToken()]), } diff --git a/integration_tests/mockApis/manageUsersApi.ts b/integration_tests/mockApis/manageUsersApi.ts new file mode 100644 index 0000000..4ab5087 --- /dev/null +++ b/integration_tests/mockApis/manageUsersApi.ts @@ -0,0 +1,52 @@ +import { stubFor } from './wiremock' + +const stubUser = (name: string = 'john smith') => + stubFor({ + request: { + method: 'GET', + urlPattern: '/manage-users-api/users/me', + }, + response: { + status: 200, + headers: { + 'Content-Type': 'application/json;charset=UTF-8', + }, + jsonBody: { + username: 'USER1', + active: true, + name, + }, + }, + }) + +const stubUserRoles = () => + stubFor({ + request: { + method: 'GET', + urlPattern: '/manage-users-api/users/me/roles', + }, + response: { + status: 200, + headers: { + 'Content-Type': 'application/json;charset=UTF-8', + }, + jsonBody: [{ roleCode: 'SOME_USER_ROLE' }], + }, + }) + +const ping = () => + stubFor({ + request: { + method: 'GET', + urlPattern: '/manage-users-api/health/ping', + }, + response: { + status: 200, + }, + }) + +export default { + stubManageUser: stubUser, + stubManageUsersPing: ping, + stubManageUserRoles: stubUserRoles, +} diff --git a/package-lock.json b/package-lock.json index 1c0e6a7..800b55e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,10 @@ "license": "MIT", "dependencies": { "@aws-sdk/client-sqs": "^3.433.0", - "@ministryofjustice/frontend": "^1.8.0", + "@ministryofjustice/frontend": "^1.8.1", "@ministryofjustice/hmpps-audit-client": "^1.0.18", "agentkeepalive": "^4.5.0", - "applicationinsights": "^2.9.0", + "applicationinsights": "^2.9.1", "body-parser": "^1.20.2", "bunyan": "^1.8.15", "bunyan-format": "^0.2.1", @@ -25,52 +25,52 @@ "express-prom-bundle": "^6.6.0", "express-session": "^1.17.3", "govuk-frontend": "^4.7.0", - "helmet": "^7.0.0", + "helmet": "^7.1.0", "http-errors": "^2.0.0", "jquery": "^3.7.1", - "jwt-decode": "^3.1.2", + "jwt-decode": "^4.0.0", "nocache": "^4.0.0", "nunjucks": "^3.2.4", - "passport": "^0.6.0", + "passport": "^0.7.0", "passport-oauth2": "^1.7.0", "prom-client": "^15.0.0", - "redis": "^4.6.10", + "redis": "^4.6.11", "superagent": "^8.1.2", "url-value-parser": "^2.2.0", "uuid": "^9.0.1" }, "devDependencies": { - "@types/bunyan": "^1.8.9", - "@types/bunyan-format": "^0.2.6", - "@types/compression": "^1.7.3", - "@types/connect-flash": "0.0.38", - "@types/cookie-session": "^2.0.45", - "@types/csurf": "^1.11.3", - "@types/express-session": "^1.17.8", - "@types/http-errors": "^2.0.2", - "@types/jest": "^29.5.5", - "@types/jsonwebtoken": "^9.0.3", - "@types/node": "^18.18.5", - "@types/nunjucks": "^3.2.4", - "@types/passport": "^1.0.13", - "@types/passport-oauth2": "^1.4.13", - "@types/superagent": "^4.1.19", - "@types/supertest": "^2.0.14", - "@types/uuid": "^9.0.5", - "@typescript-eslint/eslint-plugin": "^6.8.0", - "@typescript-eslint/parser": "^6.8.0", + "@types/bunyan": "^1.8.11", + "@types/bunyan-format": "^0.2.9", + "@types/compression": "^1.7.5", + "@types/connect-flash": "0.0.40", + "@types/cookie-session": "^2.0.48", + "@types/csurf": "^1.11.5", + "@types/express-session": "^1.17.10", + "@types/http-errors": "^2.0.4", + "@types/jest": "^29.5.11", + "@types/jsonwebtoken": "^9.0.5", + "@types/node": "^20.10.4", + "@types/nunjucks": "^3.2.6", + "@types/passport": "^1.0.16", + "@types/passport-oauth2": "^1.4.15", + "@types/superagent": "^4.1.24", + "@types/supertest": "^2.0.16", + "@types/uuid": "^9.0.7", + "@typescript-eslint/eslint-plugin": "^6.13.2", + "@typescript-eslint/parser": "^6.13.2", "audit-ci": "^6.6.1", - "concurrently": "^8.2.1", + "concurrently": "^8.2.2", "cookie-session": "^2.0.0", - "cypress": "^13.3.1", - "cypress-multi-reporters": "^1.6.3", + "cypress": "^13.6.1", + "cypress-multi-reporters": "^1.6.4", "dotenv": "^16.3.1", - "eslint": "^8.51.0", + "eslint": "^8.55.0", "eslint-config-airbnb-base": "^15.0.0", - "eslint-config-prettier": "^9.0.0", + "eslint-config-prettier": "^9.1.0", "eslint-import-resolver-typescript": "^3.6.1", "eslint-plugin-cypress": "^2.15.1", - "eslint-plugin-import": "^2.28.1", + "eslint-plugin-import": "^2.29.0", "eslint-plugin-no-only-tests": "^3.1.0", "eslint-plugin-prettier": "^5.0.1", "husky": "^8.0.3", @@ -78,20 +78,20 @@ "jest-html-reporter": "^3.10.2", "jest-junit": "^16.0.0", "jsonwebtoken": "^9.0.2", - "lint-staged": "^15.0.1", + "lint-staged": "^15.2.0", "mocha-junit-reporter": "^2.2.1", - "nock": "^13.3.4", - "nodemon": "^3.0.1", - "prettier": "^3.0.3", + "nock": "^13.4.0", + "nodemon": "^3.0.2", + "prettier": "^3.1.0", "prettier-plugin-jinja-template": "^1.3.1", - "sass": "^1.69.3", + "sass": "^1.69.5", "supertest": "^6.3.3", "ts-jest": "^29.1.1", - "typescript": "^5.2.2" + "typescript": "^5.3.3" }, "engines": { - "node": "^18", - "npm": "^9" + "node": "^20", + "npm": "^10" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -2951,9 +2951,9 @@ } }, "node_modules/@types/connect-flash": { - "version": "0.0.38", - "resolved": "https://registry.npmjs.org/@types/connect-flash/-/connect-flash-0.0.38.tgz", - "integrity": "sha512-mvReKX9oe9dVomZZOZE6rQf2DOzXv8g6P52Q6Z03IxtTFHmRyZX0uP3wsOz0s7wq+kNICRQ6aAlOJQDy/5BziA==", + "version": "0.0.40", + "resolved": "https://registry.npmjs.org/@types/connect-flash/-/connect-flash-0.0.40.tgz", + "integrity": "sha512-vqGDzZ85Kyu/tKdDwXP6JCz4i2Xp3o4bYHSCXbF7XiL1HohogtGXG5pgbgypVbdO3DYqCOHIiZhp2Gh5fP2dDw==", "dev": true, "dependencies": { "@types/express": "*" @@ -3057,9 +3057,9 @@ } }, "node_modules/@types/jest": { - "version": "29.5.10", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.10.tgz", - "integrity": "sha512-tE4yxKEphEyxj9s4inideLHktW/x6DwesIwWZ9NN1FKf9zbJYsnhBoA9vrHA/IuIOKwPa5PcFBNV4lpMIOEzyQ==", + "version": "29.5.11", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.11.tgz", + "integrity": "sha512-S2mHmYIVe13vrm6q4kN6fLYYAka15ALQki/vgDC3mIukEOx8WJlv0kQPM+d4w8Gp6u0uSdKND04IlTXBv0rwnQ==", "dev": true, "dependencies": { "expect": "^29.0.0", @@ -3100,9 +3100,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "18.19.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.2.tgz", - "integrity": "sha512-6wzfBdbWpe8QykUkXBjtmO3zITA0A3FIjoy+in0Y2K4KrCiRhNYJIdwAPDffZ3G6GnaKaSLSEa9ZuORLfEoiwg==", + "version": "20.10.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.5.tgz", + "integrity": "sha512-nNPsNE65wjMxEKI93yOP+NPGGBJz/PoN3kZsVLee0XMiJolxSekEVD8wRwBUBqkwc7UWop0edW50yrCQW4CyRw==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -3256,16 +3256,16 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "6.13.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.13.1.tgz", - "integrity": "sha512-5bQDGkXaxD46bPvQt08BUz9YSaO4S0fB1LB5JHQuXTfkGPI3+UUeS387C/e9jRie5GqT8u5kFTrMvAjtX4O5kA==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.14.0.tgz", + "integrity": "sha512-1ZJBykBCXaSHG94vMMKmiHoL0MhNHKSVlcHVYZNw+BKxufhqQVTOawNpwwI1P5nIFZ/4jLVop0mcY6mJJDFNaw==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "6.13.1", - "@typescript-eslint/type-utils": "6.13.1", - "@typescript-eslint/utils": "6.13.1", - "@typescript-eslint/visitor-keys": "6.13.1", + "@typescript-eslint/scope-manager": "6.14.0", + "@typescript-eslint/type-utils": "6.14.0", + "@typescript-eslint/utils": "6.14.0", + "@typescript-eslint/visitor-keys": "6.14.0", "debug": "^4.3.4", "graphemer": "^1.4.0", "ignore": "^5.2.4", @@ -3291,15 +3291,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "6.13.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.13.1.tgz", - "integrity": "sha512-fs2XOhWCzRhqMmQf0eicLa/CWSaYss2feXsy7xBD/pLyWke/jCIVc2s1ikEAtSW7ina1HNhv7kONoEfVNEcdDQ==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.14.0.tgz", + "integrity": "sha512-QjToC14CKacd4Pa7JK4GeB/vHmWFJckec49FR4hmIRf97+KXole0T97xxu9IFiPxVQ1DBWrQ5wreLwAGwWAVQA==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "6.13.1", - "@typescript-eslint/types": "6.13.1", - "@typescript-eslint/typescript-estree": "6.13.1", - "@typescript-eslint/visitor-keys": "6.13.1", + "@typescript-eslint/scope-manager": "6.14.0", + "@typescript-eslint/types": "6.14.0", + "@typescript-eslint/typescript-estree": "6.14.0", + "@typescript-eslint/visitor-keys": "6.14.0", "debug": "^4.3.4" }, "engines": { @@ -3319,13 +3319,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "6.13.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.13.1.tgz", - "integrity": "sha512-BW0kJ7ceiKi56GbT2KKzZzN+nDxzQK2DS6x0PiSMPjciPgd/JRQGMibyaN2cPt2cAvuoH0oNvn2fwonHI+4QUQ==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.14.0.tgz", + "integrity": "sha512-VT7CFWHbZipPncAZtuALr9y3EuzY1b1t1AEkIq2bTXUPKw+pHoXflGNG5L+Gv6nKul1cz1VH8fz16IThIU0tdg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.13.1", - "@typescript-eslint/visitor-keys": "6.13.1" + "@typescript-eslint/types": "6.14.0", + "@typescript-eslint/visitor-keys": "6.14.0" }, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -3336,13 +3336,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "6.13.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.13.1.tgz", - "integrity": "sha512-A2qPlgpxx2v//3meMqQyB1qqTg1h1dJvzca7TugM3Yc2USDY+fsRBiojAEo92HO7f5hW5mjAUF6qobOPzlBCBQ==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.14.0.tgz", + "integrity": "sha512-x6OC9Q7HfYKqjnuNu5a7kffIYs3No30isapRBJl1iCHLitD8O0lFbRcVGiOcuyN837fqXzPZ1NS10maQzZMKqw==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "6.13.1", - "@typescript-eslint/utils": "6.13.1", + "@typescript-eslint/typescript-estree": "6.14.0", + "@typescript-eslint/utils": "6.14.0", "debug": "^4.3.4", "ts-api-utils": "^1.0.1" }, @@ -3363,9 +3363,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "6.13.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.13.1.tgz", - "integrity": "sha512-gjeEskSmiEKKFIbnhDXUyiqVma1gRCQNbVZ1C8q7Zjcxh3WZMbzWVfGE9rHfWd1msQtPS0BVD9Jz9jded44eKg==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.14.0.tgz", + "integrity": "sha512-uty9H2K4Xs8E47z3SnXEPRNDfsis8JO27amp2GNCnzGETEW3yTqEIVg5+AI7U276oGF/tw6ZA+UesxeQ104ceA==", "dev": true, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -3376,13 +3376,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "6.13.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.13.1.tgz", - "integrity": "sha512-sBLQsvOC0Q7LGcUHO5qpG1HxRgePbT6wwqOiGLpR8uOJvPJbfs0mW3jPA3ujsDvfiVwVlWUDESNXv44KtINkUQ==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.14.0.tgz", + "integrity": "sha512-yPkaLwK0yH2mZKFE/bXkPAkkFgOv15GJAUzgUVonAbv0Hr4PK/N2yaA/4XQbTZQdygiDkpt5DkxPELqHguNvyw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.13.1", - "@typescript-eslint/visitor-keys": "6.13.1", + "@typescript-eslint/types": "6.14.0", + "@typescript-eslint/visitor-keys": "6.14.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -3403,17 +3403,17 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "6.13.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.13.1.tgz", - "integrity": "sha512-ouPn/zVoan92JgAegesTXDB/oUp6BP1v8WpfYcqh649ejNc9Qv+B4FF2Ff626kO1xg0wWwwG48lAJ4JuesgdOw==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.14.0.tgz", + "integrity": "sha512-XwRTnbvRr7Ey9a1NT6jqdKX8y/atWG+8fAIu3z73HSP8h06i3r/ClMhmaF/RGWGW1tHJEwij1uEg2GbEmPYvYg==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@types/json-schema": "^7.0.12", "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.13.1", - "@typescript-eslint/types": "6.13.1", - "@typescript-eslint/typescript-estree": "6.13.1", + "@typescript-eslint/scope-manager": "6.14.0", + "@typescript-eslint/types": "6.14.0", + "@typescript-eslint/typescript-estree": "6.14.0", "semver": "^7.5.4" }, "engines": { @@ -3428,12 +3428,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "6.13.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.13.1.tgz", - "integrity": "sha512-NDhQUy2tg6XGNBGDRm1XybOHSia8mcXmlbKWoQP+nm1BIIMxa55shyJfZkHpEBN62KNPLrocSM2PdPcaLgDKMQ==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.14.0.tgz", + "integrity": "sha512-fB5cw6GRhJUz03MrROVuj5Zm/Q+XWlVdIsFj+Zb1Hvqouc8t+XP2H5y53QYU/MGtd2dPg6/vJJlhoX3xc2ehfw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.13.1", + "@typescript-eslint/types": "6.14.0", "eslint-visitor-keys": "^3.4.1" }, "engines": { @@ -5080,9 +5080,9 @@ } }, "node_modules/cypress": { - "version": "13.6.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.6.0.tgz", - "integrity": "sha512-quIsnFmtj4dBUEJYU4OH0H12bABJpSujvWexC24Ju1gTlKMJbeT6tTO0vh7WNfiBPPjoIXLN+OUqVtiKFs6SGw==", + "version": "13.6.1", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.6.1.tgz", + "integrity": "sha512-k1Wl5PQcA/4UoTffYKKaxA0FJKwg8yenYNYRzLt11CUR0Kln+h7Udne6mdU1cUIdXBDTVZWtmiUjzqGs7/pEpw==", "dev": true, "hasInstallScript": true, "dependencies": { @@ -5153,6 +5153,15 @@ "mocha": ">=3.1.2" } }, + "node_modules/cypress/node_modules/@types/node": { + "version": "18.19.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.3.tgz", + "integrity": "sha512-k5fggr14DwAytoA/t8rPrIz++lXK7/DqckthCmoZOKNsEbJkId4Z//BqgApXBUGrGddrigYa1oqheo/7YmW4rg==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, "node_modules/dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -8800,9 +8809,12 @@ } }, "node_modules/jwt-decode": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", - "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "engines": { + "node": ">=18" + } }, "node_modules/keygrip": { "version": "1.1.0", @@ -10402,9 +10414,9 @@ } }, "node_modules/passport": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/passport/-/passport-0.6.0.tgz", - "integrity": "sha512-0fe+p3ZnrWRW74fe8+SvCyf4a3Pb2/h7gFkQ8yTJpAO50gDzlfjZUZTO1k5Eg9kUct22OxHLqDZoKUWRHOh9ug==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -12222,9 +12234,9 @@ } }, "node_modules/typescript": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz", - "integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true, "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index 4381e53..6a3dedd 100644 --- a/package.json +++ b/package.json @@ -24,12 +24,12 @@ "test:ci": "jest --runInBand", "security_audit": "npx audit-ci --config audit-ci.json", "int-test": "cypress run --config video=false", - "int-test-ui": "cypress open", + "int-test-ui": "cypress open --e2e --browser chrome", "clean": "rm -rf dist build node_modules stylesheets" }, "engines": { - "node": "^18", - "npm": "^9" + "node": "^20", + "npm": "^10" }, "jest": { "transform": { @@ -92,10 +92,10 @@ }, "dependencies": { "@aws-sdk/client-sqs": "^3.433.0", - "@ministryofjustice/frontend": "^1.8.0", + "@ministryofjustice/frontend": "^1.8.1", "@ministryofjustice/hmpps-audit-client": "^1.0.18", "agentkeepalive": "^4.5.0", - "applicationinsights": "^2.9.0", + "applicationinsights": "^2.9.1", "body-parser": "^1.20.2", "bunyan": "^1.8.15", "bunyan-format": "^0.2.1", @@ -107,52 +107,52 @@ "express-prom-bundle": "^6.6.0", "express-session": "^1.17.3", "govuk-frontend": "^4.7.0", - "helmet": "^7.0.0", + "helmet": "^7.1.0", "http-errors": "^2.0.0", "jquery": "^3.7.1", - "jwt-decode": "^3.1.2", + "jwt-decode": "^4.0.0", "nocache": "^4.0.0", "nunjucks": "^3.2.4", - "passport": "^0.6.0", + "passport": "^0.7.0", "passport-oauth2": "^1.7.0", "prom-client": "^15.0.0", - "redis": "^4.6.10", + "redis": "^4.6.11", "superagent": "^8.1.2", "url-value-parser": "^2.2.0", "uuid": "^9.0.1" }, "devDependencies": { - "@types/bunyan": "^1.8.9", - "@types/bunyan-format": "^0.2.6", - "@types/compression": "^1.7.3", - "@types/connect-flash": "0.0.38", - "@types/cookie-session": "^2.0.45", - "@types/csurf": "^1.11.3", - "@types/express-session": "^1.17.8", - "@types/http-errors": "^2.0.2", - "@types/jest": "^29.5.5", - "@types/jsonwebtoken": "^9.0.3", - "@types/node": "^18.18.5", - "@types/nunjucks": "^3.2.4", - "@types/passport": "^1.0.13", - "@types/passport-oauth2": "^1.4.13", - "@types/superagent": "^4.1.19", - "@types/supertest": "^2.0.14", - "@types/uuid": "^9.0.5", - "@typescript-eslint/eslint-plugin": "^6.8.0", - "@typescript-eslint/parser": "^6.8.0", + "@types/bunyan": "^1.8.11", + "@types/bunyan-format": "^0.2.9", + "@types/compression": "^1.7.5", + "@types/connect-flash": "0.0.40", + "@types/cookie-session": "^2.0.48", + "@types/csurf": "^1.11.5", + "@types/express-session": "^1.17.10", + "@types/http-errors": "^2.0.4", + "@types/jest": "^29.5.11", + "@types/jsonwebtoken": "^9.0.5", + "@types/node": "^20.10.4", + "@types/nunjucks": "^3.2.6", + "@types/passport": "^1.0.16", + "@types/passport-oauth2": "^1.4.15", + "@types/superagent": "^4.1.24", + "@types/supertest": "^2.0.16", + "@types/uuid": "^9.0.7", + "@typescript-eslint/eslint-plugin": "^6.13.2", + "@typescript-eslint/parser": "^6.13.2", "audit-ci": "^6.6.1", - "concurrently": "^8.2.1", + "concurrently": "^8.2.2", "cookie-session": "^2.0.0", - "cypress": "^13.3.1", - "cypress-multi-reporters": "^1.6.3", + "cypress": "^13.6.1", + "cypress-multi-reporters": "^1.6.4", "dotenv": "^16.3.1", - "eslint": "^8.51.0", + "eslint": "^8.55.0", "eslint-config-airbnb-base": "^15.0.0", - "eslint-config-prettier": "^9.0.0", + "eslint-config-prettier": "^9.1.0", "eslint-import-resolver-typescript": "^3.6.1", "eslint-plugin-cypress": "^2.15.1", - "eslint-plugin-import": "^2.28.1", + "eslint-plugin-import": "^2.29.0", "eslint-plugin-no-only-tests": "^3.1.0", "eslint-plugin-prettier": "^5.0.1", "husky": "^8.0.3", @@ -160,15 +160,15 @@ "jest-html-reporter": "^3.10.2", "jest-junit": "^16.0.0", "jsonwebtoken": "^9.0.2", - "lint-staged": "^15.0.1", + "lint-staged": "^15.2.0", "mocha-junit-reporter": "^2.2.1", - "nock": "^13.3.4", - "nodemon": "^3.0.1", - "prettier": "^3.0.3", + "nock": "^13.4.0", + "nodemon": "^3.0.2", + "prettier": "^3.1.0", "prettier-plugin-jinja-template": "^1.3.1", - "sass": "^1.69.3", + "sass": "^1.69.5", "supertest": "^6.3.3", "ts-jest": "^29.1.1", - "typescript": "^5.2.2" + "typescript": "^5.3.3" } } diff --git a/renovate.json b/renovate.json index b795f70..1fa0d1f 100644 --- a/renovate.json +++ b/renovate.json @@ -24,6 +24,12 @@ "matchPackageNames": ["@types/node"], "matchUpdateTypes": ["major"], "enabled": false + }, + { + "matchDatasources": ["docker"], + "matchPackageNames": ["node"], + "matchUpdateTypes": ["major"], + "enabled": false } ], "vulnerabilityAlerts": { diff --git a/server/config.ts b/server/config.ts index 943f714..f9d8614 100755 --- a/server/config.ts +++ b/server/config.ts @@ -38,6 +38,7 @@ export default { https: production, staticResourceCacheDuration: '1h', redis: { + enabled: get('REDIS_ENABLED', 'false', requiredInProduction) === 'true', host: get('REDIS_HOST', 'localhost', requiredInProduction), port: parseInt(process.env.REDIS_PORT, 10) || 6379, password: process.env.REDIS_AUTH_TOKEN, @@ -66,6 +67,14 @@ export default { systemClientId: get('SYSTEM_CLIENT_ID', 'clientid', requiredInProduction), systemClientSecret: get('SYSTEM_CLIENT_SECRET', 'clientsecret', requiredInProduction), }, + manageUsersApi: { + url: get('MANAGE_USERS_API_URL', 'http://localhost:9091', requiredInProduction), + timeout: { + response: Number(get('MANAGE_USERS_API_TIMEOUT_RESPONSE', 10000)), + deadline: Number(get('MANAGE_USERS_API_TIMEOUT_DEADLINE', 10000)), + }, + agent: new AgentConfig(Number(get('MANAGE_USERS_API_TIMEOUT_RESPONSE', 10000))), + }, tokenVerification: { url: get('TOKEN_VERIFICATION_API_URL', 'http://localhost:8100', requiredInProduction), timeout: { diff --git a/server/data/hmppsAuthClient.test.ts b/server/data/hmppsAuthClient.test.ts index 08bf042..cd02976 100644 --- a/server/data/hmppsAuthClient.test.ts +++ b/server/data/hmppsAuthClient.test.ts @@ -2,9 +2,9 @@ import nock from 'nock' import config from '../config' import HmppsAuthClient from './hmppsAuthClient' -import TokenStore from './tokenStore' +import TokenStore from './tokenStore/redisTokenStore' -jest.mock('./tokenStore') +jest.mock('./tokenStore/redisTokenStore') const tokenStore = new TokenStore(null) as jest.Mocked @@ -25,32 +25,6 @@ describe('hmppsAuthClient', () => { nock.cleanAll() }) - describe('getUser', () => { - it('should return data from api', async () => { - const response = { data: 'data' } - - fakeHmppsAuthApi - .get('/api/user/me') - .matchHeader('authorization', `Bearer ${token.access_token}`) - .reply(200, response) - - const output = await hmppsAuthClient.getUser(token.access_token) - expect(output).toEqual(response) - }) - }) - - describe('getUserRoles', () => { - it('should return data from api', async () => { - fakeHmppsAuthApi - .get('/api/user/me/roles') - .matchHeader('authorization', `Bearer ${token.access_token}`) - .reply(200, [{ roleCode: 'role1' }, { roleCode: 'role2' }]) - - const output = await hmppsAuthClient.getUserRoles(token.access_token) - expect(output).toEqual(['role1', 'role2']) - }) - }) - describe('getSystemClientToken', () => { it('should instantiate the redis client', async () => { tokenStore.getToken.mockResolvedValue(token.access_token) diff --git a/server/data/hmppsAuthClient.ts b/server/data/hmppsAuthClient.ts index 1854eee..cd8df71 100644 --- a/server/data/hmppsAuthClient.ts +++ b/server/data/hmppsAuthClient.ts @@ -2,7 +2,7 @@ import { URLSearchParams } from 'url' import superagent from 'superagent' -import type TokenStore from './tokenStore' +import type TokenStore from './tokenStore/tokenStore' import logger from '../../logger' import config from '../config' import generateOauthClientToken from '../authentication/clientCredentials' @@ -32,21 +32,6 @@ function getSystemClientTokenFromHmppsAuth(username?: string): Promise { - logger.info('Getting user details: calling HMPPS Auth') - return HmppsAuthClient.restClient(token).get({ path: '/api/user/me' }) - } - - getUserRoles(token: string): Promise { - return HmppsAuthClient.restClient(token) - .get({ path: '/api/user/me/roles' }) - .then(roles => roles.map(role => role.roleCode)) - } - async getSystemClientToken(username?: string): Promise { const key = username || '%ANONYMOUS%' diff --git a/server/data/index.ts b/server/data/index.ts index 5ad904d..a263516 100644 --- a/server/data/index.ts +++ b/server/data/index.ts @@ -11,16 +11,22 @@ initialiseAppInsights() buildAppInsightsClient(applicationInfo) import HmppsAuthClient from './hmppsAuthClient' +import ManageUsersApiClient from './manageUsersApiClient' import { createRedisClient } from './redisClient' -import TokenStore from './tokenStore' +import RedisTokenStore from './tokenStore/redisTokenStore' +import InMemoryTokenStore from './tokenStore/inMemoryTokenStore' +import config from '../config' type RestClientBuilder = (token: string) => T export const dataAccess = () => ({ applicationInfo, - hmppsAuthClient: new HmppsAuthClient(new TokenStore(createRedisClient())), + hmppsAuthClient: new HmppsAuthClient( + config.redis.enabled ? new RedisTokenStore(createRedisClient()) : new InMemoryTokenStore(), + ), + manageUsersApiClient: new ManageUsersApiClient(), }) export type DataAccess = ReturnType -export { HmppsAuthClient, RestClientBuilder } +export { HmppsAuthClient, RestClientBuilder, ManageUsersApiClient } diff --git a/server/data/manageUsersApiClient.test.ts b/server/data/manageUsersApiClient.test.ts new file mode 100644 index 0000000..a4a07da --- /dev/null +++ b/server/data/manageUsersApiClient.test.ts @@ -0,0 +1,37 @@ +import nock from 'nock' + +import config from '../config' +import ManageUsersApiClient from './manageUsersApiClient' + +jest.mock('./tokenStore/redisTokenStore') + +const token = { access_token: 'token-1', expires_in: 300 } + +describe('manageUsersApiClient', () => { + let fakeManageUsersApiClient: nock.Scope + let manageUsersApiClient: ManageUsersApiClient + + beforeEach(() => { + fakeManageUsersApiClient = nock(config.apis.manageUsersApi.url) + manageUsersApiClient = new ManageUsersApiClient() + }) + + afterEach(() => { + jest.resetAllMocks() + nock.cleanAll() + }) + + describe('getUser', () => { + it('should return data from api', async () => { + const response = { data: 'data' } + + fakeManageUsersApiClient + .get('/users/me') + .matchHeader('authorization', `Bearer ${token.access_token}`) + .reply(200, response) + + const output = await manageUsersApiClient.getUser(token.access_token) + expect(output).toEqual(response) + }) + }) +}) diff --git a/server/data/manageUsersApiClient.ts b/server/data/manageUsersApiClient.ts new file mode 100644 index 0000000..5d8f2d7 --- /dev/null +++ b/server/data/manageUsersApiClient.ts @@ -0,0 +1,30 @@ +import logger from '../../logger' +import config from '../config' +import RestClient from './restClient' + +export interface User { + username: string + name?: string + active?: boolean + authSource?: string + uuid?: string + userId?: string + activeCaseLoadId?: string // Will be removed from User. For now, use 'me/caseloads' endpoint in 'nomis-user-roles-api' +} + +export interface UserRole { + roleCode: string +} + +export default class ManageUsersApiClient { + constructor() {} + + private static restClient(token: string): RestClient { + return new RestClient('Manage Users Api Client', config.apis.manageUsersApi, token) + } + + getUser(token: string): Promise { + logger.info('Getting user details: calling HMPPS Manage Users Api') + return ManageUsersApiClient.restClient(token).get({ path: '/users/me' }) + } +} diff --git a/server/data/tokenStore/inMemoryTokenStore.test.ts b/server/data/tokenStore/inMemoryTokenStore.test.ts new file mode 100644 index 0000000..eb9b2fa --- /dev/null +++ b/server/data/tokenStore/inMemoryTokenStore.test.ts @@ -0,0 +1,19 @@ +import TokenStore from './inMemoryTokenStore' + +describe('inMemoryTokenStore', () => { + let tokenStore: TokenStore + + beforeEach(() => { + tokenStore = new TokenStore() + }) + + it('Can store and retrieve token', async () => { + await tokenStore.setToken('user-1', 'token-1', 10) + expect(await tokenStore.getToken('user-1')).toBe('token-1') + }) + + it('Expires token', async () => { + await tokenStore.setToken('user-2', 'token-2', -1) + expect(await tokenStore.getToken('user-2')).toBe(null) + }) +}) diff --git a/server/data/tokenStore/inMemoryTokenStore.ts b/server/data/tokenStore/inMemoryTokenStore.ts new file mode 100644 index 0000000..2b6a1b4 --- /dev/null +++ b/server/data/tokenStore/inMemoryTokenStore.ts @@ -0,0 +1,17 @@ +import TokenStore from './tokenStore' + +export default class InMemoryTokenStore implements TokenStore { + map = new Map() + + public async setToken(key: string, token: string, durationSeconds: number): Promise { + this.map.set(key, { token, expiry: new Date(Date.now() + durationSeconds * 1000) }) + return Promise.resolve() + } + + public async getToken(key: string): Promise { + if (!this.map.has(key) || this.map.get(key).expiry.getTime() < Date.now()) { + return Promise.resolve(null) + } + return Promise.resolve(this.map.get(key).token) + } +} diff --git a/server/data/tokenStore/redisTokenStore.test.ts b/server/data/tokenStore/redisTokenStore.test.ts new file mode 100644 index 0000000..ef98d6c --- /dev/null +++ b/server/data/tokenStore/redisTokenStore.test.ts @@ -0,0 +1,56 @@ +import { RedisClient } from '../redisClient' +import TokenStore from './redisTokenStore' + +const redisClient = { + get: jest.fn(), + set: jest.fn(), + on: jest.fn(), + connect: jest.fn(), + isOpen: true, +} as unknown as jest.Mocked + +describe('tokenStore', () => { + let tokenStore: TokenStore + + beforeEach(() => { + tokenStore = new TokenStore(redisClient as unknown as RedisClient) + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + describe('get token', () => { + it('Can retrieve token', async () => { + redisClient.get.mockResolvedValue('token-1') + + await expect(tokenStore.getToken('user-1')).resolves.toBe('token-1') + + expect(redisClient.get).toHaveBeenCalledWith('systemToken:user-1') + }) + + it('Connects when no connection calling getToken', async () => { + ;(redisClient as unknown as Record).isOpen = false + + await tokenStore.getToken('user-1') + + expect(redisClient.connect).toHaveBeenCalledWith() + }) + }) + + describe('set token', () => { + it('Can set token', async () => { + await tokenStore.setToken('user-1', 'token-1', 10) + + expect(redisClient.set).toHaveBeenCalledWith('systemToken:user-1', 'token-1', { EX: 10 }) + }) + + 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 new file mode 100644 index 0000000..52032f7 --- /dev/null +++ b/server/data/tokenStore/redisTokenStore.ts @@ -0,0 +1,30 @@ +import type { RedisClient } from '../redisClient' + +import logger from '../../../logger' +import TokenStore from './tokenStore' + +export default class RedisTokenStore implements TokenStore { + private readonly prefix = 'systemToken:' + + constructor(private readonly client: RedisClient) { + client.on('error', error => { + logger.error(error, `Redis error`) + }) + } + + private async ensureConnected() { + if (!this.client.isOpen) { + await this.client.connect() + } + } + + public async setToken(key: string, token: string, durationSeconds: number): Promise { + await this.ensureConnected() + await this.client.set(`${this.prefix}${key}`, token, { EX: durationSeconds }) + } + + public async getToken(key: string): Promise { + await this.ensureConnected() + return this.client.get(`${this.prefix}${key}`) + } +} diff --git a/server/data/tokenStore/tokenStore.ts b/server/data/tokenStore/tokenStore.ts new file mode 100644 index 0000000..60f3a28 --- /dev/null +++ b/server/data/tokenStore/tokenStore.ts @@ -0,0 +1,4 @@ +export default interface TokenStore { + setToken(key: string, token: string, durationSeconds: number): Promise + getToken(key: string): Promise +} diff --git a/server/middleware/authorisationMiddleware.test.ts b/server/middleware/authorisationMiddleware.test.ts index b44318a..fbf7a06 100644 --- a/server/middleware/authorisationMiddleware.test.ts +++ b/server/middleware/authorisationMiddleware.test.ts @@ -35,28 +35,37 @@ describe('authorisationMiddleware', () => { jest.resetAllMocks() }) - it('should return next when no required roles', async () => { + it('should return next when no required roles', () => { const res = createResWithToken({ authorities: [] }) - await authorisationMiddleware()(req, res, next) + authorisationMiddleware()(req, res, next) expect(next).toHaveBeenCalled() expect(res.redirect).not.toHaveBeenCalled() }) - it('should redirect when user has no authorised roles', async () => { + it('should redirect when user has no authorised roles', () => { const res = createResWithToken({ authorities: [] }) - await authorisationMiddleware(['SOME_REQUIRED_ROLE'])(req, res, next) + authorisationMiddleware(['SOME_REQUIRED_ROLE'])(req, res, next) expect(next).not.toHaveBeenCalled() expect(res.redirect).toHaveBeenCalledWith('/authError') }) - it('should return next when user has authorised role', async () => { - const res = createResWithToken({ authorities: ['SOME_REQUIRED_ROLE'] }) + it('should return next when user has authorised role', () => { + const res = createResWithToken({ authorities: ['ROLE_SOME_REQUIRED_ROLE'] }) - await authorisationMiddleware(['SOME_REQUIRED_ROLE'])(req, res, next) + authorisationMiddleware(['SOME_REQUIRED_ROLE'])(req, res, next) + + expect(next).toHaveBeenCalled() + expect(res.redirect).not.toHaveBeenCalled() + }) + + it('should return next when user has authorised role and middleware created with ROLE_ prefix', () => { + const res = createResWithToken({ authorities: ['ROLE_SOME_REQUIRED_ROLE'] }) + + authorisationMiddleware(['ROLE_SOME_REQUIRED_ROLE'])(req, res, next) expect(next).toHaveBeenCalled() expect(res.redirect).not.toHaveBeenCalled() diff --git a/server/middleware/authorisationMiddleware.ts b/server/middleware/authorisationMiddleware.ts index e0a7514..b736f5a 100644 --- a/server/middleware/authorisationMiddleware.ts +++ b/server/middleware/authorisationMiddleware.ts @@ -1,4 +1,4 @@ -import jwtDecode from 'jwt-decode' +import { jwtDecode } from 'jwt-decode' import type { RequestHandler } from 'express' import logger from '../../logger' @@ -6,10 +6,13 @@ import asyncMiddleware from './asyncMiddleware' export default function authorisationMiddleware(authorisedRoles: string[] = []): RequestHandler { return asyncMiddleware((req, res, next) => { + // authorities in the user token will always be prefixed by ROLE_. + // Convert roles that are passed into this function without the prefix so that we match correctly. + const authorisedAuthorities = authorisedRoles.map(role => (role.startsWith('ROLE_') ? role : `ROLE_${role}`)) if (res.locals?.user?.token) { const { authorities: roles = [] } = jwtDecode(res.locals.user.token) as { authorities?: string[] } - if (authorisedRoles.length && !roles.some(role => authorisedRoles.includes(role))) { + if (authorisedAuthorities.length && !roles.some(role => authorisedAuthorities.includes(role))) { logger.error('User is not authorised to access this') return res.redirect('/authError') } diff --git a/server/middleware/setUpWebSession.ts b/server/middleware/setUpWebSession.ts index ac54c3f..14a93c1 100644 --- a/server/middleware/setUpWebSession.ts +++ b/server/middleware/setUpWebSession.ts @@ -1,5 +1,5 @@ import { v4 as uuidv4 } from 'uuid' -import session from 'express-session' +import session, { MemoryStore, Store } from 'express-session' import RedisStore from 'connect-redis' import express, { Router } from 'express' import { createRedisClient } from '../data/redisClient' @@ -7,13 +7,20 @@ import config from '../config' import logger from '../../logger' export default function setUpWebSession(): Router { - const client = createRedisClient() - client.connect().catch((err: Error) => logger.error(`Error connecting to Redis`, err)) + let store: Store + if (config.redis.enabled) { + const client = createRedisClient() + client.connect().catch((err: Error) => logger.error(`Error connecting to Redis`, err)) + store = new RedisStore({ client }) + } else { + store = new MemoryStore() + } const router = express.Router() router.use( session({ - store: new RedisStore({ client }), + store, + name: 'hmpps-audit-poc-ui.session', cookie: { secure: config.https, sameSite: 'lax', maxAge: config.session.expiryMinutes * 60 * 1000 }, secret: config.session.secret, resave: false, // redis implements touch so shouldn't need this diff --git a/server/services/healthCheck.ts b/server/services/healthCheck.ts index e937d8a..3be7836 100644 --- a/server/services/healthCheck.ts +++ b/server/services/healthCheck.ts @@ -51,6 +51,7 @@ function gatherCheckInfo(aggregateStatus: Record, currentStatus const apiChecks = [ service('hmppsAuth', `${config.apis.hmppsAuth.url}/health/ping`, config.apis.hmppsAuth.agent), + service('manageUsersApi', `${config.apis.manageUsersApi.url}/health/ping`, config.apis.manageUsersApi.agent), ...(config.apis.tokenVerification.enabled ? [ service( diff --git a/server/services/index.ts b/server/services/index.ts index 6a68235..2bbd1c0 100644 --- a/server/services/index.ts +++ b/server/services/index.ts @@ -2,9 +2,9 @@ import { dataAccess } from '../data' import UserService from './userService' export const services = () => { - const { hmppsAuthClient, applicationInfo } = dataAccess() + const { applicationInfo, manageUsersApiClient } = dataAccess() - const userService = new UserService(hmppsAuthClient) + const userService = new UserService(manageUsersApiClient) return { applicationInfo, diff --git a/server/services/userService.test.ts b/server/services/userService.test.ts index 47d246e..e4bde20 100644 --- a/server/services/userService.test.ts +++ b/server/services/userService.test.ts @@ -1,30 +1,40 @@ import UserService from './userService' -import HmppsAuthClient, { type User } from '../data/hmppsAuthClient' +import ManageUsersApiClient, { type User } from '../data/manageUsersApiClient' +import createUserToken from '../testutils/createUserToken' -jest.mock('../data/hmppsAuthClient') - -const token = 'some token' +jest.mock('../data/manageUsersApiClient') describe('User service', () => { - let hmppsAuthClient: jest.Mocked + let manageUsersApiClient: jest.Mocked let userService: UserService describe('getUser', () => { beforeEach(() => { - hmppsAuthClient = new HmppsAuthClient(null) as jest.Mocked - userService = new UserService(hmppsAuthClient) + manageUsersApiClient = new ManageUsersApiClient() as jest.Mocked + userService = new UserService(manageUsersApiClient) }) it('Retrieves and formats user name', async () => { - hmppsAuthClient.getUser.mockResolvedValue({ name: 'john smith' } as User) + const token = createUserToken([]) + manageUsersApiClient.getUser.mockResolvedValue({ name: 'john smith' } as User) const result = await userService.getUser(token) expect(result.displayName).toEqual('John Smith') }) + it('Retrieves and formats roles', async () => { + const token = createUserToken(['ROLE_ONE', 'ROLE_TWO']) + manageUsersApiClient.getUser.mockResolvedValue({ name: 'john smith' } as User) + + const result = await userService.getUser(token) + + expect(result.roles).toEqual(['ONE', 'TWO']) + }) + it('Propagates error', async () => { - hmppsAuthClient.getUser.mockRejectedValue(new Error('some error')) + const token = createUserToken([]) + manageUsersApiClient.getUser.mockRejectedValue(new Error('some error')) await expect(userService.getUser(token)).rejects.toEqual(new Error('some error')) }) diff --git a/server/services/userService.ts b/server/services/userService.ts index 4f7d8ee..bdd902f 100644 --- a/server/services/userService.ts +++ b/server/services/userService.ts @@ -1,16 +1,23 @@ +import { jwtDecode } from 'jwt-decode' import { convertToTitleCase } from '../utils/utils' -import type HmppsAuthClient from '../data/hmppsAuthClient' -import type { User } from '../data/hmppsAuthClient' +import type { User } from '../data/manageUsersApiClient' +import ManageUsersApiClient from '../data/manageUsersApiClient' export interface UserDetails extends User { displayName: string + roles: string[] } export default class UserService { - constructor(private readonly hmppsAuthClient: HmppsAuthClient) {} + constructor(private readonly manageUsersApiClient: ManageUsersApiClient) {} async getUser(token: string): Promise { - const user = await this.hmppsAuthClient.getUser(token) - return { ...user, displayName: convertToTitleCase(user.name) } + const user = await this.manageUsersApiClient.getUser(token) + return { ...user, roles: this.getUserRoles(token), displayName: convertToTitleCase(user.name) } + } + + getUserRoles(token: string): string[] { + const { authorities: roles = [] } = jwtDecode(token) as { authorities?: string[] } + return roles.map(role => role.substring(role.indexOf('_') + 1)) } } diff --git a/server/testutils/createUserToken.ts b/server/testutils/createUserToken.ts new file mode 100644 index 0000000..1b4bdbb --- /dev/null +++ b/server/testutils/createUserToken.ts @@ -0,0 +1,14 @@ +import jwt from 'jsonwebtoken' + +export default function createUserToken(authorities: string[]) { + const payload = { + user_name: 'user1', + scope: ['read', 'write'], + auth_source: 'nomis', + authorities, + jti: 'a610a10-cca6-41db-985f-e87efb303aaf', + client_id: 'clientid', + } + + return jwt.sign(payload, 'secret', { expiresIn: '1h' }) +} diff --git a/server/views/partials/breadCrumb.njk b/server/views/partials/breadCrumb.njk index affaa72..d5ddbd0 100644 --- a/server/views/partials/breadCrumb.njk +++ b/server/views/partials/breadCrumb.njk @@ -1,6 +1,6 @@ {% from "govuk/components/breadcrumbs/macro.njk" import govukBreadcrumbs %} -{% macro breadCrumb(pageTitle, breadCrumbList) %} +{% macro breadcrumb(pageTitle, breadcrumbList) %} {% set rows = [ { text: "Home", @@ -8,7 +8,7 @@ } ] %} - {% for item in breadCrumbList %} + {% for item in breadcrumbList %} {% set rows = (rows.push( { text: item.title, diff --git a/server/views/partials/breadcrumb.njk b/server/views/partials/breadcrumb.njk new file mode 100644 index 0000000..d5ddbd0 --- /dev/null +++ b/server/views/partials/breadcrumb.njk @@ -0,0 +1,32 @@ +{% from "govuk/components/breadcrumbs/macro.njk" import govukBreadcrumbs %} + +{% macro breadcrumb(pageTitle, breadcrumbList) %} + + {% set rows = [ { + text: "Home", + href: '/' + } ] + %} + + {% for item in breadcrumbList %} + {% set rows = (rows.push( + { + text: item.title, + href: item.href + } + ), rows) %} + {% endfor %} + + {% set completedRows = (rows.push( + { + text: pageTitle + } + ), rows) %} + +{{ govukBreadcrumbs({ + collapseOnMobile: true, + items: completedRows, + classes: "govuk-!-display-none-print" +}) }} + +{% endmacro %} diff --git a/server/views/partials/layout.njk b/server/views/partials/layout.njk index 57f93d2..4a98be8 100644 --- a/server/views/partials/layout.njk +++ b/server/views/partials/layout.njk @@ -10,13 +10,7 @@ - - - - + {% endblock %} {% block pageTitle %}{{pageTitle | default(applicationName)}}{% endblock %} @@ -29,9 +23,8 @@ {% endblock %} {% block bodyEnd %} - {# Run JavaScript at end of the - , to avoid blocking the initial render. #} + {% endblock %}