Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

### :new: **New features**

- Add new Express `session` option for custom session stores
- Add support for `.env` files

### :wrench: **Fixes**

- Fix imports via `node_modules/` like GOV.UK Frontend
Expand Down
1 change: 1 addition & 0 deletions lib/index.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ describe('CommonJS package exports', () => {
assert.equal(typeof utils.addNunjucksFilters, 'function')
assert.equal(typeof utils.findAvailablePort, 'function')
assert.equal(typeof utils.getAvailablePort, 'function')
assert.equal(typeof utils.getSessionName, 'function')
assert.equal(typeof utils.normaliseOptions, 'function')
})
})
1 change: 1 addition & 0 deletions lib/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ describe('ES module named exports', () => {
assert.equal(typeof utils.addNunjucksFilters, 'function')
assert.equal(typeof utils.findAvailablePort, 'function')
assert.equal(typeof utils.getAvailablePort, 'function')
assert.equal(typeof utils.getSessionName, 'function')
assert.equal(typeof utils.normaliseOptions, 'function')
})
})
35 changes: 12 additions & 23 deletions lib/middleware/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import flash from 'express-flash'
import session from 'express-session'

import * as config from '../nhsuk-prototype-kit.config.js'
import { getSessionName } from '../utils/index.js'

import { authentication } from './authentication.js'
import { autoRoutes } from './auto-routes.js'
Expand Down Expand Up @@ -31,19 +32,6 @@ export {

const SESSION_COOKIE_MAX_AGE = 1000 * 60 * 60 * 4 // 4 hours

/**
* Generate a unique session name based on the service name
*
* @param {string} serviceName -
*/
function generateSessionName(serviceName) {
const hash = new TextEncoder()
.encode(serviceName)
.reduce((hex, byte) => hex + byte.toString(16).padStart(2, '0'), '')

return `nhsuk-prototype-kit-${hash}`
}

/**
* Configure locals middleware
*
Expand All @@ -68,18 +56,19 @@ export function configureLocals(options) {
*/
export function configureSession(options) {
const { app, serviceName } = options
const sessionName = generateSessionName(serviceName)
const sessionName = getSessionName(serviceName)

app.use(
session({
secret: sessionName,
name: sessionName,
resave: false,
saveUninitialized: true,
cookie: {
maxAge: SESSION_COOKIE_MAX_AGE
}
})
options.session ??
session({
secret: sessionName,
name: sessionName,
resave: false,
saveUninitialized: true,
cookie: {
maxAge: SESSION_COOKIE_MAX_AGE
}
})
)
}

Expand Down
41 changes: 27 additions & 14 deletions lib/middleware/reset-session-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,40 @@
* @param {NextFunction} next
*/
export function resetSessionData(req, res, next) {
const { path, method } = req
let { returnPage } = req.body ?? req.query

if (method === 'GET' && path === '/prototype-admin/reset') {
const returnPage = req.query.returnPage || '/'
res.render('reset', {
// Allow local paths only
if (typeof returnPage !== 'string' || !returnPage.startsWith('/')) {
returnPage = '/'
}

if (req.method === 'GET' && req.path === '/prototype-admin/reset') {
return res.render('reset', {
returnPage
})
} else if (path === '/prototype-admin/reset-session-data') {
const returnPage =
req.body.returnPage && req.body.returnPage.startsWith('/')
? req.body.returnPage // Local paths only
: '/'
}

req.session.data = {}
if (req.path === '/prototype-admin/reset-session-data') {
return req.session.regenerate((error) => {
if (error) {
return next(error)
}

res.render('reset-done', {
returnPage
req.session.data = {}

req.session.save((error) => {
if (error) {
return next(error)
}

res.render('reset-done', {
returnPage
})
})
})
} else {
next()
}

next()
}

/**
Expand Down
126 changes: 77 additions & 49 deletions lib/middleware/reset-session-data.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ describe('resetSessionData middleware', () => {
method: 'GET',
originalUrl: '/test-page',
query: {},
body: {},
session: {}
session: {
regenerate: (cb) => cb(),
save: (cb) => cb()
}
})

res = /** @type {Response} */ ({
Expand All @@ -38,99 +40,124 @@ describe('resetSessionData middleware', () => {

it('should render the reset view with returnPage from query', () => {
req.query.returnPage = '/custom-page'
resetSessionData(req, res, next)

assert.deepEqual(render.mock.calls[0].arguments, [
'reset',
{ returnPage: '/custom-page' }
])
resetSessionData(req, res, (error) => {
assert.equal(error, undefined)

assert.deepEqual(render.mock.calls[0].arguments, [
'reset',
{ returnPage: '/custom-page' }
])
})
})

it('should use "/" as default returnPage when not provided', () => {
resetSessionData(req, res, next)
resetSessionData(req, res, (error) => {
assert.equal(error, undefined)

assert.deepEqual(render.mock.calls[0].arguments, [
'reset',
{ returnPage: '/' }
])
assert.deepEqual(render.mock.calls[0].arguments, [
'reset',
{ returnPage: '/' }
])
})
})

it('should not call next()', () => {
resetSessionData(req, res, next)

assert.equal(next.mock.callCount(), 0)
})
})

describe('POST /prototype-admin/reset-session-data', () => {
beforeEach(() => {
Object.assign(req, {
body: {},
method: 'POST',
path: '/prototype-admin/reset-session-data'
})
})

it('should reset session data and render reset-done view', () => {
req.body.returnPage = '/dashboard'
resetSessionData(req, res, next)

assert.deepEqual(req.session.data, {})
resetSessionData(req, res, (error) => {
assert.equal(error, undefined)

assert.deepEqual(render.mock.calls[0].arguments, [
'reset-done',
{ returnPage: '/dashboard' }
])
assert.deepEqual(req.session.data, {})

assert.deepEqual(render.mock.calls[0].arguments, [
'reset-done',
{ returnPage: '/dashboard' }
])
})
})

it('should use "/" as default when returnPage not provided', () => {
resetSessionData(req, res, next)
resetSessionData(req, res, (error) => {
assert.equal(error, undefined)

assert.deepEqual(req.session.data, {})
assert.deepEqual(req.session.data, {})

assert.deepEqual(render.mock.calls[0].arguments, [
'reset-done',
{ returnPage: '/' }
])
assert.deepEqual(render.mock.calls[0].arguments, [
'reset-done',
{ returnPage: '/' }
])
})
})

it('should reject returnPage that does not start with "/"', () => {
req.body.returnPage = 'https://malicious.com'
resetSessionData(req, res, next)

assert.deepEqual(render.mock.calls[0].arguments, [
'reset-done',
{ returnPage: '/' }
])
resetSessionData(req, res, (error) => {
assert.equal(error, undefined)

assert.deepEqual(render.mock.calls[0].arguments, [
'reset-done',
{ returnPage: '/' }
])
})
})

it('should reject returnPage without leading slash', () => {
req.body.returnPage = 'relative-path'
resetSessionData(req, res, next)

assert.deepEqual(render.mock.calls[0].arguments, [
'reset-done',
{ returnPage: '/' }
])
resetSessionData(req, res, (error) => {
assert.equal(error, undefined)

assert.deepEqual(render.mock.calls[0].arguments, [
'reset-done',
{ returnPage: '/' }
])
})
})

it('should accept valid local path with query string', () => {
req.body.returnPage = '/page?foo=bar'
resetSessionData(req, res, next)

assert.deepEqual(render.mock.calls[0].arguments, [
'reset-done',
{ returnPage: '/page?foo=bar' }
])
resetSessionData(req, res, (error) => {
assert.equal(error, undefined)

assert.deepEqual(render.mock.calls[0].arguments, [
'reset-done',
{ returnPage: '/page?foo=bar' }
])
})
})

it('should clear existing session data', () => {
req.session.data = { key1: 'value1', key2: 'value2' }
resetSessionData(req, res, next)

assert.deepEqual(req.session.data, {})
resetSessionData(req, res, (error) => {
assert.equal(error, undefined)

assert.deepEqual(req.session.data, {})
})
})

it('should not call next()', () => {
resetSessionData(req, res, next)

assert.equal(next.mock.callCount(), 0)
})
})
Expand All @@ -156,20 +183,21 @@ describe('resetSessionData middleware', () => {
// Note: The middleware doesn't check method, so GET requests will also reset session
// In practice, req.body would be empty for GET, so returnPage defaults to '/'
session: {
...req.session,
data: { key: 'value' }
}
})

resetSessionData(req, res, next)
resetSessionData(req, res, (error) => {
assert.equal(error, undefined)

assert.deepEqual(req.session.data, {})
assert.deepEqual(req.session.data, {})

assert.deepEqual(render.mock.calls[0].arguments, [
'reset-done',
{ returnPage: '/' }
])

assert.equal(next.mock.callCount(), 0)
assert.deepEqual(render.mock.calls[0].arguments, [
'reset-done',
{ returnPage: '/' }
])
})
})
})
})
Expand Down
21 changes: 20 additions & 1 deletion lib/nhsuk-prototype-kit.config.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { existsSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { cwd } from 'node:process'
import { fileURLToPath } from 'node:url'
Expand All @@ -6,9 +7,22 @@ import nhsukFrontendPkg from 'nhsuk-frontend/package.json' with { type: 'json' }

import pkg from '../package.json' with { type: 'json' }

// Application path
/**
* Application path
*/
export const appPath = cwd()

/**
* Script path
*/
export const scriptPath =
process.mainModule?.filename ?? join(appPath, 'app.js')

/**
* Environment variables path
*/
export const envPath = join(dirname(scriptPath), '.env')

/**
* NHS prototype kit path
*/
Expand Down Expand Up @@ -67,3 +81,8 @@ export const searchPaths = [
join(nhsukFrontendPath, 'dist'),
...modulePaths
]

// Load environment variables (optional)
if (existsSync(envPath)) {
process.loadEnvFile(envPath)
}
1 change: 1 addition & 0 deletions lib/nhsuk-prototype-kit.js
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ export class NHSPrototypeKit {
* @property {NunjucksFilters | ((env: Environment) => NunjucksFilters)} [filters] - Additional custom Nunjucks filters
* @property {object} [config] - Configuration options
* @property {BuildOptions} [buildOptions] - Build options
* @property {RequestHandler} [session] - Middleware to use custom session store
* @property {{ [key: string]: unknown }} [sessionDataDefaults] - Default data to set in the session
*/

Expand Down
Loading