From fe0ef893f00518a0ea1eeedf90058aa9645280d3 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Wed, 27 Aug 2025 11:28:40 +0100 Subject: [PATCH 1/8] Renamed saveAndReturn to saveAndExit --- src/server/plugins/engine/views/partials/form.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/plugins/engine/views/partials/form.html b/src/server/plugins/engine/views/partials/form.html index c7a68d32e..f45397e08 100644 --- a/src/server/plugins/engine/views/partials/form.html +++ b/src/server/plugins/engine/views/partials/form.html @@ -23,4 +23,4 @@ }) }} {% endif %} - + \ No newline at end of file From 2e2d7dae104363d167391ac277156e0ed2bf9d84 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Wed, 27 Aug 2025 11:47:44 +0100 Subject: [PATCH 2/8] Further rename to saveAndExit From 1e1112253f83475c1752db681ee4bd2e3c264485 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 3 Sep 2025 17:25:39 +0100 Subject: [PATCH 3/8] Accept new class for cache service input --- .../plugins/engine/configureEnginePlugin.ts | 6 +- src/server/plugins/engine/options.js | 3 +- src/server/plugins/engine/plugin.ts | 6 +- src/server/plugins/engine/types.ts | 4 +- src/server/types.ts | 1 + test/form/cacheService.test.js | 133 ++++++++++++++++++ 6 files changed, 145 insertions(+), 8 deletions(-) create mode 100644 test/form/cacheService.test.js diff --git a/src/server/plugins/engine/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts index 3501f8fdd..a8d72a53b 100644 --- a/src/server/plugins/engine/configureEnginePlugin.ts +++ b/src/server/plugins/engine/configureEnginePlugin.ts @@ -19,7 +19,8 @@ export const configureEnginePlugin = async ({ controllers, preparePageEventRequestOptions, onRequest, - saveAndExit + saveAndExit, + cacheServiceClass }: RouteConfig = {}): Promise<{ plugin: typeof plugin options: PluginOptions @@ -59,7 +60,8 @@ export const configureEnginePlugin = async ({ preparePageEventRequestOptions, onRequest, baseUrl: 'http://localhost:3009', // always runs locally - saveAndExit + saveAndExit, + cacheServiceClass } } } diff --git a/src/server/plugins/engine/options.js b/src/server/plugins/engine/options.js index bd620753e..bb5e0f146 100644 --- a/src/server/plugins/engine/options.js +++ b/src/server/plugins/engine/options.js @@ -20,7 +20,8 @@ const pluginRegistrationOptionsSchema = Joi.object({ preparePageEventRequestOptions: Joi.function().optional(), onRequest: Joi.function().optional(), baseUrl: Joi.string().uri().required(), - saveAndExit: Joi.function().optional() + saveAndExit: Joi.function().optional(), + cacheServiceClass: Joi.function().optional() }) /** diff --git a/src/server/plugins/engine/plugin.ts b/src/server/plugins/engine/plugin.ts index 85dff2e7d..6154b03b8 100644 --- a/src/server/plugins/engine/plugin.ts +++ b/src/server/plugins/engine/plugin.ts @@ -37,10 +37,8 @@ export const plugin = { preparePageEventRequestOptions } = options - const cacheService = new CacheService({ - server, - cacheName - }) + const CacheServiceClass = options.cacheServiceClass ?? CacheService + const cacheService = new CacheServiceClass({ server, cacheName }) await registerVision(server, options) diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 05480d632..ef1eeb64a 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -43,6 +43,7 @@ import { type FormResponseToolkit, type FormStatus } from '~/src/server/routes/types.js' +import { type CacheService } from '~/src/server/services/cacheService.js' import { type RequestOptions } from '~/src/server/services/httpService.js' import { type Services } from '~/src/server/types.js' @@ -391,7 +392,8 @@ export interface PluginOptions { viewContext: PluginProperties['forms-engine-plugin']['viewContext'] preparePageEventRequestOptions?: PreparePageEventRequestOptions onRequest?: OnRequestCallback - baseUrl: string // base URL of the application, protocol and hostname e.g. "https://myapp.com" + baseUrl: string // base URL of the application, protocol and hostname e.g. "https://myapp.com", + cacheServiceClass?: typeof CacheService } export interface FormAdapterSubmissionMessageMeta { diff --git a/src/server/types.ts b/src/server/types.ts index b6bca3d00..fea7351f5 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -50,6 +50,7 @@ export interface RouteConfig { preparePageEventRequestOptions?: PreparePageEventRequestOptions onRequest?: OnRequestCallback saveAndExit?: PluginOptions['saveAndExit'] + cacheServiceClass?: PluginOptions['cacheServiceClass'] } export interface OutputService { diff --git a/test/form/cacheService.test.js b/test/form/cacheService.test.js new file mode 100644 index 000000000..98f84ea9b --- /dev/null +++ b/test/form/cacheService.test.js @@ -0,0 +1,133 @@ +import { join } from 'path' + +import { Engine as CatboxMemory } from '@hapi/catbox-memory' + +import { FORM_PREFIX } from '~/src/server/constants.js' +import { createServer } from '~/src/server/index.js' +import { CacheService } from '~/src/server/services/cacheService.js' +import { getCookie, getCookieHeader } from '~/test/utils/get-cookie.js' + +const basePath = `${FORM_PREFIX}/minimal` + +class NewCacheService extends CacheService { + /** + * + * @param {AnyRequest} _request + * @param {ADDITIONAL_IDENTIFIER} [_additionalIdentifier] + * @returns + */ + Key(_request, _additionalIdentifier) { + return { + segment: 'irrelevant', + id: 'my-custom-identifier' + } + } +} + +describe('CacheService', () => { + /** @type {Server} */ + let server + + /** @type {string} */ + let csrfToken + + /** @type {ReturnType} */ + let headers + + afterAll(async () => { + await server.stop() + }) + + test('the new cache service is utilised', async () => { + // Spy on CatboxMemory.prototype.set globally + const setStateSpy = jest.spyOn(NewCacheService.prototype, 'setState') + const catboxSetSpy = jest.spyOn(CatboxMemory.prototype, 'set') + + server = await createServer({ + formFileName: 'minimal.js', + formFilePath: join(import.meta.dirname, 'definitions'), + cacheServiceClass: NewCacheService + }) + + await server.initialize() + + // Navigate to start + const response = await server.inject({ + url: `${basePath}/start`, + headers + }) + + // Extract the session cookie + csrfToken = getCookie(response, 'crumb') + headers = getCookieHeader(response, ['session', 'crumb']) + + // Submit answers + await server.inject({ + url: `${basePath}/start`, + method: 'POST', + headers, + payload: { + crumb: csrfToken, + field: 'value' + } + }) + + // assert our new custom cache is used + expect(setStateSpy).toHaveBeenCalled() + setStateSpy.mockRestore() + + // Assert the custom ID 'my-custom-identifier' is used + expect(catboxSetSpy).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'my-custom-identifier', + segment: 'formSubmission' + }), + expect.any(Object), + expect.any(Number) + ) + catboxSetSpy.mockRestore() + }) + + test('the default cache service is utilised', async () => { + // Spy on CatboxMemory.prototype.set globally + const setStateSpy = jest.spyOn(CacheService.prototype, 'setState') + + server = await createServer({ + formFileName: 'minimal.js', + formFilePath: join(import.meta.dirname, 'definitions') + }) + + await server.initialize() + + // Navigate to start + const response = await server.inject({ + url: `${basePath}/start`, + headers + }) + + // Extract the session cookie + csrfToken = getCookie(response, 'crumb') + headers = getCookieHeader(response, ['session', 'crumb']) + + // Submit answers + await server.inject({ + url: `${basePath}/start`, + method: 'POST', + headers, + payload: { + crumb: csrfToken, + field: 'value' + } + }) + + // assert our new custom cache is used + expect(setStateSpy).toHaveBeenCalled() + setStateSpy.mockRestore() + }) +}) + +/** + * @import { Server } from '@hapi/hapi' + * @import { AnyFormRequest, AnyRequest, FormSubmissionState } from '~/src/server/plugins/engine/types.js' + * @import { ADDITIONAL_IDENTIFIER } from '~/src/server/services/cacheService.js' + */ From 71ace3eed15255cf7c41a458decef555dddcccf0 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 4 Sep 2025 16:23:40 +0100 Subject: [PATCH 4/8] Accept a CacheService instance instead of a cache name --- docs/GETTING_STARTED.md | 2 +- docs/PLUGIN_OPTIONS.md | 5 +++- src/server/index.ts | 5 +++- .../plugins/engine/configureEnginePlugin.ts | 28 ++++++++++--------- src/server/plugins/engine/options.js | 9 ++++-- src/server/plugins/engine/plugin.ts | 8 ++++-- src/server/plugins/engine/types.ts | 5 ++-- src/server/types.ts | 4 ++- test/form/cacheService.test.js | 5 ++-- 9 files changed, 43 insertions(+), 28 deletions(-) diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index bb4953329..4e428874e 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -111,7 +111,7 @@ const paths = [join(config.get('appDir'), 'views')] await server.register({ plugin, options: { - cacheName: 'session', // must match a session you've instantiated in your hapi server config + cache: 'session', // must match a session you've instantiated in your hapi server config. Also accepts a CacheService instance for advanced use-cases. /** * Options that DXT uses to render Nunjucks templates */ diff --git a/docs/PLUGIN_OPTIONS.md b/docs/PLUGIN_OPTIONS.md index 42d74ca20..4a5421ff7 100644 --- a/docs/PLUGIN_OPTIONS.md +++ b/docs/PLUGIN_OPTIONS.md @@ -16,7 +16,10 @@ The forms plugin is configured with [registration options](https://hapi.dev/api/ - `controllers` (optional) - Object map of custom page controllers used to override the default. See [custom controllers](#custom-controllers) - `globals` (optional) - A map of custom template globals to include - `filters` (optional) - A map of custom template filters to include -- `cacheName` (optional) - The cache name to use. Defaults to hapi's [default server cache]. Recommended for production. See [here](#custom-cache) for more details +- `cache` (optional) - Caching options + - `cache` (optional) - Caching options. Recommended for production. This can be either: + - a string representing the cache name to use (e.g. hapi's default server cache). See [here](#custom-cache) for more details. + - a custom `CacheService` instance implementing your own caching logic - `pluginPath` (optional) - The location of the plugin (defaults to `node_modules/@defra/forms-engine-plugin`) - `preparePageEventRequestOptions` (optional) - A function that will be invoked for http-based [page events](./features/configuration-based/PAGE_EVENTS.md). See [here](./features/configuration-based/PAGE_EVENTS.md#authenticating-a-http-page-event-request-from-dxt-in-your-api) for details - `saveAndExit` (optional) - Configuration for custom session management including key generation, session hydration, and persistence. See [save and exit documentation](./features/code-based/SAVE_AND_EXIT.md) for details diff --git a/src/server/index.ts b/src/server/index.ts index 9af3e79ad..f4a3aa7f4 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -82,8 +82,11 @@ export async function createServer(routeConfig?: RouteConfig) { prepareSecureContext(server) } + const cacheService = routeConfig?.cacheServiceCreator + ? routeConfig.cacheServiceCreator(server) + : undefined const pluginCrumb = configureCrumbPlugin(routeConfig) - const pluginEngine = await configureEnginePlugin(routeConfig) + const pluginEngine = await configureEnginePlugin(routeConfig, cacheService) await server.register(pluginSession) await server.register(pluginPulse) diff --git a/src/server/plugins/engine/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts index a8d72a53b..dba5fa230 100644 --- a/src/server/plugins/engine/configureEnginePlugin.ts +++ b/src/server/plugins/engine/configureEnginePlugin.ts @@ -10,18 +10,21 @@ import { formsService } from '~/src/server/plugins/engine/services/localFormsSer import { type PluginOptions } from '~/src/server/plugins/engine/types.js' import { findPackageRoot } from '~/src/server/plugins/engine/vision.js' import { devtoolContext } from '~/src/server/plugins/nunjucks/context.js' +import { type CacheService } from '~/src/server/services/cacheService.js' import { type RouteConfig } from '~/src/server/types.js' -export const configureEnginePlugin = async ({ - formFileName, - formFilePath, - services, - controllers, - preparePageEventRequestOptions, - onRequest, - saveAndExit, - cacheServiceClass -}: RouteConfig = {}): Promise<{ +export const configureEnginePlugin = async ( + { + formFileName, + formFilePath, + services, + controllers, + preparePageEventRequestOptions, + onRequest, + saveAndExit + }: RouteConfig = {}, + cache?: CacheService +): Promise<{ plugin: typeof plugin options: PluginOptions }> => { @@ -51,7 +54,7 @@ export const configureEnginePlugin = async ({ formsService: await formsService() }, controllers, - cacheName: 'session', + cache: cache ?? 'session', nunjucks: { baseLayoutPath: 'dxt-devtool-baselayout.html', paths: [join(findPackageRoot(), 'src/server/devserver')] // custom layout to make it really clear this is not the same as the runner @@ -60,8 +63,7 @@ export const configureEnginePlugin = async ({ preparePageEventRequestOptions, onRequest, baseUrl: 'http://localhost:3009', // always runs locally - saveAndExit, - cacheServiceClass + saveAndExit } } } diff --git a/src/server/plugins/engine/options.js b/src/server/plugins/engine/options.js index bb5e0f146..39f04daca 100644 --- a/src/server/plugins/engine/options.js +++ b/src/server/plugins/engine/options.js @@ -1,6 +1,7 @@ import Joi from 'joi' import { createLogger } from '~/src/server/common/helpers/logging/logger.js' +import { CacheService } from '~/src/server/services/index.js' const logger = createLogger() @@ -8,7 +9,10 @@ const pluginRegistrationOptionsSchema = Joi.object({ model: Joi.object().optional(), services: Joi.object().optional(), controllers: Joi.object().pattern(Joi.string(), Joi.any()).optional(), - cacheName: Joi.string().optional(), + cache: Joi.alternatives().try( + Joi.object().instance(CacheService), + Joi.string() + ), globals: Joi.object().pattern(Joi.string(), Joi.any()).optional(), filters: Joi.object().pattern(Joi.string(), Joi.any()).optional(), pluginPath: Joi.string().optional(), @@ -20,8 +24,7 @@ const pluginRegistrationOptionsSchema = Joi.object({ preparePageEventRequestOptions: Joi.function().optional(), onRequest: Joi.function().optional(), baseUrl: Joi.string().uri().required(), - saveAndExit: Joi.function().optional(), - cacheServiceClass: Joi.function().optional() + saveAndExit: Joi.function().optional() }) /** diff --git a/src/server/plugins/engine/plugin.ts b/src/server/plugins/engine/plugin.ts index 6154b03b8..2c783d38d 100644 --- a/src/server/plugins/engine/plugin.ts +++ b/src/server/plugins/engine/plugin.ts @@ -30,15 +30,17 @@ export const plugin = { const { model, - cacheName, + cache, saveAndExit, nunjucks: nunjucksOptions, viewContext, preparePageEventRequestOptions } = options - const CacheServiceClass = options.cacheServiceClass ?? CacheService - const cacheService = new CacheServiceClass({ server, cacheName }) + const cacheService = + typeof cache === 'string' + ? new CacheService({ server, cacheName: cache }) + : cache await registerVision(server, options) diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index ef1eeb64a..7576545d7 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -380,7 +380,7 @@ export interface PluginOptions { model?: FormModel services?: Services controllers?: Record - cacheName?: string + cache?: CacheService | string globals?: Record filters?: Record saveAndExit?: SaveAndExitHandler @@ -392,8 +392,7 @@ export interface PluginOptions { viewContext: PluginProperties['forms-engine-plugin']['viewContext'] preparePageEventRequestOptions?: PreparePageEventRequestOptions onRequest?: OnRequestCallback - baseUrl: string // base URL of the application, protocol and hostname e.g. "https://myapp.com", - cacheServiceClass?: typeof CacheService + baseUrl: string // base URL of the application, protocol and hostname e.g. "https://myapp.com" } export interface FormAdapterSubmissionMessageMeta { diff --git a/src/server/types.ts b/src/server/types.ts index fea7351f5..b5179ae00 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -4,6 +4,7 @@ import { type SubmitPayload, type SubmitResponsePayload } from '@defra/forms-model' +import { type Server } from '@hapi/hapi' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { type DetailItem } from '~/src/server/plugins/engine/models/types.js' @@ -18,6 +19,7 @@ import { type FormRequestPayload, type FormStatus } from '~/src/server/routes/types.js' +import { type CacheService } from '~/src/server/services/cacheService.js' export interface FormsService { getFormMetadata: (slug: string) => Promise @@ -50,7 +52,7 @@ export interface RouteConfig { preparePageEventRequestOptions?: PreparePageEventRequestOptions onRequest?: OnRequestCallback saveAndExit?: PluginOptions['saveAndExit'] - cacheServiceClass?: PluginOptions['cacheServiceClass'] + cacheServiceCreator?: (server: Server) => CacheService } export interface OutputService { diff --git a/test/form/cacheService.test.js b/test/form/cacheService.test.js index 98f84ea9b..5b7739dfe 100644 --- a/test/form/cacheService.test.js +++ b/test/form/cacheService.test.js @@ -34,7 +34,7 @@ describe('CacheService', () => { /** @type {ReturnType} */ let headers - afterAll(async () => { + afterEach(async () => { await server.stop() }) @@ -46,7 +46,8 @@ describe('CacheService', () => { server = await createServer({ formFileName: 'minimal.js', formFilePath: join(import.meta.dirname, 'definitions'), - cacheServiceClass: NewCacheService + cacheServiceCreator: (server) => + new NewCacheService({ server, cacheName: 'session' }) }) await server.initialize() From 932335dbb06fc4769a7681f5bc33c3ec86b71499 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 5 Sep 2025 17:21:53 +0100 Subject: [PATCH 5/8] Export CacheService --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index f6e673813..c7bf3eca1 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "./helpers.js": "./.server/server/plugins/engine/components/helpers.js", "./schema.js": "./.server/server/schemas/index.js", "./templates/*": "./.server/server/plugins/engine/views/*", + "./cacheService.js": "./.server/server/services/cacheService.js", "./package.json": "./package.json" }, "scripts": { From bab69c6659054401970e35a14e363f41e2368bf1 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 5 Sep 2025 17:39:39 +0100 Subject: [PATCH 6/8] use kebab case to align cache service with file form service --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c7bf3eca1..ab12825bc 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "./helpers.js": "./.server/server/plugins/engine/components/helpers.js", "./schema.js": "./.server/server/schemas/index.js", "./templates/*": "./.server/server/plugins/engine/views/*", - "./cacheService.js": "./.server/server/services/cacheService.js", + "./cache-service.js": "./.server/server/services/cacheService.js", "./package.json": "./package.json" }, "scripts": { From 70bb349f9dd7ad23a96899f177259b0563233112 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 5 Sep 2025 17:43:06 +0100 Subject: [PATCH 7/8] Add trailing newline to satisfy editorconfig --- src/server/plugins/engine/views/partials/form.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/plugins/engine/views/partials/form.html b/src/server/plugins/engine/views/partials/form.html index f45397e08..c7a68d32e 100644 --- a/src/server/plugins/engine/views/partials/form.html +++ b/src/server/plugins/engine/views/partials/form.html @@ -23,4 +23,4 @@ }) }} {% endif %} - \ No newline at end of file + From ea68514e3dc4d6c29d4023c20f987404f8334d07 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 5 Sep 2025 17:51:20 +0100 Subject: [PATCH 8/8] Fix state leakage between tests --- test/form/cacheService.test.js | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/test/form/cacheService.test.js b/test/form/cacheService.test.js index 5b7739dfe..d71e01f4d 100644 --- a/test/form/cacheService.test.js +++ b/test/form/cacheService.test.js @@ -28,12 +28,6 @@ describe('CacheService', () => { /** @type {Server} */ let server - /** @type {string} */ - let csrfToken - - /** @type {ReturnType} */ - let headers - afterEach(async () => { await server.stop() }) @@ -53,20 +47,21 @@ describe('CacheService', () => { await server.initialize() // Navigate to start + const headers = undefined const response = await server.inject({ url: `${basePath}/start`, headers }) // Extract the session cookie - csrfToken = getCookie(response, 'crumb') - headers = getCookieHeader(response, ['session', 'crumb']) + const csrfToken = getCookie(response, 'crumb') + const newHeaders = getCookieHeader(response, ['session', 'crumb']) // Submit answers await server.inject({ url: `${basePath}/start`, method: 'POST', - headers, + headers: newHeaders, payload: { crumb: csrfToken, field: 'value' @@ -101,20 +96,21 @@ describe('CacheService', () => { await server.initialize() // Navigate to start + const headers = undefined const response = await server.inject({ url: `${basePath}/start`, headers }) // Extract the session cookie - csrfToken = getCookie(response, 'crumb') - headers = getCookieHeader(response, ['session', 'crumb']) + const csrfToken = getCookie(response, 'crumb') + const newHeaders = getCookieHeader(response, ['session', 'crumb']) // Submit answers await server.inject({ url: `${basePath}/start`, method: 'POST', - headers, + headers: newHeaders, payload: { crumb: csrfToken, field: 'value'