diff --git a/src/errors/index.ts b/src/errors/index.ts index 539e9063..b1557ae9 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -14,6 +14,7 @@ export type SchemaValidationErrorType = | 'forbidden' | 'const' | 'enum' + | 'additionalProperties' /** * Schema composition keywords (allOf, anyOf, oneOf, not) * These keywords apply subschemas in a logical manner according to JSON Schema spec diff --git a/src/errors/messages.ts b/src/errors/messages.ts index 7f796658..ba635cf7 100644 --- a/src/errors/messages.ts +++ b/src/errors/messages.ts @@ -112,6 +112,8 @@ export function getErrorMessage( throw new Error('"minContains" is not implemented yet') case 'maxContains': throw new Error('"maxContains" is not implemented yet') + case 'additionalProperties': + return 'Additional property is not allowed' case 'json-logic': return customErrorMessage || 'The value is not valid' } diff --git a/src/validation/schema.ts b/src/validation/schema.ts index 9030a8f2..acfdfc53 100644 --- a/src/validation/schema.ts +++ b/src/validation/schema.ts @@ -147,6 +147,14 @@ function validateJsonLogicSchema(value: SchemaValue, schema: JsfSchema | undefin return validateSchema(value, schema, options, path, jsonLogicContext) } +interface CompiledPattern { regex: RegExp } + +function compilePatternProperties(patternProperties: Record = {}): CompiledPattern[] { + return Object.keys(patternProperties).map( + pattern => ({ regex: new RegExp(pattern) }), + ) +} + /** * Validate a value against a schema * @param value - The value to validate @@ -259,6 +267,25 @@ export function validateSchema( } } + if (schema.additionalProperties === false && isObjectValue(value)) { + const definedProps = new Set(Object.keys(schema.properties || {})) + const compiledPatterns = compilePatternProperties(schema.patternProperties) + + for (const key of Object.keys(value)) { + const isDefined = definedProps.has(key) + const matchesPattern = compiledPatterns.some(({ regex }) => regex.test(key)) + + if (!isDefined && !matchesPattern) { + errors.push({ + path: [...path, key], + validation: 'additionalProperties', + schema, + value: value[key], + }) + } + } + } + return [ ...errors, // JSON-schema spec validations diff --git a/test/validation/additional_properties.test.ts b/test/validation/additional_properties.test.ts new file mode 100644 index 00000000..e48ca396 --- /dev/null +++ b/test/validation/additional_properties.test.ts @@ -0,0 +1,93 @@ +import type { JsfObjectSchema } from '../../src/types' +import { describe, expect, it } from '@jest/globals' +import { createHeadlessForm } from '../../src' + +describe('additionalProperties validation', () => { + describe('basic additionalProperties: false', () => { + const schema: JsfObjectSchema = { + type: 'object', + properties: { + a: { type: 'integer' }, + b: { type: 'string' }, + }, + additionalProperties: false, + } + const form = createHeadlessForm(schema) + + it('allows objects with only defined properties', () => { + expect(form.handleValidation({ a: 1 })).not.toHaveProperty('formErrors') + expect(form.handleValidation({ a: 1, b: 'test' })).not.toHaveProperty('formErrors') + }) + + it('rejects objects with additional properties', () => { + expect(form.handleValidation({ a: 1, c: 'extra' })).toMatchObject({ + formErrors: { c: 'Additional property is not allowed' }, + }) + + expect(form.handleValidation({ a: 1, b: 'test', c: 'extra' })).toMatchObject({ + formErrors: { c: 'Additional property is not allowed' }, + }) + }) + + it('rejects objects with only additional properties', () => { + expect(form.handleValidation({ c: 'extra' })).toMatchObject({ + formErrors: { c: 'Additional property is not allowed' }, + }) + }) + }) + + describe('additionalProperties: false with patternProperties', () => { + const schema: JsfObjectSchema = { + type: 'object', + properties: { + foo: {}, + bar: {}, + }, + patternProperties: { + '^v': {}, + }, + additionalProperties: false, + } + const form = createHeadlessForm(schema) + + it('allows properties defined in properties', () => { + expect(form.handleValidation({ foo: 1 })).not.toHaveProperty('formErrors') + expect(form.handleValidation({ foo: 1, bar: 2 })).not.toHaveProperty('formErrors') + }) + + it('allows properties matching patternProperties', () => { + expect(form.handleValidation({ vroom: 1 })).not.toHaveProperty('formErrors') + expect(form.handleValidation({ vampire: 1 })).not.toHaveProperty('formErrors') + expect(form.handleValidation({ v: 1 })).not.toHaveProperty('formErrors') + }) + + it('allows combination of properties and patternProperties', () => { + expect(form.handleValidation({ foo: 1, vroom: 2 })).not.toHaveProperty('formErrors') + expect(form.handleValidation({ foo: 1, bar: 2, vampire: 3 })).not.toHaveProperty('formErrors') + }) + + it('rejects properties that match neither properties nor patternProperties', () => { + expect(form.handleValidation({ foo: 1, quux: 'boom' })).toMatchObject({ + formErrors: { quux: 'Additional property is not allowed' }, + }) + + expect(form.handleValidation({ hello: 'world' })).toMatchObject({ + formErrors: { hello: 'Additional property is not allowed' }, + }) + }) + }) + + describe('when additionalProperties is not false', () => { + const schema: JsfObjectSchema = { + type: 'object', + properties: { + a: { type: 'integer' }, + }, + } + const form = createHeadlessForm(schema) + + it('allows additional properties', () => { + expect(form.handleValidation({ a: 1, b: 2, c: 3 })).not.toHaveProperty('formErrors') + }) + }) +})