Skip to content
Merged
155 changes: 155 additions & 0 deletions src/server/plugins/engine/__stubs__/definitions.ts
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 2 additions & 1 deletion src/server/plugins/engine/components/helpers/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand Down
130 changes: 130 additions & 0 deletions src/server/plugins/engine/errors.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
78 changes: 78 additions & 0 deletions src/server/plugins/engine/errors.ts
Original file line number Diff line number Diff line change
@@ -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'
}
}
Loading
Loading