diff --git a/src/server/plugins/engine/__stubs__/definitions.ts b/src/server/plugins/engine/__stubs__/definitions.ts new file mode 100644 index 000000000..4762193f8 --- /dev/null +++ b/src/server/plugins/engine/__stubs__/definitions.ts @@ -0,0 +1,155 @@ +import { + ComponentType, + ConditionType, + ControllerType, + Engine, + OperatorName, + SchemaVersion, + type FormDefinition, + type PageQuestion +} from '@defra/forms-model' + +const YES_NO_COMPONENT_ID = 'a7c0242f-2a31-45b2-8c71-ff2ac7f53288' +const USER_TYPE_LIST_ID = '4fa26e9c-07cf-47cd-a9dd-5cec0dd3f544' +const EXISTING_USER_ITEM_ID = '55fe0067-d011-4d33-886c-e1aa266637c3' + +/** + * A minimal, schema-valid V2 definition: one YesNo question page and a + * summary page. Each call returns a fresh object, so tests can mutate their + * copy freely without affecting each other. + */ +export function buildDefinition(): FormDefinition { + return { + name: 'Stub definition', + engine: Engine.V2, + schema: SchemaVersion.V2, + startPage: '/summary', + pages: [ + { + id: '449c053b-9201-4312-9a75-187afc6ba48b', + path: '/licence', + title: 'Licence', + components: [ + { + id: YES_NO_COMPONENT_ID, + name: 'xVrYaJ', + type: ComponentType.YesNoField, + title: 'Do you have a licence?', + shortDescription: 'Licence', + options: { required: true } + } + ], + next: [] + }, + { + id: '449c053b-9201-4312-9a75-187afc6ba48c', + path: '/summary', + title: 'Summary', + controller: ControllerType.Summary, + components: [] + } + ], + lists: [], + sections: [], + conditions: [] + } +} + +/** + * A definition that passes schema validation but cannot be used by the + * engine: a ListItemRef condition points at the YesNoField (boolean), so the + * condition expression builder produces an unparseable expression. Distilled + * from a real production incident. + */ +export function buildBrokenConditionDefinition(): FormDefinition { + const definition = buildDefinition() + + definition.name = 'Broken condition fixture' + + const questionPage = definition.pages[0] as PageQuestion + // A YesNoField cannot legitimately carry a custom list — that is the + // corruption this fixture models — so the TS component types (rightly) + // have no `list` property here and a cast is required. + ;(questionPage.components[0] as { list?: string }).list = USER_TYPE_LIST_ID + definition.lists = [ + { + id: USER_TYPE_LIST_ID, + name: 'XtfRYR', + title: 'User type list', + type: 'string', + items: [ + { + id: EXISTING_USER_ITEM_ID, + text: 'existing user', + value: 'existing user' + }, + { + id: '2277c7e5-7fef-46c6-993b-d294116d6d6b', + text: 'new user', + value: 'new user' + } + ] + } + ] + definition.conditions = [ + { + id: '3f9d3a35-6dee-4706-806c-3f776129f631', + displayName: 'Existing user', + items: [ + { + id: '7d7f58ee-c860-4d24-8a13-de5cb9af53d8', + componentId: YES_NO_COMPONENT_ID, + operator: OperatorName.Is, + type: ConditionType.ListItemRef, + value: { + listId: USER_TYPE_LIST_ID, + itemId: [EXISTING_USER_ITEM_ID] + } + } + ] + } + ] + + return definition +} + +/** + * A schema-valid definition whose first page names a controller that is + * neither built in nor registered by the host application. + */ +export function buildUnknownControllerDefinition(): FormDefinition { + const definition = buildDefinition() + + definition.name = 'Unknown controller fixture' + definition.pages[0].controller = + 'NoSuchPageController' as unknown as ControllerType + + return definition +} + +/** + * Schema-valid (component types are free strings in the schema), but the + * question uses a type the engine has no component class for. + */ +export function buildUnknownComponentDefinition(): FormDefinition { + const definition = buildDefinition() + + definition.name = 'Unknown component fixture' + const questionPage = definition.pages[0] as PageQuestion + questionPage.components[0].type = 'MyUnknownField' as unknown as ComponentType + + return definition +} + +/** + * A definition that fails schema validation: the question page appears + * twice, violating the pages uniqueness rule. + */ +export function buildSchemaInvalidDefinition(): FormDefinition { + const definition = buildDefinition() + + definition.name = 'Schema invalid fixture' + definition.pages.push(buildDefinition().pages[0]) + + return definition +} diff --git a/src/server/plugins/engine/components/helpers/components.ts b/src/server/plugins/engine/components/helpers/components.ts index ff10294dc..728fc8616 100644 --- a/src/server/plugins/engine/components/helpers/components.ts +++ b/src/server/plugins/engine/components/helpers/components.ts @@ -6,6 +6,7 @@ import { ListFormComponent } from '~/src/server/plugins/engine/components/ListFo import { escapeMarkdown } from '~/src/server/plugins/engine/components/helpers/index.js' import * as Components from '~/src/server/plugins/engine/components/index.js' import { markdown } from '~/src/server/plugins/engine/components/markdownParser.js' +import { UnknownComponentTypeError } from '~/src/server/plugins/engine/errors.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type FormState } from '~/src/server/plugins/engine/types.js' @@ -210,7 +211,7 @@ export function createComponent( } if (typeof component === 'undefined') { - throw new Error(`Component type ${def.type} does not exist`) + throw new UnknownComponentTypeError(def.type) } return component diff --git a/src/server/plugins/engine/errors.test.ts b/src/server/plugins/engine/errors.test.ts new file mode 100644 index 000000000..e4a72f073 --- /dev/null +++ b/src/server/plugins/engine/errors.test.ts @@ -0,0 +1,130 @@ +import { + buildBrokenConditionDefinition, + buildSchemaInvalidDefinition, + buildUnknownComponentDefinition, + buildUnknownControllerDefinition +} from '~/src/server/plugins/engine/__stubs__/definitions.js' +import { + ConditionBuildError, + InvalidFormDefinitionError, + SchemaValidationError, + UnknownComponentTypeError, + UnknownPageControllerError +} from '~/src/server/plugins/engine/errors.js' +import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' + +describe('InvalidFormDefinitionError hierarchy', () => { + it('ConditionBuildError carries the condition name and cause', () => { + const cause = new Error('parse error [1:24]: Expected EOF') + const err = new ConditionBuildError('Existing user', { cause }) + + expect(err).toBeInstanceOf(InvalidFormDefinitionError) + expect(err.name).toBe('ConditionBuildError') + expect(err.conditionName).toBe('Existing user') + expect(err.message).toBe("Failed to build condition 'Existing user'") + expect(err.cause).toBe(cause) + }) + + it('UnknownPageControllerError carries the controller name', () => { + const err = new UnknownPageControllerError('NoSuchPageController') + + expect(err).toBeInstanceOf(InvalidFormDefinitionError) + expect(err.name).toBe('UnknownPageControllerError') + expect(err.controllerName).toBe('NoSuchPageController') + expect(err.message).toBe( + 'Page controller NoSuchPageController does not exist' + ) + }) + + it('UnknownComponentTypeError carries the component type', () => { + const err = new UnknownComponentTypeError('NopeField') + + expect(err).toBeInstanceOf(InvalidFormDefinitionError) + expect(err.name).toBe('UnknownComponentTypeError') + expect(err.componentType).toBe('NopeField') + expect(err.message).toBe('Component type NopeField does not exist') + }) +}) + +describe('typed errors thrown from real failure sites', () => { + it('FormModel throws ConditionBuildError for an uncompilable condition', () => { + const build = () => + new FormModel(buildBrokenConditionDefinition(), { basePath: 'test' }) + + expect(build).toThrow(ConditionBuildError) + + let thrown: unknown + try { + build() + } catch (err) { + thrown = err + } + + const conditionErr = thrown as ConditionBuildError + expect(conditionErr.conditionName).toBe('Existing user') + expect(conditionErr.cause).toBeInstanceOf(Error) + expect((conditionErr.cause as Error).message).toContain('parse error') + }) + + it('FormModel throws UnknownPageControllerError for an unregistered controller', () => { + const build = () => + new FormModel(buildUnknownControllerDefinition(), { basePath: 'test' }) + + expect(build).toThrow(UnknownPageControllerError) + + let thrown: unknown + try { + build() + } catch (err) { + thrown = err + } + + expect((thrown as UnknownPageControllerError).controllerName).toBe( + 'NoSuchPageController' + ) + }) +}) + +describe('typed errors thrown from real failure sites (components)', () => { + it('FormModel throws UnknownComponentTypeError for an unregistered component type', () => { + const build = () => + new FormModel(buildUnknownComponentDefinition(), { basePath: 'test' }) + + expect(build).toThrow(UnknownComponentTypeError) + + let thrown: unknown + try { + build() + } catch (err) { + thrown = err + } + + expect((thrown as UnknownComponentTypeError).componentType).toBe( + 'MyUnknownField' + ) + }) +}) + +describe('SchemaValidationError', () => { + it('is thrown by FormModel for a schema-invalid definition, wrapping the raw Joi error', () => { + const build = () => + new FormModel(buildSchemaInvalidDefinition(), { basePath: 'test' }) + + let thrown: unknown + try { + build() + } catch (err) { + thrown = err + } + + expect(thrown).toBeInstanceOf(SchemaValidationError) + expect(thrown).toBeInstanceOf(InvalidFormDefinitionError) + + const schemaError = thrown as SchemaValidationError + expect(schemaError.name).toBe('SchemaValidationError') + expect(schemaError.message).toContain('Invalid form definition:') + + expect(schemaError.cause.isJoi).toBe(true) + expect(Array.isArray(schemaError.cause.details)).toBe(true) + }) +}) diff --git a/src/server/plugins/engine/errors.ts b/src/server/plugins/engine/errors.ts new file mode 100644 index 000000000..493af107c --- /dev/null +++ b/src/server/plugins/engine/errors.ts @@ -0,0 +1,78 @@ +import { type ValidationError } from 'joi' + +/** + * Base class for errors caused by an invalid form definition — whether it + * fails schema validation or passes the schema but cannot be used by the + * engine. Never thrown directly — throw a subclass. Consumers (e.g. + * forms-runner error pages) detect the family with + * `instanceof InvalidFormDefinitionError`. + * + * When subclassing, remember that the message and the messages of any + * `cause` chain are shown to form authors on preview error pages. Only + * describe the problem with the form itself — never include sensitive + * details such as file paths, configuration values or secrets. + */ +export class InvalidFormDefinitionError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'InvalidFormDefinitionError' + } +} + +/** + * Thrown when a condition in the definition cannot be compiled into an + * evaluatable expression (e.g. it references the wrong component or list). + */ +export class ConditionBuildError extends InvalidFormDefinitionError { + public readonly conditionName: string + + constructor(conditionName: string, options?: ErrorOptions) { + super(`Failed to build condition '${conditionName}'`, options) + this.name = 'ConditionBuildError' + this.conditionName = conditionName + } +} + +/** + * Thrown when a page names a controller that is neither built in nor + * registered by the host application. + */ +export class UnknownPageControllerError extends InvalidFormDefinitionError { + public readonly controllerName: string + + constructor(controllerName: string) { + super(`Page controller ${controllerName} does not exist`) + this.name = 'UnknownPageControllerError' + this.controllerName = controllerName + } +} + +/** + * Thrown when a component declares a type with no registered implementation. + */ +export class UnknownComponentTypeError extends InvalidFormDefinitionError { + public readonly componentType: string + + constructor(componentType: string) { + super(`Component type ${componentType} does not exist`) + this.name = 'UnknownComponentTypeError' + this.componentType = componentType + } +} + +/** + * Thrown when a form definition fails Joi schema validation. The raw + * ValidationError is preserved as `cause` so consumers can reach the + * per-field details; the message carries Joi's own summary so log lines + * remain diagnostic for consumers that only read `error.message`. + */ +export class SchemaValidationError extends InvalidFormDefinitionError { + // Error types `cause` as unknown; this constructor only accepts a Joi + // ValidationError, so narrow the declaration for consumers (type-only). + declare cause: ValidationError + + constructor(cause: ValidationError) { + super(`Invalid form definition: ${cause.message}`, { cause }) + this.name = 'SchemaValidationError' + } +} diff --git a/src/server/plugins/engine/models/FormModel.test.ts b/src/server/plugins/engine/models/FormModel.test.ts index 76574e1cc..d3084e2b7 100644 --- a/src/server/plugins/engine/models/FormModel.test.ts +++ b/src/server/plugins/engine/models/FormModel.test.ts @@ -9,6 +9,7 @@ import { } from '@defra/forms-model' import { todayAsDateOnly } from '~/src/server/plugins/engine/date-helper.js' +import { SchemaValidationError } from '~/src/server/plugins/engine/errors.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js' import { type FormContextRequest } from '~/src/server/plugins/engine/types.js' @@ -107,13 +108,22 @@ describe('FormModel', () => { ).toBeDefined() }) - it('throws an error if schema validation fails', () => { + it('throws a SchemaValidationError if schema validation fails', () => { + const validationError = new Error('Validation error') formDefinitionV2Schema.validate = jest.fn().mockReturnValueOnce({ - error: 'Validation error' + error: validationError }) expect(() => new FormModel(definitionV2, { basePath: 'test' })).toThrow( - 'Validation error' + SchemaValidationError + ) + + formDefinitionV2Schema.validate = jest.fn().mockReturnValueOnce({ + error: validationError + }) + + expect(() => new FormModel(definitionV2, { basePath: 'test' })).toThrow( + 'Invalid form definition: Validation error' ) }) diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index 5bbf07aa5..0309e8826 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -39,6 +39,10 @@ import { type Component } from '~/src/server/plugins/engine/components/helpers/components.js' import { todayAsDateOnly } from '~/src/server/plugins/engine/date-helper.js' +import { + ConditionBuildError, + SchemaValidationError +} from '~/src/server/plugins/engine/errors.js' import { findPage, getError, @@ -124,7 +128,7 @@ export class FormModel { const result = schema.validate(def, { abortEarly: false }) if (result.error) { - throw result.error + throw new SchemaValidationError(result.error) } // Make a clone of the shallow copy returned @@ -292,7 +296,13 @@ export class FormModel { }) const { name, displayName, value } = condition - const expr = this.toConditionExpression(value, parser) + + let expr + try { + expr = this.toConditionExpression(value, parser) + } catch (cause) { + throw new ConditionBuildError(displayName, { cause }) + } const fn = (evaluationState: FormState) => { const ctx = this.toConditionContext(evaluationState, this.conditions) diff --git a/src/server/plugins/engine/pageControllers/helpers/pages.ts b/src/server/plugins/engine/pageControllers/helpers/pages.ts index c87c9b3dd..e30b4fe8d 100644 --- a/src/server/plugins/engine/pageControllers/helpers/pages.ts +++ b/src/server/plugins/engine/pageControllers/helpers/pages.ts @@ -4,6 +4,7 @@ import { type Page } from '@defra/forms-model' +import { UnknownPageControllerError } from '~/src/server/plugins/engine/errors.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import * as PageControllers from '~/src/server/plugins/engine/pageControllers/index.js' @@ -66,7 +67,7 @@ export function createPage(model: FormModel, pageDef: Page) { } if (typeof controller === 'undefined') { - throw new Error(`Page controller ${pageDef.controller} does not exist`) + throw new UnknownPageControllerError(pageDef.controller) } return controller