diff --git a/app.js b/app.js index f269ab333..3a9d61684 100644 --- a/app.js +++ b/app.js @@ -1,27 +1,28 @@ -const enableAppInsights = require('./app/app-insights/app-insights'); +import enableAppInsights from './app/app-insights/app-insights.js'; +import payloadGuard from './app/service/service-payloadGuard.js'; + +import express from 'express'; +import cookieParser from 'cookie-parser'; +import { legacyCreateProxyMiddleware as proxy } from 'http-proxy-middleware'; +import config from 'config'; +import loggingPkg from '@hmcts/nodejs-logging'; +const { Express: ExpressLogger, Logger } = loggingPkg; +import {authCheckerUserOnlyFilter, mapFetchErrors} from './app/user/auth-checker-user-only-filter.js'; +import addressLookup from './app/address/address-lookup.js'; +import serviceFilter from './app/service/service-filter.js'; +import corsHandler from './app/security/cors.js'; +import handleTiming from './app/security/timing.js'; +import hstsHandler from './app/security/hsts.js'; +import healthcheck from '@hmcts/nodejs-healthcheck'; +import routes from '@hmcts/nodejs-healthcheck/healthcheck/routes.js'; +import {oauth2Route} from './app/oauth2/oauth2-route.js'; +import {logoutRoute} from './app/oauth2/logout-route.js'; +import noCache from 'nocache'; +import noSniff from 'dont-sniff-mimetype'; enableAppInsights(); -let express = require('express'); -let cookieParser = require('cookie-parser'); -const { legacyCreateProxyMiddleware: proxy } = require('http-proxy-middleware'); -const config = require('config'); -const { Express: ExpressLogger, Logger } = require('@hmcts/nodejs-logging'); -const {authCheckerUserOnlyFilter} = require('./app/user/auth-checker-user-only-filter'); -const {mapFetchErrors} = require('./app/user/auth-checker-user-only-filter'); -const addressLookup = require('./app/address/address-lookup'); -const serviceFilter = require('./app/service/service-filter'); -const corsHandler = require('./app/security/cors'); -const handleTiming = require('./app/security/timing'); -const hstsHandler = require('./app/security/hsts'); -const healthcheck = require('@hmcts/nodejs-healthcheck'); -const routes = require('@hmcts/nodejs-healthcheck/healthcheck/routes'); -const oauth2Route = require('./app/oauth2/oauth2-route').oauth2Route; -const logoutRoute = require('./app/oauth2/logout-route').logoutRoute; -const noCache = require('nocache'); -const noSniff = require('dont-sniff-mimetype'); - -let app = express(); +const app = express(); const appHealth = express(); const logger = Logger.getLogger('app'); @@ -77,6 +78,18 @@ app.get('/logout', logoutRoute); app.use(serviceFilter); +// parsing + basic size limits (protects DoS) +app.use('/data', express.json({ limit: '1mb', strict: true })); +app.use('/data', express.urlencoded({ limit: '1mb', extended: false })); + +// lightweight safety/validation middleware +app.use('/data', payloadGuard({ + allowContentTypes: ['application/json'], + maxArrayLength: 10000, // sanity cap + rejectPathTraversal: true, + rejectObviousScriptTags: true +})); + app.get('/addresses',(req, res, next) => { addressLookup(req.query.postcode) .then(result => res.send(result)) @@ -163,7 +176,7 @@ app.use(function (err, req, res, next) { // eslint-disable-line no-unused-vars res.locals.error = req.app.get('env') === 'development' ? err : {}; // render the error page - let status = isNaN(err.status) ? 500 : err.status; + let status = Number.isNaN(err.status) ? 500 : err.status; res.status(status); res.json({ error: err.error || 'Unauthorized', @@ -172,4 +185,4 @@ app.use(function (err, req, res, next) { // eslint-disable-line no-unused-vars }); }); -module.exports = app; +export default app; diff --git a/app/address/address-lookup.js b/app/address/address-lookup.js index e8bbd2f0e..8ad3d23a3 100644 --- a/app/address/address-lookup.js +++ b/app/address/address-lookup.js @@ -1,9 +1,10 @@ -const config = require('config'); -const fetch = require('node-fetch').default; -const { HttpsProxyAgent } = require('https-proxy-agent'); -const { Logger } = require('@hmcts/nodejs-logging'); +import config from 'config'; +import fetch from 'node-fetch'; +import {HttpsProxyAgent} from 'https-proxy-agent'; +import {Logger} from '@hmcts/nodejs-logging'; + const logger = Logger.getLogger('addressLookup'); -const crypto = require('crypto'); +import crypto from 'crypto'; function addressLookup(postcode) { @@ -50,4 +51,4 @@ function badResponse(res, body) { throw errorObj(`An error response was received from the Postcode Service. Status=${res.status}, Body=${body}`, res.status); } -module.exports = addressLookup; +export default addressLookup; diff --git a/app/app-insights/app-insights.js b/app/app-insights/app-insights.js index e8f0a26f3..701c94eb9 100644 --- a/app/app-insights/app-insights.js +++ b/app/app-insights/app-insights.js @@ -1,7 +1,5 @@ -const config = require('config'); -const appInsights = require('applicationinsights'); - -const enabled = config.get('appInsights.enabled'); +import config from 'config'; +import appInsights from 'applicationinsights'; function fineGrainedSampling(envelope) { // activity data is not interesting and should not really be going through this proxy anyway @@ -9,11 +7,11 @@ function fineGrainedSampling(envelope) { if (['RequestData', 'RemoteDependencyData'].includes(envelope.data.baseType) && (envelope.data.baseData.name.includes('/activity') || envelope.data.baseData.name.includes('/health'))) { envelope.sampleRate = 1; } - return true; } -const enableAppInsights = () => { +export default function enableAppInsights(){ + const enabled = config.get('appInsights.enabled'); if (enabled) { const appInsightsKey = config.get('secrets.ccd.AppInsightsInstrumentationKey'); const appInsightsRoleName = config.get('appInsights.roleName'); @@ -25,5 +23,3 @@ const enableAppInsights = () => { appInsights.start(); } }; - -module.exports = enableAppInsights; diff --git a/app/cache/cache-config.js b/app/cache/cache-config.js index 72dd77506..4bca639f6 100644 --- a/app/cache/cache-config.js +++ b/app/cache/cache-config.js @@ -1,10 +1,17 @@ -const CacheService = require('./cache-service'); -const config = require('config'); +import config from 'config'; +import CacheService from './cache-service.js'; // TTL for userInfoCache should always be set to lower value than the actual token's TTL // to limit risk/time of an expired token still returning details - config default is 10 mins -const userInfoCache = new CacheService('UserInfoCache', config.get('cache.user_info_ttl'), config.get('cache.user_info_check_period')); +let userInfoCacheInstance; -module.exports = { - userInfoCache +function userInfoCache() { + if (!userInfoCacheInstance) { + userInfoCacheInstance = new CacheService('UserInfoCache', config.get('cache.user_info_ttl'), config.get('cache.user_info_check_period')); + } + return userInfoCacheInstance; +} + +export { + userInfoCache }; diff --git a/app/cache/cache-service.js b/app/cache/cache-service.js index 83038b7c5..a48eb17e7 100644 --- a/app/cache/cache-service.js +++ b/app/cache/cache-service.js @@ -1,11 +1,12 @@ -const NodeCache = require('node-cache'); -const { Logger } = require('@hmcts/nodejs-logging'); +import NodeCache from 'node-cache'; +import {Logger} from '@hmcts/nodejs-logging'; + const logger = Logger.getLogger('CacheService'); class Cache { constructor(name, ttlSeconds, checkPeriodSeconds) { - this.cache = new NodeCache({ stdTTL: ttlSeconds, checkperiod: checkPeriodSeconds }); + this.cache = new NodeCache({stdTTL: ttlSeconds, checkperiod: checkPeriodSeconds}); this.name = name; } @@ -41,4 +42,4 @@ class Cache { } } -module.exports = Cache; +export default Cache; diff --git a/app/oauth2/access-token-request.js b/app/oauth2/access-token-request.js index 2caea7fa9..478b226e8 100644 --- a/app/oauth2/access-token-request.js +++ b/app/oauth2/access-token-request.js @@ -1,7 +1,7 @@ -const config = require('config'); -const fetch = require('node-fetch').default; -const { URL } = require('url'); -const { Logger } = require('@hmcts/nodejs-logging'); +import config from 'config'; +import fetch from 'node-fetch'; +import {URL} from 'url'; +import {Logger} from '@hmcts/nodejs-logging'; const logger = Logger.getLogger('accessTokenRequest'); @@ -50,4 +50,4 @@ function accessTokenRequest(request) { throw error; }); } -module.exports = accessTokenRequest; +export default accessTokenRequest; diff --git a/app/oauth2/logout-route.js b/app/oauth2/logout-route.js index 31e539152..2e308f467 100644 --- a/app/oauth2/logout-route.js +++ b/app/oauth2/logout-route.js @@ -1,8 +1,9 @@ -const config = require('config'); -const fetch = require('node-fetch').default; -const COOKIE_ACCESS_TOKEN = require('./oauth2-route').COOKIE_ACCESS_TOKEN; +import config from 'config'; +import fetch from 'node-fetch'; +import {COOKIE_ACCESS_TOKEN} from './oauth2-route.js'; + const TOKEN_PLACEHOLDER = ':token'; -const { userInfoCache } = require('../cache/cache-config'); +import {userInfoCache} from '../cache/cache-config.js'; const logoutRoute = (req, res, next) => { const accessToken = req.cookies && req.cookies[COOKIE_ACCESS_TOKEN]; @@ -20,7 +21,7 @@ const logoutRoute = (req, res, next) => { fetch(config.get('idam.oauth2.logout_endpoint').replace(TOKEN_PLACEHOLDER, accessToken), options) .then(() => { res.clearCookie(COOKIE_ACCESS_TOKEN); - userInfoCache.del(accessToken); + userInfoCache().del(accessToken); res.status(204).send(); }) .catch(err => next(err)); @@ -33,6 +34,6 @@ const logoutRoute = (req, res, next) => { } }; -module.exports = { +export { logoutRoute }; diff --git a/app/oauth2/oauth2-route.js b/app/oauth2/oauth2-route.js index 826ee5fc9..9a5a15045 100644 --- a/app/oauth2/oauth2-route.js +++ b/app/oauth2/oauth2-route.js @@ -1,26 +1,27 @@ -const accessTokenRequest = require('./access-token-request'); -const config = require('config'); +import accessTokenRequest from './access-token-request.js'; +import config from 'config'; + const COOKIE_ACCESS_TOKEN = 'accessToken'; const oauth2Route = (req, res, next) => { accessTokenRequest(req) .then(result => { - if( result.status === 200 ) { + if (result.status === 200) { - result.json().then ( jsonResult => { + result.json().then(jsonResult => { - res.cookie(COOKIE_ACCESS_TOKEN, jsonResult.access_token, - { - maxAge: jsonResult.expires_in * 1000, - httpOnly: true, - secure: config.get('security.secure_auth_cookie_enabled') - }); - res.status(204).send(); + res.cookie(COOKIE_ACCESS_TOKEN, jsonResult.access_token, + { + maxAge: jsonResult.expires_in * 1000, + httpOnly: true, + secure: config.get('security.secure_auth_cookie_enabled') + }); + res.status(204).send(); } ); } else { next({ - status: 502, + status: 502, message: 'Internal Server Error' }); } @@ -29,7 +30,7 @@ const oauth2Route = (req, res, next) => { .catch(err => next(err)); }; -module.exports = { +export { COOKIE_ACCESS_TOKEN, oauth2Route }; diff --git a/app/security/cors.js b/app/security/cors.js index 679c3676d..f5ad99c14 100644 --- a/app/security/cors.js +++ b/app/security/cors.js @@ -1,5 +1,5 @@ -const config = require('config'); -const sanitize = require('../util/sanitize'); +import config from 'config'; +import {sanitizeData} from '../util/sanitize.js'; const WILDCARD = '*'; @@ -23,7 +23,7 @@ const handleCors = (req, res, next) => { if (corsOptions.allowOrigin) { const origin = req.get('origin'); if (corsOptions.allowOrigin(origin)) { - res.set('Access-Control-Allow-Origin', sanitize.sanitizeData(origin)); + res.set('Access-Control-Allow-Origin', sanitizeData(origin)); } } else { res.set('Access-Control-Allow-Origin', '*'); @@ -34,7 +34,7 @@ const handleCors = (req, res, next) => { if (corsOptions.allowMethods) { res.set('Access-Control-Allow-Methods', corsOptions.allowMethods); } - res.set('Access-Control-Allow-Headers', sanitize.sanitizeData(req.get('Access-Control-Request-Headers'))); + res.set('Access-Control-Allow-Headers', sanitizeData(req.get('Access-Control-Request-Headers'))); if('OPTIONS' === req.method) { res .status(200) @@ -44,4 +44,4 @@ const handleCors = (req, res, next) => { } }; -module.exports = handleCors; +export default handleCors; diff --git a/app/security/hsts.js b/app/security/hsts.js index b5e3cbbd8..8d16e20c6 100644 --- a/app/security/hsts.js +++ b/app/security/hsts.js @@ -3,4 +3,4 @@ const handleHSTS = (req, res, next) => { next(); }; -module.exports = handleHSTS; +export default handleHSTS; diff --git a/app/security/timing.js b/app/security/timing.js index 8a3695f27..a932d4927 100644 --- a/app/security/timing.js +++ b/app/security/timing.js @@ -1,8 +1,8 @@ -const config = require('config'); +import config from 'config'; const handleTiming = (req, res, next) => { res.set('Timing-Allow-Origin', config.get('security.timing-allow-origin')); next(); }; -module.exports = handleTiming; +export default handleTiming; diff --git a/app/service/service-filter.js b/app/service/service-filter.js index 79fa38473..8cfe14689 100644 --- a/app/service/service-filter.js +++ b/app/service/service-filter.js @@ -1,5 +1,5 @@ -const serviceTokenGenerator = require('./service-token-generator'); -const { Logger } = require('@hmcts/nodejs-logging'); +import serviceTokenGenerator from './service-token-generator.js'; +import {Logger} from '@hmcts/nodejs-logging'; const logger = Logger.getLogger('serviceFilter'); @@ -26,4 +26,4 @@ const serviceFilter = (req, res, next) => { }); }; -module.exports = serviceFilter; +export default serviceFilter; diff --git a/app/service/service-payloadGuard.js b/app/service/service-payloadGuard.js new file mode 100644 index 000000000..36954264d --- /dev/null +++ b/app/service/service-payloadGuard.js @@ -0,0 +1,115 @@ +import {Logger} from '@hmcts/nodejs-logging'; + +function payloadGuard(opts) { + const logger = Logger.getLogger('payloadGuard'); + + const { + allowContentTypes = ['application/json'], + rejectPathTraversal = true, + rejectObviousScriptTags = true, + maxArrayLength = 10000, + maxDepth = 40 + } = opts || {}; + + return (req, res, next) => { + if (['POST', 'PUT', 'PATCH'].includes(req.method)) { + const contentType = (req.headers['content-type'] || '').split(';')[0].trim(); + if (allowContentTypes.length && !allowContentTypes.includes(contentType)) { + logger.warn(`Invalid content-type: ${contentType}`); + return res.status(415).json({ + status: 415, + error: 'Unsupported Media Type' + }); + } + + if (rejectPathTraversal && isSuspiciousPath(req.url || '')) { + logger.warn(`Path traversal attempt: ${req.url || ''}`); + return res.status(400).json({message: 'Invalid path'}); + } + + if (req.body && typeof req.body === 'object') { + const seen = new Set(); + let depth = 0; + let tooBig = false; + + (function walk(v, d) { + if (tooBig || d > maxDepth) { + tooBig = true; + return; + } + if (v && typeof v === 'object') { + if (seen.has(v)) return; // cycles + seen.add(v); + if (Array.isArray(v) && v.length > maxArrayLength) { + tooBig = true; + return; + } + for (const k of Object.keys(v)) walk(v[k], d + 1); + } + })(req.body, depth); + + if (tooBig) { + logger.warn('Payload too large or too deeply nested'); + return res.status(413).json({message: 'Payload too large or too deeply nested'}); + } + + } + + if (rejectObviousScriptTags) { + const hasScript = containsScriptTag(req.body); + if (hasScript) { + logger.warn('Payload contains disallowed content'); + return res.status(400).json({message: 'Payload contains disallowed content'}); + } + } + } + next(); + }; +} + +function safeDecodeURIComponent(value) { + return String(value).replace(/%[0-9a-fA-F]{2}/g, m => { + const code = parseInt(m.slice(1), 16); + return String.fromCharCode(code); + }); +} + +function extractPath(rawUrl) { + const noHash = rawUrl.split('#', 1)[0]; + return noHash.split('?', 1)[0]; +} + +function isSuspiciousPath(rawUrl) { + const pathname = extractPath(rawUrl); + const decodedRaw = safeDecodeURIComponent(rawUrl); + const decodedPath = safeDecodeURIComponent(pathname); + + return ( + rawUrl.includes('..') || + decodedRaw.includes('..') || + pathname.includes('..') || + pathname.includes('\\') || + decodedPath.includes('..') || + decodedPath.includes('\\') || + decodedRaw.includes('\\') || + rawUrl.includes('\\') || + /%2e|%2f|%5c/i.test(rawUrl) || + /%2e|%2f|%5c/i.test(pathname) + ); +} + +function containsScriptTag(obj) { + const re = /<\s*script\b|javascript:/i; + const stack = [obj]; + while (stack.length) { + const cur = stack.pop(); + if (typeof cur === 'string') { + if (re.test(cur)) return true; + } else if (cur && typeof cur === 'object') { + for (const k in cur) stack.push(cur[k]); + } + } + return false; +} + +export default payloadGuard; diff --git a/app/service/service-token-generator.js b/app/service/service-token-generator.js index f709834fc..38a597e15 100644 --- a/app/service/service-token-generator.js +++ b/app/service/service-token-generator.js @@ -1,7 +1,7 @@ -const OTP = require('otp'); -const config = require('config'); -const { jwtDecode } = require('jwt-decode'); -const fetch = require('../util/fetch'); +import config from 'config'; +import {jwtDecode} from 'jwt-decode'; +import fetch from '../util/fetch.js'; +import OTP from 'otp'; const idamS2SUrl = config.get('idam.s2s_url'); const serviceName = config.get('idam.service_name'); @@ -41,4 +41,4 @@ const serviceTokenGenerator = () => { } }; -module.exports = serviceTokenGenerator; +export default serviceTokenGenerator; diff --git a/app/user/auth-checker-user-only-filter.js b/app/user/auth-checker-user-only-filter.js index f2d5d4fea..48d87de63 100644 --- a/app/user/auth-checker-user-only-filter.js +++ b/app/user/auth-checker-user-only-filter.js @@ -1,13 +1,12 @@ -const userRequestAuthorizer = require('./user-request-authorizer'); -const { Logger } = require('@hmcts/nodejs-logging'); +import {authorise} from './user-request-authorizer.js'; +import {Logger} from '@hmcts/nodejs-logging'; const logger = Logger.getLogger('authCheckerUserOnlyFilter'); const authCheckerUserOnlyFilter = (req, res, next) => { req.authentication = {}; - userRequestAuthorizer - .authorise(req) + authorise(req) .then(user => { req.authentication.user = user; req.headers['user-id'] = user.uid; @@ -27,7 +26,7 @@ const authCheckerUserOnlyFilter = (req, res, next) => { }; const isBadGatewayError = (error) => { - return error.message !== undefined && (error.message.includes('getaddrinfo ENOTFOUND') || + return error.message !== undefined && (error.message.includes('getaddrinfo ENOTFOUND') || error.message.includes('socket hang up') || error.message.includes('getaddrinfo EAI_AGAIN') || error.message.includes('connect ETIMEOUT') || @@ -71,7 +70,7 @@ const mapFetchErrors = (error, res, next) => { } }; -module.exports = { +export { authCheckerUserOnlyFilter, mapFetchErrors }; diff --git a/app/user/authorised-roles-extractor.js b/app/user/authorised-roles-extractor.js index 2ead0420f..44788a36e 100644 --- a/app/user/authorised-roles-extractor.js +++ b/app/user/authorised-roles-extractor.js @@ -19,4 +19,4 @@ const extract = (request) => { return []; }; -exports.extract = extract; +export default extract; diff --git a/app/user/cached-user-resolver.js b/app/user/cached-user-resolver.js index 46786dbe8..083d30d7f 100644 --- a/app/user/cached-user-resolver.js +++ b/app/user/cached-user-resolver.js @@ -1,10 +1,10 @@ -const userResolver = require('./user-resolver'); -const { userInfoCache } = require('../cache/cache-config'); -const jwtUtil = require('../util/jwt'); +import {getUserDetails} from './user-resolver.js'; +import {userInfoCache} from '../cache/cache-config.js'; +import {removeBearer} from '../util/jwt.js'; const getCachedUserDetails = (jwt) => { - return userInfoCache.getOrElseUpdate(jwtUtil.removeBearer(jwt), - () => userResolver.getUserDetails(jwt)); + return userInfoCache().getOrElseUpdate(removeBearer(jwt), + () => getUserDetails(jwt)); }; -exports.getUserDetails = getCachedUserDetails; +export {getCachedUserDetails}; diff --git a/app/user/user-request-authorizer.js b/app/user/user-request-authorizer.js index f4e241a27..854473465 100644 --- a/app/user/user-request-authorizer.js +++ b/app/user/user-request-authorizer.js @@ -1,9 +1,13 @@ -const authorizedRolesExtractor = require('./authorised-roles-extractor'); -const COOKIE_ACCESS_TOKEN = require('../oauth2/oauth2-route').COOKIE_ACCESS_TOKEN; -const config = require('config'); +import extract from './authorised-roles-extractor.js'; +import {COOKIE_ACCESS_TOKEN} from '../oauth2/oauth2-route.js'; +import config from 'config'; + const userResolver = config.get('cache.user_info_enabled') - ? require('./cached-user-resolver') - : require('./user-resolver'); + ? await import('./cached-user-resolver.js') + : await import('./user-resolver.js'); + +const getUserDetails = userResolver.getUserDetails + || userResolver.default?.getUserDetails; const AUTHORIZATION = 'Authorization'; @@ -50,14 +54,13 @@ const authorise = (request) => { // Use AccessToken cookie as Authorization header if (!request.get(AUTHORIZATION) && bearerToken) { if (!request.headers) { - request.headers = { [AUTHORIZATION]: `Bearer ${bearerToken}` }; + request.headers = {[AUTHORIZATION]: `Bearer ${bearerToken}`}; } else { request.headers[AUTHORIZATION] = `Bearer ${bearerToken}`; } } - return userResolver - .getUserDetails(bearerToken) + return getUserDetails(bearerToken) .then(userDetails => user = userDetails) .then(() => fillInUserId(request, user)) .then(() => authorizeRoles(request, user)) @@ -66,7 +69,7 @@ const authorise = (request) => { const authorizeRoles = (request, user) => { if (request.originalUrl.includes('/caseworkers/')) { - const roles = authorizedRolesExtractor.extract(request); + const roles = extract(request); if (roles.length === 0 || !roles.some(role => user.roles.includes(role))) { @@ -97,8 +100,10 @@ const fillInUserId = (request, user) => { request.originalUrl = request.originalUrl.replace(USER_ID_PLACEHOLDER, user.uid); }; -exports.ERROR_TOKEN_MISSING = ERROR_TOKEN_MISSING; -exports.ERROR_UNAUTHORISED_ROLE = ERROR_UNAUTHORISED_ROLE; -exports.ERROR_UNAUTHORISED_USER_ID = ERROR_UNAUTHORISED_USER_ID; -exports.AUTHORIZATION = AUTHORIZATION; -exports.authorise = authorise; +export { + ERROR_TOKEN_MISSING, + ERROR_UNAUTHORISED_ROLE, + ERROR_UNAUTHORISED_USER_ID, + AUTHORIZATION, + authorise +}; diff --git a/app/user/user-resolver.js b/app/user/user-resolver.js index bc35781ba..31b885986 100644 --- a/app/user/user-resolver.js +++ b/app/user/user-resolver.js @@ -1,14 +1,14 @@ -const config = require('config'); -const fetch = require('../util/fetch'); -const jwtUtil = require('../util/jwt'); +import config from 'config'; +import fetch from '../util/fetch.js'; +import {addBearer} from '../util/jwt.js'; const getUserDetails = (jwt) => { return fetch(`${config.get('idam.base_url')}/o/userinfo`, { headers: { - 'Authorization': jwtUtil.addBearer(jwt) + 'Authorization': addBearer(jwt) } }) .then(res => res.json()); }; -exports.getUserDetails = getUserDetails; +export {getUserDetails}; diff --git a/app/util/fetch.js b/app/util/fetch.js index 88eb39189..db25d5e73 100644 --- a/app/util/fetch.js +++ b/app/util/fetch.js @@ -1,4 +1,4 @@ -let _fetch = require('node-fetch').default; +import {default as _fetch} from 'node-fetch'; const fetch = (...args) => { return _fetch(...args) @@ -12,4 +12,4 @@ const fetch = (...args) => { }); }; -module.exports = fetch; +export default fetch; diff --git a/app/util/jwt.js b/app/util/jwt.js index 54dbdd0e0..2d8f490a4 100644 --- a/app/util/jwt.js +++ b/app/util/jwt.js @@ -8,5 +8,4 @@ const removeBearer = (jwt) => { return jwt.startsWith(BEARER_PREFIX) ? jwt.replace(BEARER_PREFIX, '') : jwt; }; -exports.addBearer = addBearer; -exports.removeBearer = removeBearer; +export {addBearer, removeBearer}; diff --git a/app/util/sanitize.js b/app/util/sanitize.js index be753e4c3..2004c95a4 100644 --- a/app/util/sanitize.js +++ b/app/util/sanitize.js @@ -8,4 +8,5 @@ const sanitizeData = (data) => { return sanitizedData; }; -exports.sanitizeData = sanitizeData; +export {sanitizeData}; + diff --git a/config/test.yaml b/config/test.yaml index 4c179b46b..47396a4c1 100644 --- a/config/test.yaml +++ b/config/test.yaml @@ -2,6 +2,8 @@ appInsights: enabled: false idam: base_url: http://test-idam:1234 + cache: + user_info_enabled: false secrets: ccd: ccd-api-gateway-oauth2-client-secret: ccd_gateway_secret diff --git a/eslint.config.mjs b/eslint.config.mjs index 525c61347..17738e01a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -46,7 +46,7 @@ export default defineConfig([ codecept_helper: true, }, - ecmaVersion: 2019, + ecmaVersion: 2022, sourceType: "module", }, diff --git a/package.json b/package.json index d21131663..3bc79e2e2 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,9 @@ "setup": "cross-env NODE_PATH=. yarn node --version", "lint": "NODE_PATH=. eslint --ignore-pattern coverage .", "test": "yarn test:unit", - "test:unit": "NODE_ENV=test NODE_PATH=. mocha 'test/**/*.spec.js' --reporter mochawesome --reporter-options reportFilename=unit,inlineAssets=true,reportTitle=ccd-api-gateway --recursive", + "test:unit": "NODE_ENV=test NODE_PATH=. mocha --loader=esmock 'test/**/*.spec.js' --reporter mochawesome --reporter-options reportFilename=unit,inlineAssets=true,reportTitle=ccd-api-gateway --recursive", "test:specific": "NODE_ENV=test LOG_LEVEL=DEBUG NODE_PATH=. mocha --recursive ", - "test:coverage": "NODE_ENV=test NODE_PATH=. nyc mocha 'test/**/*.spec.js' --recursive", + "test:coverage": "NODE_ENV=test NODE_PATH=. c8 mocha 'test/**/*.spec.js' --recursive", "fortifyScan": "./test/java/gradlew -p test/java fortifyScan", "test:smoke": "echo SMOKE TESTS NOT CONFIGURED", "test:functional": "echo 'TODO - FUNCTIONAL TEST'", @@ -54,10 +54,12 @@ "otp": "^0.1.3" }, "devDependencies": { + "c8": "^11.0.0", "chai": "^6.2.2", "chai-http": "^5.1.2", "eslint": "^10.4.1", "eslint-plugin-mocha": "^11.3.0", + "esmock": "^2.7.6", "fetch-mock": "^12.6.0", "git-message": "^2.0.2", "istanbul": "^0.4.5", @@ -76,12 +78,10 @@ "sonar-scanner": "^3.1.0", "supertest": "^7.2.2" }, - "nyc": { - "extension": [ - ".js" - ], + "type": "module", + "c8": { "include": [ - "app" + "app/**/*.js" ], "reporter": [ "lcov", @@ -90,5 +90,8 @@ "report-dir": "coverage", "all": true }, + "resolutions": { + "@opentelemetry/core": "2.8.0" + }, "packageManager": "yarn@4.17.0" } diff --git a/server.js b/server.js index a05bf3834..1f0238346 100644 --- a/server.js +++ b/server.js @@ -3,22 +3,29 @@ /** * Module dependencies. */ +import config from 'config'; +import * as propertiesVolume from '@hmcts/properties-volume'; +import debugLib from 'debug'; +import http from 'node:http'; +import https from 'node:https'; +import path from 'node:path'; +import fs from 'node:fs'; +import log from '@hmcts/nodejs-logging'; +import { fileURLToPath } from 'node:url'; + +const debug = debugLib('ccd-api-gateway-web:server'); +const logger = log.Logger.getLogger('server'); +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); -require('@hmcts/properties-volume').addTo(require('config')); -let app = require('./app'); -let debug = require('debug')('ccd-api-gateway-web:server'); -let http = require('http'); -let https = require('https'); -let path = require('path'); -let fs = require('fs'); -let log = require('@hmcts/nodejs-logging'); -const logger = log.Logger.getLogger('server'); +propertiesVolume.addTo(config); +const { default: app } = await import('./app.js'); + /** * Get port from environment and store in Express. */ - -let port = normalizePort(process.env.PORT || '3453'); +const port = normalizePort(process.env.PORT || '3453'); logger.info('Starting on port ' + port); app.set('port', port); @@ -26,7 +33,7 @@ app.set('port', port); * Create HTTPS or HTTP server, depending on whether this is a local dev environment or not. */ -let server = createServer(app); +const server = createServer(app); function createServer(app) { if (process.env.ENV === 'localdev') { @@ -56,9 +63,9 @@ server.on('listening', onListening); */ function normalizePort(val) { - let port = parseInt(val, 10); + const port = Number.parseInt(val, 10); - if (isNaN(port)) { + if (Number.isNaN(port)) { // named pipe return val; } @@ -80,7 +87,7 @@ function onError(error) { throw error; } - let bind = typeof port === 'string' + const bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port; @@ -104,8 +111,8 @@ function onError(error) { */ function onListening() { - let addr = server.address(); - let bind = typeof addr === 'string' + const addr = server.address(); + const bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port; debug('Listening on ' + bind); diff --git a/test/address/address-lookup.spec.js b/test/address/address-lookup.spec.js index 5346bf1ae..cdd128594 100644 --- a/test/address/address-lookup.spec.js +++ b/test/address/address-lookup.spec.js @@ -1,16 +1,11 @@ -const chai = require('chai'); -const expect = chai.expect; -const nock = require('nock'); +import {expect} from 'chai'; +import nock from 'nock'; +import addressLookup from '../../app/address/address-lookup.js'; /* The line below turns off ESLints 'no-undef' for the chai 'fail' function */ /*global fail */ describe('Address Lookup', () => { - let addressLookup; - - before(function(){ - addressLookup = require('../../app/address/address-lookup'); - }); it('Should expect key and postcode placed holders to be substituted', (done) => { diff --git a/test/app-insights/app-insights.spec.js b/test/app-insights/app-insights.spec.js index c071f40f2..6c1d99e88 100644 --- a/test/app-insights/app-insights.spec.js +++ b/test/app-insights/app-insights.spec.js @@ -1,6 +1,5 @@ -const chai = require('chai'); -const expect = chai.expect; -const enableAppInsights = require('../../app/app-insights/app-insights'); +import {expect} from 'chai'; +import enableAppInsights from '../../app/app-insights/app-insights.js'; describe('Application insights', () => { it('should initialize properly', () => { diff --git a/test/cache/cache-service.spec.js b/test/cache/cache-service.spec.js index 0cbcfb3f3..9d05a61b6 100644 --- a/test/cache/cache-service.spec.js +++ b/test/cache/cache-service.spec.js @@ -1,12 +1,14 @@ -const chai = require('chai'); -const expect = chai.expect; -const proxyquire = require('proxyquire'); -const sinon = require('sinon'); +import {expect} from 'chai'; +import * as chai from 'chai'; +import esmock from 'esmock'; +import sinon from 'sinon'; + const assert = sinon.assert; -const sinonChai = require('sinon-chai').default; +import sinonChai from 'sinon-chai'; + chai.use(sinonChai); -const CacheService = require('../../app/cache/cache-service'); -const NodeCache = require('node-cache'); +import CacheService from '../../app/cache/cache-service.js'; +import NodeCache from 'node-cache'; describe('CacheService', () => { const KEY = 'Key'; @@ -29,7 +31,7 @@ describe('CacheService', () => { let cache; - beforeEach(() => { + beforeEach(async () => { getStub = sinon.stub(); setStub = sinon.stub(); deleteStub = sinon.stub(); @@ -40,10 +42,12 @@ describe('CacheService', () => { del: deleteStub }; - nodeCacheSpy = sinon.spy(function () { return MockNodeCache; }); + nodeCacheSpy = sinon.spy(function () { + return MockNodeCache; + }); - StubbedCacheService = proxyquire('../../app/cache/cache-service', { 'node-cache': nodeCacheSpy }); - cache = new StubbedCacheService('TestCache', CACHE_TTL_SECONDS, 120); + StubbedCacheService = await esmock('../../app/cache/cache-service', {'node-cache': nodeCacheSpy}); + cache = new StubbedCacheService('TestCache', CACHE_TTL_SECONDS, 120); }); it('should get existing cache value', async () => { diff --git a/test/health/health-check.spec.js b/test/health/health-check.spec.js index 84e420bde..8581981c3 100644 --- a/test/health/health-check.spec.js +++ b/test/health/health-check.spec.js @@ -1,7 +1,6 @@ -const chai = require('chai'); -const expect = chai.expect; -const request = require('supertest'); -const app = require('app'); +import {expect} from 'chai'; +import request from 'supertest'; +import app from '../../app.js'; describe('health check', () => { @@ -25,19 +24,19 @@ describe('health check', () => { it('should return 200 OK for liveness health check', async () => { await request(app) - .get('/health/liveness') - .expect(res => { - expect(res.status).equal(200); - expect(res.body.status).equal('UP'); - }); + .get('/health/liveness') + .expect(res => { + expect(res.status).equal(200); + expect(res.body.status).equal('UP'); + }); }); it('should return 200 OK for readiness health check', async () => { await request(app) - .get('/health/readiness') - .expect(res => { - expect(res.status).equal(200); - expect(res.body.status).equal('UP'); - }); + .get('/health/readiness') + .expect(res => { + expect(res.status).equal(200); + expect(res.body.status).equal('UP'); + }); }); }); diff --git a/test/oauth2/access-token-request.spec.js b/test/oauth2/access-token-request.spec.js index 7a31ed620..7581134bd 100644 --- a/test/oauth2/access-token-request.spec.js +++ b/test/oauth2/access-token-request.spec.js @@ -1,11 +1,11 @@ -const chai = require('chai'); -const expect = chai.expect; -const proxyquire = require('proxyquire'); -const sinon = require('sinon'); +import * as chai from 'chai'; +import {expect} from 'chai'; +import esmock from 'esmock'; +import sinon from 'sinon'; const assert = sinon.assert; -const sinonChai = require('sinon-chai').default; -const sinonExpressMock = require('sinon-express-mock'); -const url = require('url'); +import sinonChai from 'sinon-chai'; +import sinonExpressMock from 'sinon-express-mock'; +import url from 'url'; chai.use(sinonChai); describe('Access Token Request', () => { @@ -58,35 +58,35 @@ describe('Access Token Request', () => { let accessTokenRequest; let unsuccessfulAccessTokenRequest; - beforeEach(() => { + beforeEach(async () => { config = { get: sinon.stub() }; successStub = sinon.stub(); fetch = { - default: successStub.callsFake(function(...args) { + default: successStub.callsFake(function (...args) { let requestedUrl = url.parse(args[0], true); expect(requestedUrl.query.code).to.equal(AUTH_CODE); expect(requestedUrl.query.redirect_uri).to.equal(REDIRECT_URL); return Promise.resolve(SUCCESSFUL_RESPONSE); }) }; - accessTokenRequest = proxyquire('../../app/oauth2/access-token-request', { + accessTokenRequest = await esmock('../../app/oauth2/access-token-request.js', { 'config': config, 'node-fetch': fetch }); unsuccessfulStub = sinon.stub(); unsuccessfulFetch = { - default: unsuccessfulStub.callsFake(function(...args) { + default: unsuccessfulStub.callsFake(function (...args) { let requestedUrl = url.parse(args[0], true); expect(requestedUrl.query.code).to.equal(AUTH_CODE); expect(requestedUrl.query.redirect_uri).to.equal(REDIRECT_URL); return Promise.resolve(UNSUCCESSFUL_RESPONSE); }) }; - unsuccessfulAccessTokenRequest = proxyquire('../../app/oauth2/access-token-request', { + unsuccessfulAccessTokenRequest = await esmock('../../app/oauth2/access-token-request.js', { 'config': config, 'node-fetch': unsuccessfulFetch }); diff --git a/test/oauth2/logout-route.spec.js b/test/oauth2/logout-route.spec.js index bab75c617..ef40ce2fb 100644 --- a/test/oauth2/logout-route.spec.js +++ b/test/oauth2/logout-route.spec.js @@ -1,14 +1,17 @@ -const chai = require('chai'); -const proxyquire = require('proxyquire'); -const sinon = require('sinon'); +import * as chai from 'chai'; +import {expect} from 'chai'; +import esmock from 'esmock'; +import sinon from 'sinon'; const assert = sinon.assert; -const expect = chai.expect; -const sinonChai = require('sinon-chai').default; -const sinonExpressMock = require('sinon-express-mock'); -const ACCESS_TOKEN_COOKIE_NAME = require('../../app/oauth2/oauth2-route').COOKIE_ACCESS_TOKEN; +import sinonChai from 'sinon-chai'; +import sinonExpressMock from 'sinon-express-mock'; +import {COOKIE_ACCESS_TOKEN} from '../../app/oauth2/oauth2-route.js'; chai.use(sinonChai); -const nock = require('nock'); -const CacheService = require('../../app/cache/cache-service'); +import nock from 'nock'; +import CacheService from '../../app/cache/cache-service.js'; + +let logoutRoute; +let getCachedUserDetails; describe('logoutRoute', () => { const CLIENT_ID = 'ccd_gateway'; @@ -22,27 +25,25 @@ describe('logoutRoute', () => { let response; let next; let config; - let logoutRoute; let fetchStub; let fetch; let userInfoCacheSpy; let sandbox; let clock; + let userInfoCacheInstance; - let cachedUserResolver; - let userInfoCache; - - beforeEach(() => { + beforeEach(async () => { config = { get: sinon.stub() }; sandbox = sinon.createSandbox(); clock = sandbox.useFakeTimers(); - userInfoCache = new CacheService('UserInfoCache', CACHE_TTL_SECONDS, 120); - userInfoCacheSpy = sandbox.spy(userInfoCache, 'getOrElseUpdate'); - cachedUserResolver = proxyquire('../../app/user/cached-user-resolver', { - '../cache/cache-config': { userInfoCache } - }); + userInfoCacheInstance = new CacheService('UserInfoCache', CACHE_TTL_SECONDS, 120); + userInfoCacheSpy = sandbox.spy(userInfoCacheInstance, 'getOrElseUpdate'); + + ({ getCachedUserDetails } = await esmock('../../app/user/cached-user-resolver.js', { + '../../app/cache/cache-config.js': { userInfoCache: () => userInfoCacheInstance } + })); config.get.withArgs('idam.oauth2.client_id').returns(CLIENT_ID); config.get.withArgs('secrets.ccd.ccd-api-gateway-oauth2-client-secret').returns(CLIENT_SECRET); @@ -50,7 +51,7 @@ describe('logoutRoute', () => { request = sinonExpressMock.mockReq({ cookies: { - [ACCESS_TOKEN_COOKIE_NAME]: ACCESS_TOKEN + [COOKIE_ACCESS_TOKEN]: ACCESS_TOKEN } }); response = sinonExpressMock.mockRes(); @@ -58,15 +59,16 @@ describe('logoutRoute', () => { fetchStub = sinon.stub(); fetch = { - default: fetchStub.callsFake(function() { + default: fetchStub.callsFake(function () { return Promise.resolve({}); }) }; - logoutRoute = proxyquire('../../app/oauth2/logout-route', { + const result = await esmock('../../app/oauth2/logout-route.js', { 'config': config, 'node-fetch': fetch - }).logoutRoute; + }); + logoutRoute = result.logoutRoute; }); afterEach(() => { @@ -76,18 +78,18 @@ describe('logoutRoute', () => { assert.fail('Not all nock interceptors have completed'); } }); - + it('should call IDAM OAuth 2 logout endpoint with JWT token', done => { response.status.callsFake(() => { try { assert.calledWith(fetchStub, LOGOUT_END_POINT.replace(':token', ACCESS_TOKEN)); assert.notCalled(next); - assert.calledWith(response.clearCookie, ACCESS_TOKEN_COOKIE_NAME); + assert.calledWith(response.clearCookie, COOKIE_ACCESS_TOKEN); - cachedUserResolver.getUserDetails(TOKEN); clock.tick(CACHE_TTL_SECONDS * 1000 + 1); - const result = cachedUserResolver.getUserDetails(TOKEN); + getCachedUserDetails(TOKEN); + const result = getCachedUserDetails(TOKEN); expect(JSON.stringify(result)).to.equal('{}'); assert.calledWith(userInfoCacheSpy, TOKEN, sinon.match.func); assert.calledTwice(userInfoCacheSpy); diff --git a/test/oauth2/oauth2-route.spec.js b/test/oauth2/oauth2-route.spec.js index 9830bd813..c5a7e23f1 100644 --- a/test/oauth2/oauth2-route.spec.js +++ b/test/oauth2/oauth2-route.spec.js @@ -1,10 +1,10 @@ -const chai = require('chai'); -const proxyquire = require('proxyquire'); -const expect = chai.expect; -const sinon = require('sinon'); -const sinonChai = require('sinon-chai').default; -const sinonExpressMock = require('sinon-express-mock'); -const ACCESS_TOKEN_COOKIE_NAME = require('../../app/oauth2/oauth2-route').COOKIE_ACCESS_TOKEN; +import * as chai from 'chai'; +import {expect} from 'chai'; +import esmock from 'esmock'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import sinonExpressMock from 'sinon-express-mock'; +import {COOKIE_ACCESS_TOKEN} from '../../app/oauth2/oauth2-route.js'; const assert = sinon.assert; chai.use(sinonChai); @@ -22,7 +22,7 @@ describe('oauth2Route', () => { let oauth2Route; let responseFromPromiseMock; - beforeEach(() => { + beforeEach(async () => { config = { get: sinon.stub() @@ -38,10 +38,11 @@ describe('oauth2Route', () => { accessTokenRequest = sinon.stub(); accessTokenRequest.withArgs(request).returns(Promise.resolve(responseFromPromiseMock)); - oauth2Route = proxyquire('../../app/oauth2/oauth2-route', { - './access-token-request': accessTokenRequest, - 'config': config - }).oauth2Route; + const oauth2Module = await esmock('../../app/oauth2/oauth2-route.js', { + '../../app/oauth2/access-token-request.js': { default: accessTokenRequest }, + config: { default: config } + }); + oauth2Route = oauth2Module.oauth2Route; }); it('should set an accessToken cookie with the "secure" flag enabled', done => { @@ -54,7 +55,7 @@ describe('oauth2Route', () => { assert.calledWith(accessTokenRequest, request); assert.calledWith(config.get, 'security.secure_auth_cookie_enabled'); - assert.calledWith(response.cookie, ACCESS_TOKEN_COOKIE_NAME, TOKEN.access_token, + assert.calledWith(response.cookie, COOKIE_ACCESS_TOKEN, TOKEN.access_token, { maxAge: TOKEN.expires_in * 1000, httpOnly: true, secure: true }); assert.calledWith(response.status, 204); done(); @@ -75,8 +76,8 @@ describe('oauth2Route', () => { try { assert.calledWith(accessTokenRequest, request); assert.calledWith(config.get, 'security.secure_auth_cookie_enabled'); - assert.calledWith(response.cookie, ACCESS_TOKEN_COOKIE_NAME, TOKEN.access_token, - { maxAge: TOKEN.expires_in * 1000, httpOnly: true, secure: false }); + assert.calledWith(response.cookie, COOKIE_ACCESS_TOKEN, TOKEN.access_token, + {maxAge: TOKEN.expires_in * 1000, httpOnly: true, secure: false}); assert.calledWith(response.status, 204); done(); } catch (e) { @@ -88,31 +89,26 @@ describe('oauth2Route', () => { }); it('should fail to obation an accessToken dude to unauthorized request.', done => { + const expectedError = { status: 502, message: 'Internal Server Error' }; - let expectedError = { - status: 502, - message: 'Internal Server Error' - }; - - let unauthorizedAccessTokenRequest = sinon.stub(); + const unauthorizedAccessTokenRequest = sinon.stub(); unauthorizedAccessTokenRequest.withArgs(request).returns(Promise.resolve(expectedError)); - let unauthorizedOauth2Route = proxyquire('../../app/oauth2/oauth2-route', { - './access-token-request': unauthorizedAccessTokenRequest, - 'config': config - }).oauth2Route; - - next.callsFake((result) => { - try { - - assert.calledWith(unauthorizedAccessTokenRequest, request); - expect(result).to.deep.equal(expectedError); - done(); - } catch (e) { - done(e); - } - }); - - unauthorizedOauth2Route(request, response, next); + esmock('../../app/oauth2/oauth2-route.js', { + '../../app/oauth2/access-token-request.js': { default: unauthorizedAccessTokenRequest }, + config: { default: config } + }).then(({ oauth2Route: unauthorizedOauth2Route }) => { + next.callsFake((result) => { + try { + assert.calledWith(unauthorizedAccessTokenRequest, request); + expect(result).to.deep.equal(expectedError); + done(); + } catch (e) { + done(e); + } + }); + + unauthorizedOauth2Route(request, response, next); + }).catch(done); }); }); diff --git a/test/security/cors.spec.js b/test/security/cors.spec.js index 6537e5a20..cbe91865d 100644 --- a/test/security/cors.spec.js +++ b/test/security/cors.spec.js @@ -1,9 +1,10 @@ -const chai = require('chai'); -const proxyquire = require('proxyquire'); -const sinon = require('sinon'); +import * as chai from 'chai'; +import esmock from 'esmock'; +import sinon from 'sinon'; + const assert = sinon.assert; -const sinonChai = require('sinon-chai').default; -const sinonExpressMock = require('sinon-express-mock'); +import sinonChai from 'sinon-chai'; +import sinonExpressMock from 'sinon-express-mock'; chai.use(sinonChai); describe('CORS', () => { @@ -15,7 +16,7 @@ describe('CORS', () => { let config, req, res, next, handleCors; - beforeEach(() => { + beforeEach(async () => { config = { get: sinon.stub() }; @@ -30,7 +31,7 @@ describe('CORS', () => { res = sinonExpressMock.mockRes({}); next = sinon.stub(); - handleCors = proxyquire('../../app/security/cors', { + handleCors = await esmock('../../app/security/cors.js', { 'config': config }); }); diff --git a/test/security/hsts.spec.js b/test/security/hsts.spec.js index 95897774f..fd9927c9f 100644 --- a/test/security/hsts.spec.js +++ b/test/security/hsts.spec.js @@ -1,9 +1,9 @@ -const chai = require('chai'); -const proxyquire = require('proxyquire'); -const sinon = require('sinon'); +import * as chai from 'chai'; +import esmock from 'esmock'; +import sinon from 'sinon'; const assert = sinon.assert; -const sinonChai = require('sinon-chai').default; -const sinonExpressMock = require('sinon-express-mock'); +import sinonChai from 'sinon-chai'; +import sinonExpressMock from 'sinon-express-mock'; chai.use(sinonChai); describe('HSTS', () => { @@ -12,12 +12,12 @@ describe('HSTS', () => { let req, res, next, handleHSTS; - beforeEach(() => { + beforeEach(async () => { req = sinonExpressMock.mockReq(); res = sinonExpressMock.mockRes({}); next = sinon.stub(); - handleHSTS = proxyquire('../../app/security/hsts', {}); + handleHSTS = await esmock('../../app/security/hsts.js', {}); }); it('should add STS header to response for data request', () => { diff --git a/test/service/service-filter.spec.js b/test/service/service-filter.spec.js index dd6751eb7..e4e05ca0e 100644 --- a/test/service/service-filter.spec.js +++ b/test/service/service-filter.spec.js @@ -1,13 +1,12 @@ -const proxyquire = require('proxyquire'); -const chai = require('chai'); -const expect = chai.expect; +import { expect } from 'chai'; +import esmock from 'esmock'; describe('service filter', () => { const request = {}; const reply = {}; - it('should return a 500 error in case of FetchError', done => { + it('should return a 500 error in case of FetchError', async () => { const EnotFoundError = () => { return Promise.reject({ name: 'FetchError', @@ -15,51 +14,69 @@ describe('service filter', () => { }); }; - const serviceFilter = proxyquire('../../app/service/service-filter', + const serviceFilter = await esmock('../../app/service/service-filter.js', { - './service-token-generator': EnotFoundError + '../../app/service/service-token-generator.js': {default : EnotFoundError} }); + await new Promise((resolve, reject) => { serviceFilter(request, reply, (error) => { - expect(error.status).to.equal(500); - expect(error.error).to.equal('Internal Server Error'); - expect(error.message).to.equal('some error'); - done(); + try { + expect(error.status).to.equal(500); + expect(error.error).to.equal('Internal Server Error'); + expect(error.message).to.equal('some error'); + resolve(); + } catch (assertionError) { + reject(assertionError); + } }); + }); }); - it('should return a 401 error when s2s call fails with no error status', done => { + it('should return a 401 error when s2s call fails with no error status', async () => { const ErrorWithNoStatus = () => { return Promise.reject({}); }; - const serviceFilter = proxyquire('../../app/service/service-filter', + const serviceFilter = await esmock('../../app/service/service-filter.js', { - './service-token-generator': ErrorWithNoStatus + '../../app/service/service-token-generator.js': ErrorWithNoStatus }); + await new Promise((resolve, reject) => { serviceFilter(request, reply, (error) => { - expect(error.status).to.equal(401); - done(); + try { + expect(error.status).to.equal(401); + resolve(); + } catch (assertionError) { + reject(assertionError); + } }); + }); }); - it('should return the error status when s2s call fails with an error status', done => { + it('should return the error status when s2s call fails with an error status', async () => { const ErrorWithStatus = () => { return Promise.reject({ status: 502 }); }; - const serviceFilter = proxyquire('../../app/service/service-filter', + const serviceFilter = await esmock('../../app/service/service-filter.js', { - './service-token-generator': ErrorWithStatus + '../../app/service/service-token-generator.js': ErrorWithStatus }); + await new Promise((resolve, reject) => { serviceFilter(request, reply, (error) => { - expect(error.status).to.equal(502); - done(); + try { + expect(error.status).to.equal(502); + resolve(); + } catch (assertionError) { + reject(assertionError); + } }); + }); }); diff --git a/test/service/service-payloadGuard.spec.js b/test/service/service-payloadGuard.spec.js new file mode 100644 index 000000000..c04b1fa11 --- /dev/null +++ b/test/service/service-payloadGuard.spec.js @@ -0,0 +1,208 @@ +import { expect } from 'chai'; +import esmock from 'esmock'; +import sinon from 'sinon'; + +// Use CommonJS import for the ES module +describe('service-payloadGuard middleware', () => { + let payloadGuard, LoggerStub, loggerStub; + + beforeEach(async () => { + loggerStub = {warn: sinon.spy(), info: sinon.spy()}; + LoggerStub = {getLogger: sinon.stub().returns(loggerStub)}; + payloadGuard = await esmock('../../app/service/service-payloadGuard.js', { + '@hmcts/nodejs-logging': {Logger: LoggerStub} + }); + }); + + function mockReqRes(method, opts = {}) { + const req = { + method, + headers: opts.headers || {}, + url: opts.url || '/test', + body: opts.body + }; + const res = { + status: sinon.stub().returnsThis(), + json: sinon.stub().returnsThis() + }; + const next = sinon.spy(); + return { req, res, next }; + } + + it('should call next for allowed content-type', () => { + const { req, res, next } = mockReqRes('POST', { + headers: { 'content-type': 'application/json' }, + body: { foo: 'bar' } + }); + payloadGuard()(req, res, next); + expect(next.calledOnce).to.be.true; + }); + + it('should reject unsupported content-type on POST', () => { + const { req, res, next } = mockReqRes('POST', { + headers: { 'content-type': 'text/plain' }, + body: { foo: 'bar' } + }); + payloadGuard()(req, res, next); + expect(res.status.calledWith(415)).to.be.true; + expect(res.json.calledWithMatch({ error: 'Unsupported Media Type' })).to.be.true; + expect(next.notCalled).to.be.true; + }); + + it('should reject unsupported content-type on PUT', () => { + const { req, res, next } = mockReqRes('PUT', { + headers: { 'content-type': 'text/plain' }, + body: { name: 'name' } + }); + payloadGuard()(req, res, next); + expect(res.status.calledWith(415)).to.be.true; + }); + + + it('should reject unsupported content-type on PATCH', () => { + const { req, res, next } = mockReqRes('PATCH', { + headers: { 'content-type': 'text/plain' }, + body: { age: 'age' } + }); + payloadGuard()(req, res, next); + expect(res.status.calledWith(415)).to.be.true; + }); + + it('should reject path traversal attempts', () => { + const { req, res, next } = mockReqRes('POST', { + url: '/../etc/passwd', + headers: { 'content-type': 'application/json', host: 'localhost' }, + body: { foo: 'bar' } + }); + payloadGuard()(req, res, next); + expect(res.status.calledWith(400)).to.be.true; + expect(res.json.calledWithMatch({ message: 'Invalid path' })).to.be.true; + expect(next.notCalled).to.be.true; + }); + + it('should reject payloads that are too large or deeply nested', () => { + // Create a deeply nested object + let obj = {}; + let cur = obj; + for (let i = 0; i < 50; i++) { + cur.nest = {}; + cur = cur.nest; + } + const { req, res, next } = mockReqRes('POST', { + headers: { 'content-type': 'application/json' }, + body: obj + }); + payloadGuard()(req, res, next); + expect(res.status.calledWith(413)).to.be.true; + expect(res.json.calledWithMatch({ message: 'Payload too large or too deeply nested' })).to.be.true; + expect(next.notCalled).to.be.true; + }); + + it('should reject payloads with arrays exceeding maxArrayLength', () => { + const { req, res, next } = mockReqRes('POST', { + headers: { 'content-type': 'application/json' }, + body: { items: new Array(10001).fill('x') } + }); + payloadGuard()(req, res, next); + expect(res.status.calledWith(413)).to.be.true; + expect(res.json.calledWithMatch({ message: 'Payload too large or too deeply nested' })).to.be.true; + expect(next.notCalled).to.be.true; + }); + + it('should reject payloads containing ' } + }); + payloadGuard()(req, res, next); + expect(res.status.calledWith(400)).to.be.true; + expect(res.json.calledWithMatch({ message: 'Payload contains disallowed content' })).to.be.true; + expect(next.notCalled).to.be.true; + }); + + it('should reject payloads containing javascript', () => { + const { req, res, next } = mockReqRes('POST', { + headers: { 'content-type': 'application/json' }, + body: { url: 'javascript:alert(1)' } + }); + payloadGuard()(req, res, next); + expect(res.status.calledWith(400)).to.be.true; + expect(res.json.calledWithMatch({ message: 'Payload contains disallowed content' })).to.be.true; + }); + + it('should reject script tags in nested body objects', () => { + const { req, res, next } = mockReqRes('POST', { + headers: { 'content-type': 'application/json' }, + body: { a: { b: { c: '' } } } + }); + payloadGuard()(req, res, next); + expect(res.status.calledWith(400)).to.be.true; + }); + + it('should call next for non-POST/PUT/PATCH methods', () => { + const { req, res, next } = mockReqRes('GET', { + headers: { 'content-type': 'application/json' }, + body: { foo: 'bar' } + }); + payloadGuard()(req, res, next); + expect(next.calledOnce).to.be.true; + }); + + it('should call next for application/json with charset', () => { + const { req, res, next } = mockReqRes('POST', { + headers: { 'content-type': 'application/json; charset=utf-8' }, + body: { foo: 'bar' } + }); + payloadGuard()(req, res, next); + expect(next.calledOnce).to.be.true; + }); + + it('should respect custom maxArrayLength option', () => { + const { req, res, next } = mockReqRes('POST', { + headers: { 'content-type': 'application/json' }, + body: { items: new Array(5).fill('x') } + }); + payloadGuard({ maxArrayLength: 3 })(req, res, next); + expect(res.status.calledWith(413)).to.be.true; + }); + + it('should reject percent-encoded path traversal', () => { + const { req, res, next } = mockReqRes('POST', { + url: '/%2e%2e/etc/passwd', + headers: { 'content-type': 'application/json', host: 'localhost' }, + body: {} + }); + payloadGuard()(req, res, next); + expect(res.status.calledWith(400)).to.be.true; + expect(res.json.calledWithMatch({ message: 'Invalid path' })).to.be.true; + }); + + it('should not reject path traversal when rejectPathTraversal is false', () => { + const { req, res, next } = mockReqRes('POST', { + url: '/../etc/passwd', + headers: { 'content-type': 'application/json', host: 'localhost' }, + body: {} + }); + payloadGuard({ rejectPathTraversal: false })(req, res, next); + expect(next.calledOnce).to.be.true; + }); + + it('should not reject script tags when rejectObviousScriptTags is false', () => { + const { req, res, next } = mockReqRes('POST', { + headers: { 'content-type': 'application/json' }, + body: { foo: '' } + }); + payloadGuard({ rejectObviousScriptTags: false })(req, res, next); + expect(next.calledOnce).to.be.true; + }); + + it('should skip content-type check when allowContentTypes is empty', () => { + const { req, res, next } = mockReqRes('POST', { + headers: { 'content-type': 'text/xml' }, + body: {} + }); + payloadGuard({ allowContentTypes: [] })(req, res, next); + expect(next.calledOnce).to.be.true; + }); + +}); diff --git a/test/service/service-token-generator.spec.js b/test/service/service-token-generator.spec.js index 6f5f3e005..9191c0753 100644 --- a/test/service/service-token-generator.spec.js +++ b/test/service/service-token-generator.spec.js @@ -1,16 +1,15 @@ -const chai = require('chai'); -const expect = chai.expect; -const proxyquire = require('proxyquire'); -const sinon = require('sinon'); -const nock = require('nock'); -const jwt = require('jsonwebtoken'); -const moment = require('moment'); +import { expect } from 'chai'; +import esmock from 'esmock'; +import sinon from 'sinon'; +import nock from 'nock'; +import jwt from 'jsonwebtoken'; +import moment from 'moment'; describe('service token generator', () => { let serviceTokenGenerator; - beforeEach(() => { + beforeEach(async () => { let config = { get: sinon.stub() }; @@ -18,11 +17,11 @@ describe('service token generator', () => { config.get.withArgs('idam.s2s_url').returns('http://localhost:9999'); config.get.withArgs('appInsights.enabled').returns(false); - serviceTokenGenerator = proxyquire('../../app/service/service-token-generator', { + serviceTokenGenerator = await esmock('../../app/service/service-token-generator', { 'config': config }); - proxyquire('../../app/app-insights/app-insights', { + await esmock('../../app/app-insights/app-insights', { 'config': config }); }); diff --git a/test/user/auth-checker-user-only-filter.spec.js b/test/user/auth-checker-user-only-filter.spec.js index 76d9427c5..1c0ffeaa2 100644 --- a/test/user/auth-checker-user-only-filter.spec.js +++ b/test/user/auth-checker-user-only-filter.spec.js @@ -1,8 +1,8 @@ -const chai = require('chai'); -const expect = chai.expect; -const proxyquire = require('proxyquire'); -const sinon = require('sinon'); -const sinonChai = require('sinon-chai').default; +import * as chai from 'chai'; +import { expect } from 'chai'; +import esmock from 'esmock'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; chai.use(sinonChai); describe('authCheckerUserOnlyFilter', () => { @@ -17,7 +17,7 @@ describe('authCheckerUserOnlyFilter', () => { let userRequestAuthorizer; let filter; - beforeEach(() => { + beforeEach(async () => { req = { headers: {} }; @@ -34,8 +34,8 @@ describe('authCheckerUserOnlyFilter', () => { authorise: sinon.stub() }; - filter = proxyquire('../../app/user/auth-checker-user-only-filter', { - './user-request-authorizer': userRequestAuthorizer + filter = await esmock('../../app/user/auth-checker-user-only-filter.js', { + '../../app/user/user-request-authorizer.js': userRequestAuthorizer }); }); diff --git a/test/user/authorised-roles-extractor.spec.js b/test/user/authorised-roles-extractor.spec.js index f2916f013..59a83d0bc 100644 --- a/test/user/authorised-roles-extractor.spec.js +++ b/test/user/authorised-roles-extractor.spec.js @@ -1,8 +1,7 @@ -const chai = require('chai'); -const expect = chai.expect; +import { expect } from 'chai'; -const authorisedRolesExtractor = require('../../app/user/authorised-roles-extractor'); -const http = require('http'); +import authorisedRolesExtractor from '../../app/user/authorised-roles-extractor.js'; +import http from 'http'; describe('Authorised roles extractor', () => { describe('extract()', () => { @@ -10,7 +9,7 @@ describe('Authorised roles extractor', () => { let request = Object.create(http.IncomingMessage.prototype); request.originalUrl = '/data/'; - let roles = authorisedRolesExtractor.extract(request); + let roles = authorisedRolesExtractor(request); expect(roles.length).to.equal(0); }); @@ -19,7 +18,7 @@ describe('Authorised roles extractor', () => { let request = Object.create(http.IncomingMessage.prototype); request.originalUrl = '/data/caseworkers/5/profile'; - let roles = authorisedRolesExtractor.extract(request); + let roles = authorisedRolesExtractor(request); expect(roles).to.contain('caseworker'); expect(roles.length).to.equal(1); @@ -29,7 +28,7 @@ describe('Authorised roles extractor', () => { let request = Object.create(http.IncomingMessage.prototype); request.originalUrl = '/data/CaseWorkers/5/jurisdiction/TEST/case-types/TestAddressBookCase/cases/7'; - let roles = authorisedRolesExtractor.extract(request); + let roles = authorisedRolesExtractor(request); expect(roles).to.contain('caseworker-test'); expect(roles.length).to.equal(1); diff --git a/test/user/cached-user-resolver.spec.js b/test/user/cached-user-resolver.spec.js index c500e8f99..a147b1cb0 100644 --- a/test/user/cached-user-resolver.spec.js +++ b/test/user/cached-user-resolver.spec.js @@ -1,13 +1,13 @@ -const proxyquire = require('proxyquire'); -const chai = require('chai'); -const expect = chai.expect; -const sinon = require('sinon'); +import * as chai from 'chai'; +import { expect } from 'chai'; +import esmock from 'esmock'; +import sinon from 'sinon'; const assert = sinon.assert; -const sinonChai = require('sinon-chai').default; +import sinonChai from 'sinon-chai'; chai.use(sinonChai); -const nock = require('nock'); -const CacheService = require('../../app/cache/cache-service'); -const NodeCache = require('node-cache'); +import nock from 'nock'; +import CacheService from '../../app/cache/cache-service.js'; +import NodeCache from 'node-cache'; describe('getCachedUserDetails', () => { const TOKEN = 'TOKEN'; @@ -22,9 +22,9 @@ describe('getCachedUserDetails', () => { let clock; let userResolver; let cachedUserResolver; - let userInfoCache; + let userInfoCacheInstance; - beforeEach(() => { + beforeEach(async () => { sandbox = sinon.createSandbox(); userResolver = { getUserDetails: sinon.stub() @@ -35,10 +35,10 @@ describe('getCachedUserDetails', () => { userResolver.getUserDetails.withArgs(ERROR_TOKEN).returns(Promise.reject(new Error('User details error'))); clock = sandbox.useFakeTimers(); nodeCacheSpy = sandbox.spy(NodeCache.prototype, 'set'); - userInfoCache = new CacheService('UserInfoCache', CACHE_TTL_SECONDS, 120); - cachedUserResolver = proxyquire('../../app/user/cached-user-resolver', { - '../cache/cache-config': { userInfoCache }, - './user-resolver': userResolver + userInfoCacheInstance = new CacheService('UserInfoCache', CACHE_TTL_SECONDS, 120); + cachedUserResolver = await esmock('../../app/user/cached-user-resolver.js', { + '../../app/cache/cache-config.js': { userInfoCache: () => userInfoCacheInstance }, + '../../app/user/user-resolver.js': { getUserDetails: userResolver.getUserDetails } }); }); @@ -51,15 +51,15 @@ describe('getCachedUserDetails', () => { }); it('should get user details with no cache', async () => { - const result = await cachedUserResolver.getUserDetails(TOKEN); + const result = await cachedUserResolver.getCachedUserDetails(TOKEN); expect(JSON.stringify(result)).to.equal(JSON.stringify(USER_DETAILS)); assert.calledOnce(nodeCacheSpy); }); it('should get cached user details', async () => { - const firstResult = await cachedUserResolver.getUserDetails(TOKEN); - const cachedResult = await cachedUserResolver.getUserDetails(TOKEN); + const firstResult = await cachedUserResolver.getCachedUserDetails(TOKEN); + const cachedResult = await cachedUserResolver.getCachedUserDetails(TOKEN); expect(JSON.stringify(firstResult)).to.equal(JSON.stringify(cachedResult)); expect(JSON.stringify(cachedResult)).to.equal(JSON.stringify(USER_DETAILS)); @@ -67,8 +67,8 @@ describe('getCachedUserDetails', () => { }); it('should get cached user details with token including Bearer', async () => { - const firstResult = await cachedUserResolver.getUserDetails(TOKEN); - const cachedResult = await cachedUserResolver.getUserDetails(`Bearer ${TOKEN}`); + const firstResult = await cachedUserResolver.getCachedUserDetails(TOKEN); + const cachedResult = await cachedUserResolver.getCachedUserDetails(`Bearer ${TOKEN}`); expect(JSON.stringify(firstResult)).to.equal(JSON.stringify(cachedResult)); expect(JSON.stringify(cachedResult)).to.equal(JSON.stringify(USER_DETAILS)); @@ -76,8 +76,8 @@ describe('getCachedUserDetails', () => { }); it('should get user details with no cache for different tokens', async () => { - const originalResult = await cachedUserResolver.getUserDetails(TOKEN); - const otherResult = await cachedUserResolver.getUserDetails(OTHER_TOKEN); + const originalResult = await cachedUserResolver.getCachedUserDetails(TOKEN); + const otherResult = await cachedUserResolver.getCachedUserDetails(OTHER_TOKEN); expect(JSON.stringify(originalResult)).to.equal(JSON.stringify(USER_DETAILS)); expect(JSON.stringify(otherResult)).to.equal(JSON.stringify(OTHER_DETAILS)); @@ -85,10 +85,10 @@ describe('getCachedUserDetails', () => { }); it('should get new user details after ttl expiry', async () => { - await cachedUserResolver.getUserDetails(TOKEN); + await cachedUserResolver.getCachedUserDetails(TOKEN); clock.tick(CACHE_TTL_SECONDS * 1000 + 1); - const result = await cachedUserResolver.getUserDetails(TOKEN); + const result = await cachedUserResolver.getCachedUserDetails(TOKEN); expect(JSON.stringify(result)).to.equal(JSON.stringify(USER_DETAILS)); assert.calledTwice(nodeCacheSpy); @@ -98,7 +98,7 @@ describe('getCachedUserDetails', () => { let result; let error; try { - result = await cachedUserResolver.getUserDetails(ERROR_TOKEN); + result = await cachedUserResolver.getCachedUserDetails(ERROR_TOKEN); } catch(err) { error = err; } diff --git a/test/user/user-request-authorizer.spec.js b/test/user/user-request-authorizer.spec.js index c8568abeb..8f8045272 100644 --- a/test/user/user-request-authorizer.spec.js +++ b/test/user/user-request-authorizer.spec.js @@ -1,11 +1,12 @@ -const proxyquire = require('proxyquire'); -const chai = require('chai'); -const sinon = require('sinon'); -const expect = chai.expect; -const sinonChai = require('sinon-chai').default; +import * as chai from 'chai'; +import {expect} from 'chai'; +import esmock from 'esmock'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; + chai.use(sinonChai); -const userReqAuth = require('../../app/user/user-request-authorizer'); -const COOKIE_ACCESS_TOKEN = require('../../app/oauth2/oauth2-route').COOKIE_ACCESS_TOKEN; +import {AUTHORIZATION} from '../../app/user/user-request-authorizer.js'; +import {COOKIE_ACCESS_TOKEN} from '../../app/oauth2/oauth2-route.js'; describe('UserRequestAuthorizer', () => { describe('authorize', () => { @@ -25,10 +26,9 @@ describe('UserRequestAuthorizer', () => { let request; let userResolver; let authorizedRolesExtractor; - let userRequestAuthorizer; - beforeEach(() => { + beforeEach(async () => { request = { url: 'http://caseworkers/:uid/more/stuff', originalUrl: 'http://caseworkers/:uid/more/stuff', @@ -36,18 +36,18 @@ describe('UserRequestAuthorizer', () => { cookies: COOKIES }; userResolver = { - getUserDetails: sinon.stub().returns(Promise.resolve(DETAILS)) + getUserDetails: sinon.stub().resolves(DETAILS), + getCachedUserDetails: sinon.stub().resolves(DETAILS) }; + authorizedRolesExtractor = { - extract: sinon.stub() + default: sinon.stub() }; - delete require.cache[require.resolve('../../app/user/user-request-authorizer')]; - - userRequestAuthorizer = proxyquire('../../app/user/user-request-authorizer', { - './cached-user-resolver': userResolver, - './user-resolver': userResolver, - './authorised-roles-extractor': authorizedRolesExtractor + userRequestAuthorizer = await esmock('../../app/user/user-request-authorizer.js', { + '../../app/user/cached-user-resolver.js': userResolver, + '../../app/user/user-resolver.js': userResolver, + '../../app/user/authorised-roles-extractor.js': authorizedRolesExtractor }); }); @@ -76,7 +76,7 @@ describe('UserRequestAuthorizer', () => { }); it('should reject when roles do not match', done => { - authorizedRolesExtractor.extract.returns(['no-match']); + authorizedRolesExtractor.default.returns(['no-match']); userRequestAuthorizer.authorise(request) .then(() => done(new Error('Promise should have been rejected'))) @@ -87,7 +87,7 @@ describe('UserRequestAuthorizer', () => { }); it('should reject when no roles are extracted', done => { - authorizedRolesExtractor.extract.returns([]); + authorizedRolesExtractor.default.returns([]); userRequestAuthorizer.authorise(request) .then(() => done(new Error('Promise should have been rejected'))) @@ -98,7 +98,7 @@ describe('UserRequestAuthorizer', () => { }); it('should fill in user ID placeholder in URL', done => { - authorizedRolesExtractor.extract.returns([ROLE_1]); + authorizedRolesExtractor.default.returns([ROLE_1]); userRequestAuthorizer.authorise(request) .then(() => { @@ -111,7 +111,7 @@ describe('UserRequestAuthorizer', () => { it('should resolve with user details when all checks OK', done => { request.cookies = null; - authorizedRolesExtractor.extract.returns([ROLE_1]); + authorizedRolesExtractor.default.returns([ROLE_1]); userRequestAuthorizer.authorise(request) .then(user => { @@ -123,7 +123,7 @@ describe('UserRequestAuthorizer', () => { it('should NOT reject missing Authorization header when AccessToken cookie present', done => { request.get.returns(null); - authorizedRolesExtractor.extract.returns([ROLE_1]); + authorizedRolesExtractor.default.returns([ROLE_1]); userRequestAuthorizer.authorise(request) .then(() => done()) @@ -135,7 +135,7 @@ describe('UserRequestAuthorizer', () => { it('should use the AccessToken cookie when present, to obtain user details', done => { request.get.returns(null); - authorizedRolesExtractor.extract.returns([ROLE_1]); + authorizedRolesExtractor.default.returns([ROLE_1]); userRequestAuthorizer.authorise(request) .then(() => { @@ -145,19 +145,16 @@ describe('UserRequestAuthorizer', () => { .catch(() => done(new Error('Promise should have been resolved'))); }); - it('should use the AccessToken cookie to set the Authorization header, when the header is missing', done => { + it('should use the AccessToken cookie to set the Authorization header, when the header is missing', async () => { request.get.returns(null); request.headers = {'X_CUSTOM_HEADER': X_CUSTOM_HEADER}; - authorizedRolesExtractor.extract.returns([ROLE_1]); + authorizedRolesExtractor.default.returns([ROLE_1]); - userRequestAuthorizer.authorise(request) - .then(() => { - expect(request.headers).not.to.be.undefined; - expect(request.headers[userReqAuth.AUTHORIZATION]).to.equal('Bearer ' + COOKIES[COOKIE_ACCESS_TOKEN]); - expect(request.headers['X_CUSTOM_HEADER']).to.equal(X_CUSTOM_HEADER); - done(); - }) - .catch(() => done(new Error('Promise should have been resolved'))); + await userRequestAuthorizer.authorise(request); + + expect(request.headers).not.to.be.undefined; + expect(request.headers[AUTHORIZATION]).to.equal('Bearer ' + COOKIES[COOKIE_ACCESS_TOKEN]); + expect(request.headers['X_CUSTOM_HEADER']).to.equal(X_CUSTOM_HEADER); }); it('should NOT check roles and resolve when URL does not contain /caseworkers/', done => { @@ -166,7 +163,7 @@ describe('UserRequestAuthorizer', () => { userRequestAuthorizer.authorise(request) .then(user => { - expect(authorizedRolesExtractor.extract).not.to.have.been.called; + expect(authorizedRolesExtractor.default).not.to.have.been.called; expect(user).to.equal(DETAILS); done(); }) @@ -179,7 +176,7 @@ describe('UserRequestAuthorizer', () => { userRequestAuthorizer.authorise(request) .then(user => { - expect(authorizedRolesExtractor.extract).not.to.have.been.called; + expect(authorizedRolesExtractor.default).not.to.have.been.called; expect(user).to.equal(DETAILS); done(); }) @@ -187,7 +184,7 @@ describe('UserRequestAuthorizer', () => { }); it('should resolve when user has at least one matching role', done => { - authorizedRolesExtractor.extract.returns([ROLE_1, 'other-role']); + authorizedRolesExtractor.default.returns([ROLE_1, 'other-role']); userRequestAuthorizer.authorise(request) .then(user => { @@ -198,7 +195,7 @@ describe('UserRequestAuthorizer', () => { }); describe('static role protected paths', () => { - beforeEach(() => { + beforeEach(async () => { request = { url: '/print/probateManTypes', originalUrl: '/print/probateManTypes', @@ -206,12 +203,10 @@ describe('UserRequestAuthorizer', () => { cookies: COOKIES }; - delete require.cache[require.resolve('../../app/user/user-request-authorizer')]; - - userRequestAuthorizer = proxyquire('../../app/user/user-request-authorizer', { - './cached-user-resolver': userResolver, - './user-resolver': userResolver, - './authorised-roles-extractor': authorizedRolesExtractor + userRequestAuthorizer = await esmock('../../app/user/user-request-authorizer.js', { + '../../app/user/cached-user-resolver.js': userResolver, + '../../app/user/user-resolver.js': userResolver, + '../../app/user/authorised-roles-extractor.js': authorizedRolesExtractor }); }); @@ -274,20 +269,17 @@ describe('UserRequestAuthorizer', () => { }); }); - it('should reject with UNAUTHORISED_ROLE when user has no roles for /print/probateManTypes', done => { - userResolver.getUserDetails.returns(Promise.resolve({ - uid: USER_ID, - roles: [] - })); + it('should reject with UNAUTHORISED_ROLE when user has no roles for /print/probateManTypes', async () => { + userResolver.getUserDetails.resolves({ uid: USER_ID, roles: [] }); - userRequestAuthorizer.authorise(request) - .then(() => done(new Error('Promise should have been rejected'))) - .catch(error => { - expect(error).to.equal(userRequestAuthorizer.ERROR_UNAUTHORISED_ROLE); - expect(error.status).to.equal(403); - expect(error.error).to.equal('Unauthorised role'); - done(); - }); + try { + await userRequestAuthorizer.authorise(request); + throw new Error('Promise should have been rejected'); + } catch (error) { + expect(error).to.equal(userRequestAuthorizer.ERROR_UNAUTHORISED_ROLE); + expect(error.status).to.equal(403); + expect(error.error).to.equal('Unauthorised role'); + } }); it('should resolve for a path that does not match any static protected path', done => { diff --git a/test/util/jwt.spec.js b/test/util/jwt.spec.js index 9cc8bfaa0..840db277c 100644 --- a/test/util/jwt.spec.js +++ b/test/util/jwt.spec.js @@ -1,7 +1,6 @@ -const chai = require('chai'); -const expect = chai.expect; +import {expect} from 'chai'; -const jwtUtil = require('../../app/util/jwt'); +import * as jwtUtil from '../../app/util/jwt.js'; describe('get bearer jwt', () => { it('should return bearer where jwt has prefix', () => { diff --git a/yarn-audit-known-issues b/yarn-audit-known-issues index 288061e14..13994e394 100644 --- a/yarn-audit-known-issues +++ b/yarn-audit-known-issues @@ -1,5 +1,4 @@ -{"value":"@opentelemetry/core","children":{"ID":1120821,"Issue":"OpenTelemetry Core: Unbounded memory allocation in W3C Baggage propagation","URL":"https://github.com/advisories/GHSA-8988-4f7v-96qf","Severity":"moderate","Vulnerable Versions":"<2.8.0","Tree Versions":["2.7.1"],"Dependents":["@opentelemetry/exporter-logs-otlp-http@virtual:f929e7efe4e527b9e7f342a5744200c3a98dadc4585c20076ed75c954b440d663510efc3b6317980affff22ca3411dac18adf9bae8f716421eb973cca60806cb#npm:0.217.0"]}} -{"value":"js-yaml","children":{"ID":1120792,"Issue":"JS-YAML: Quadratic-complexity DoS in merge key handling via repeated aliases","URL":"https://github.com/advisories/GHSA-h67p-54hq-rp68","Severity":"moderate","Vulnerable Versions":"<=4.1.1","Tree Versions":["3.14.2"],"Dependents":["@istanbuljs/load-nyc-config@npm:1.1.0"]}} +{"value":"js-yaml","children":{"ID":1121859,"Issue":"JS-YAML: Quadratic-complexity DoS in merge key handling via repeated aliases","URL":"https://github.com/advisories/GHSA-h67p-54hq-rp68","Severity":"moderate","Vulnerable Versions":"<3.15.0","Tree Versions":["3.14.2"],"Dependents":["@istanbuljs/load-nyc-config@npm:1.1.0"]}} {"value":"mem","children":{"ID":"mem (deprecation)","Issue":"Renamed to memoize: https://www.npmjs.com/package/memoize","Severity":"moderate","Vulnerable Versions":"10.0.0","Tree Versions":["10.0.0"],"Dependents":["ccd-api-gateway-web@workspace:."]}} {"value":"node-domexception","children":{"ID":"node-domexception (deprecation)","Issue":"Use your platform's native DOMException instead","Severity":"moderate","Vulnerable Versions":"1.0.0","Tree Versions":["1.0.0"],"Dependents":["fetch-blob@npm:3.2.0"]}} {"value":"protobufjs","children":{"ID":1118640,"Issue":"protobuf.js: Code injection through bytes field defaults in generated toObject code","URL":"https://github.com/advisories/GHSA-66ff-xgx4-vchm","Severity":"high","Vulnerable Versions":">=8.0.0 <=8.0.1","Tree Versions":["8.0.1"],"Dependents":["@opentelemetry/otlp-transformer@virtual:1b6880599216cbe889955e567228ece139af5b29d0a893c8d13b5f3084faba5c068facdcae34bba7be1a659e74308f639e73aa9f19f4d37f7bf44a55beff324e#npm:0.217.0"]}} diff --git a/yarn.lock b/yarn.lock index 2749422d2..11f8146a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -476,6 +476,13 @@ __metadata: languageName: node linkType: hard +"@bcoe/v8-coverage@npm:^1.0.1": + version: 1.0.2 + resolution: "@bcoe/v8-coverage@npm:1.0.2" + checksum: 10/46600b2dde460269b07a8e4f12b72e418eae1337b85c979f43af3336c9a1c65b04e42508ab6b245f1e0e3c64328e1c38d8cd733e4a7cebc4fbf9cf65c6e59937 + languageName: node + linkType: hard + "@colors/colors@npm:1.6.0": version: 1.6.0 resolution: "@colors/colors@npm:1.6.0" @@ -751,7 +758,7 @@ __metadata: languageName: node linkType: hard -"@jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.28": +"@jridgewell/trace-mapping@npm:^0.3.12, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.28": version: 0.3.31 resolution: "@jridgewell/trace-mapping@npm:0.3.31" dependencies: @@ -884,18 +891,7 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/core@npm:2.7.1": - version: 2.7.1 - resolution: "@opentelemetry/core@npm:2.7.1" - dependencies: - "@opentelemetry/semantic-conventions": "npm:^1.29.0" - peerDependencies: - "@opentelemetry/api": ">=1.0.0 <1.10.0" - checksum: 10/aaec929e1f22b3704e3cb195f8a27b2d9bd0292a565741c00b1e8cde0f3043de41713632fe15e4bfd588d83807a5b10a861deffad173e28787802ede9123ced7 - languageName: node - linkType: hard - -"@opentelemetry/core@npm:2.8.0, @opentelemetry/core@npm:^2.0.0, @opentelemetry/core@npm:^2.7.1": +"@opentelemetry/core@npm:2.8.0": version: 2.8.0 resolution: "@opentelemetry/core@npm:2.8.0" dependencies: @@ -1780,6 +1776,13 @@ __metadata: languageName: node linkType: hard +"@types/istanbul-lib-coverage@npm:^2.0.1": + version: 2.0.6 + resolution: "@types/istanbul-lib-coverage@npm:2.0.6" + checksum: 10/3feac423fd3e5449485afac999dcfcb3d44a37c830af898b689fadc65d26526460bedb889db278e0d4d815a670331796494d073a10ee6e3a6526301fe7415778 + languageName: node + linkType: hard + "@types/json-schema@npm:^7.0.15": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" @@ -2365,6 +2368,32 @@ __metadata: languageName: node linkType: hard +"c8@npm:^11.0.0": + version: 11.0.0 + resolution: "c8@npm:11.0.0" + dependencies: + "@bcoe/v8-coverage": "npm:^1.0.1" + "@istanbuljs/schema": "npm:^0.1.3" + find-up: "npm:^5.0.0" + foreground-child: "npm:^3.1.1" + istanbul-lib-coverage: "npm:^3.2.0" + istanbul-lib-report: "npm:^3.0.1" + istanbul-reports: "npm:^3.1.6" + test-exclude: "npm:^8.0.0" + v8-to-istanbul: "npm:^9.0.0" + yargs: "npm:^17.7.2" + yargs-parser: "npm:^21.1.1" + peerDependencies: + monocart-coverage-reports: ^2 + peerDependenciesMeta: + monocart-coverage-reports: + optional: true + bin: + c8: bin/c8.js + checksum: 10/4c01de0fa49dab5b99bcb85bfff359764b7c027028f390841ece6e907c6ecb33eef8253291564665a5ddc81c39983947327bf0e12db88a8ab42fd5dffaf73b4b + languageName: node + linkType: hard + "caching-transform@npm:^4.0.0": version: 4.0.0 resolution: "caching-transform@npm:4.0.0" @@ -2437,6 +2466,7 @@ __metadata: applicationinsights: "npm:3.15.0" body-parser: "npm:^2.2.2" brace-expansion: "npm:^5.0.6" + c8: "npm:^11.0.0" chai: "npm:^6.2.2" chai-http: "npm:^5.1.2" config: "npm:^4.4.1" @@ -2447,6 +2477,7 @@ __metadata: dont-sniff-mimetype: "npm:^1.1.0" eslint: "npm:^10.4.1" eslint-plugin-mocha: "npm:^11.3.0" + esmock: "npm:^2.7.6" express: "npm:^5.2.1" fetch-mock: "npm:^12.6.0" form-data: "npm:^4.0.5" @@ -3275,6 +3306,13 @@ __metadata: languageName: node linkType: hard +"esmock@npm:^2.7.6": + version: 2.7.6 + resolution: "esmock@npm:2.7.6" + checksum: 10/46563a047c6c745be7553b62ddb162422a05b180fb2f98257e5b15f0c76271c3a63e9952ed63138a55236f3868187871eccebfac7eb1db0326ce2ae96c1199ab + languageName: node + linkType: hard + "espree@npm:^10.0.1": version: 10.4.0 resolution: "espree@npm:10.4.0" @@ -3631,7 +3669,7 @@ __metadata: languageName: node linkType: hard -"foreground-child@npm:^3.1.0, foreground-child@npm:^3.3.0": +"foreground-child@npm:^3.1.0, foreground-child@npm:^3.1.1, foreground-child@npm:^3.3.0": version: 3.3.1 resolution: "foreground-child@npm:3.3.1" dependencies: @@ -4418,7 +4456,7 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-report@npm:^3.0.0": +"istanbul-lib-report@npm:^3.0.0, istanbul-lib-report@npm:^3.0.1": version: 3.0.1 resolution: "istanbul-lib-report@npm:3.0.1" dependencies: @@ -4440,7 +4478,7 @@ __metadata: languageName: node linkType: hard -"istanbul-reports@npm:^3.0.2": +"istanbul-reports@npm:^3.0.2, istanbul-reports@npm:^3.1.6": version: 3.2.0 resolution: "istanbul-reports@npm:3.2.0" dependencies: @@ -6875,6 +6913,17 @@ __metadata: languageName: node linkType: hard +"v8-to-istanbul@npm:^9.0.0": + version: 9.3.0 + resolution: "v8-to-istanbul@npm:9.3.0" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.12" + "@types/istanbul-lib-coverage": "npm:^2.0.1" + convert-source-map: "npm:^2.0.0" + checksum: 10/fb1d70f1176cb9dc46cabbb3fd5c52c8f3e8738b61877b6e7266029aed0870b04140e3f9f4550ac32aebcfe1d0f38b0bac57e1e8fb97d68fec82f2b416148166 + languageName: node + linkType: hard + "vary@npm:^1.1.2": version: 1.1.2 resolution: "vary@npm:1.1.2"