624 lines
23 KiB
TypeScript
624 lines
23 KiB
TypeScript
/**
|
|
* Tests all validator providers with various JSON Schema 2020-12 features
|
|
* Based on MCP specification for elicitation schemas:
|
|
* https://modelcontextprotocol.io/specification/draft/client/elicitation.md
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { vi } from 'vitest';
|
|
|
|
import { AjvJsonSchemaValidator } from '../../src/validation/ajv-provider.js';
|
|
import { CfWorkerJsonSchemaValidator } from '../../src/validation/cfworker-provider.js';
|
|
import type { JsonSchemaType } from '../../src/validation/types.js';
|
|
|
|
// Test with both AJV and CfWorker validators
|
|
// AJV validator will use default configuration with format validation enabled
|
|
const validators = [
|
|
{ name: 'AJV', provider: new AjvJsonSchemaValidator() },
|
|
{ name: 'CfWorker', provider: new CfWorkerJsonSchemaValidator() }
|
|
];
|
|
|
|
describe('JSON Schema Validators', () => {
|
|
describe.each(validators)('$name Validator', ({ provider }) => {
|
|
describe('String schemas', () => {
|
|
it('validates basic string', () => {
|
|
const schema: JsonSchemaType = {
|
|
type: 'string'
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
const validResult = validator('hello');
|
|
expect(validResult.valid).toBe(true);
|
|
expect(validResult.data).toBe('hello');
|
|
|
|
const invalidResult = validator(123);
|
|
expect(invalidResult.valid).toBe(false);
|
|
expect(invalidResult.errorMessage).toBeDefined();
|
|
});
|
|
|
|
it('validates string with title and description', () => {
|
|
const schema: JsonSchemaType = {
|
|
type: 'string',
|
|
title: 'Name',
|
|
description: "User's full name"
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
const result = validator('John Doe');
|
|
expect(result.valid).toBe(true);
|
|
expect(result.data).toBe('John Doe');
|
|
});
|
|
|
|
it('validates string with length constraints', () => {
|
|
const schema: JsonSchemaType = {
|
|
type: 'string',
|
|
minLength: 3,
|
|
maxLength: 10
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
expect(validator('abc').valid).toBe(true);
|
|
expect(validator('abcdefghij').valid).toBe(true);
|
|
expect(validator('ab').valid).toBe(false);
|
|
expect(validator('abcdefghijk').valid).toBe(false);
|
|
});
|
|
|
|
it('validates email format', () => {
|
|
const schema: JsonSchemaType = {
|
|
type: 'string',
|
|
format: 'email'
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
expect(validator('user@example.com').valid).toBe(true);
|
|
expect(validator('invalid-email').valid).toBe(false);
|
|
});
|
|
|
|
it('validates URI format', () => {
|
|
const schema: JsonSchemaType = {
|
|
type: 'string',
|
|
format: 'uri'
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
expect(validator('https://example.com').valid).toBe(true);
|
|
expect(validator('not-a-uri').valid).toBe(false);
|
|
});
|
|
|
|
it('validates date-time format', () => {
|
|
const schema: JsonSchemaType = {
|
|
type: 'string',
|
|
format: 'date-time'
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
expect(validator('2025-10-17T12:00:00Z').valid).toBe(true);
|
|
expect(validator('not-a-date').valid).toBe(false);
|
|
});
|
|
|
|
it('validates string pattern', () => {
|
|
const schema: JsonSchemaType = {
|
|
type: 'string',
|
|
pattern: '^[A-Z]{3}$'
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
expect(validator('ABC').valid).toBe(true);
|
|
expect(validator('abc').valid).toBe(false);
|
|
expect(validator('ABCD').valid).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Number schemas', () => {
|
|
it('validates number type', () => {
|
|
const schema: JsonSchemaType = {
|
|
type: 'number'
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
expect(validator(42).valid).toBe(true);
|
|
expect(validator(3.14).valid).toBe(true);
|
|
expect(validator('42').valid).toBe(false);
|
|
});
|
|
|
|
it('validates integer type', () => {
|
|
const schema: JsonSchemaType = {
|
|
type: 'integer'
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
expect(validator(42).valid).toBe(true);
|
|
expect(validator(3.14).valid).toBe(false);
|
|
});
|
|
|
|
it('validates number range', () => {
|
|
const schema: JsonSchemaType = {
|
|
type: 'number',
|
|
minimum: 0,
|
|
maximum: 100
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
expect(validator(0).valid).toBe(true);
|
|
expect(validator(50).valid).toBe(true);
|
|
expect(validator(100).valid).toBe(true);
|
|
expect(validator(-1).valid).toBe(false);
|
|
expect(validator(101).valid).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Boolean schemas', () => {
|
|
it('validates boolean type', () => {
|
|
const schema: JsonSchemaType = {
|
|
type: 'boolean'
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
expect(validator(true).valid).toBe(true);
|
|
expect(validator(false).valid).toBe(true);
|
|
expect(validator('true').valid).toBe(false);
|
|
expect(validator(1).valid).toBe(false);
|
|
});
|
|
|
|
it('validates boolean with default', () => {
|
|
const schema: JsonSchemaType = {
|
|
type: 'boolean',
|
|
default: false
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
expect(validator(true).valid).toBe(true);
|
|
expect(validator(false).valid).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Enum schemas', () => {
|
|
it('validates enum values', () => {
|
|
const schema: JsonSchemaType = {
|
|
enum: ['red', 'green', 'blue']
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
expect(validator('red').valid).toBe(true);
|
|
expect(validator('green').valid).toBe(true);
|
|
expect(validator('blue').valid).toBe(true);
|
|
expect(validator('yellow').valid).toBe(false);
|
|
});
|
|
|
|
it('validates enum with mixed types', () => {
|
|
const schema: JsonSchemaType = {
|
|
enum: ['option1', 42, true, null]
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
expect(validator('option1').valid).toBe(true);
|
|
expect(validator(42).valid).toBe(true);
|
|
expect(validator(true).valid).toBe(true);
|
|
expect(validator(null).valid).toBe(true);
|
|
expect(validator('other').valid).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Object schemas', () => {
|
|
it('validates simple object', () => {
|
|
const schema: JsonSchemaType = {
|
|
type: 'object',
|
|
properties: {
|
|
name: { type: 'string' },
|
|
age: { type: 'number' }
|
|
},
|
|
required: ['name']
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
expect(validator({ name: 'John', age: 30 }).valid).toBe(true);
|
|
expect(validator({ name: 'John' }).valid).toBe(true);
|
|
expect(validator({ age: 30 }).valid).toBe(false);
|
|
expect(validator({}).valid).toBe(false);
|
|
});
|
|
|
|
it('validates nested objects', () => {
|
|
const schema: JsonSchemaType = {
|
|
type: 'object',
|
|
properties: {
|
|
user: {
|
|
type: 'object',
|
|
properties: {
|
|
name: { type: 'string' },
|
|
email: { type: 'string', format: 'email' }
|
|
},
|
|
required: ['name']
|
|
}
|
|
},
|
|
required: ['user']
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
expect(
|
|
validator({
|
|
user: { name: 'John', email: 'john@example.com' }
|
|
}).valid
|
|
).toBe(true);
|
|
|
|
expect(
|
|
validator({
|
|
user: { name: 'John' }
|
|
}).valid
|
|
).toBe(true);
|
|
|
|
expect(
|
|
validator({
|
|
user: { email: 'john@example.com' }
|
|
}).valid
|
|
).toBe(false);
|
|
});
|
|
|
|
it('validates object with additionalProperties: false', () => {
|
|
const schema: JsonSchemaType = {
|
|
type: 'object',
|
|
properties: {
|
|
name: { type: 'string' }
|
|
},
|
|
additionalProperties: false
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
expect(validator({ name: 'John' }).valid).toBe(true);
|
|
expect(validator({ name: 'John', extra: 'field' }).valid).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Array schemas', () => {
|
|
it('validates array of strings', () => {
|
|
const schema: JsonSchemaType = {
|
|
type: 'array',
|
|
items: { type: 'string' }
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
expect(validator(['a', 'b', 'c']).valid).toBe(true);
|
|
expect(validator([]).valid).toBe(true);
|
|
expect(validator(['a', 1, 'c']).valid).toBe(false);
|
|
});
|
|
|
|
it('validates array length constraints', () => {
|
|
const schema: JsonSchemaType = {
|
|
type: 'array',
|
|
items: { type: 'number' },
|
|
minItems: 1,
|
|
maxItems: 3
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
expect(validator([1]).valid).toBe(true);
|
|
expect(validator([1, 2, 3]).valid).toBe(true);
|
|
expect(validator([]).valid).toBe(false);
|
|
expect(validator([1, 2, 3, 4]).valid).toBe(false);
|
|
});
|
|
|
|
it('validates array with unique items', () => {
|
|
const schema: JsonSchemaType = {
|
|
type: 'array',
|
|
items: { type: 'number' },
|
|
uniqueItems: true
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
expect(validator([1, 2, 3]).valid).toBe(true);
|
|
expect(validator([1, 2, 2, 3]).valid).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('JSON Schema 2020-12 features', () => {
|
|
it('validates schema with $schema field', () => {
|
|
const schema: JsonSchemaType = {
|
|
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
type: 'string'
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
expect(validator('test').valid).toBe(true);
|
|
});
|
|
|
|
it('validates schema with $id field', () => {
|
|
const schema: JsonSchemaType = {
|
|
$id: 'https://example.com/schemas/test',
|
|
type: 'number'
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
expect(validator(42).valid).toBe(true);
|
|
});
|
|
|
|
it('validates with allOf', () => {
|
|
const schema: JsonSchemaType = {
|
|
allOf: [
|
|
{ type: 'object', properties: { name: { type: 'string' } } },
|
|
{ type: 'object', properties: { age: { type: 'number' } } }
|
|
]
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
expect(validator({ name: 'John', age: 30 }).valid).toBe(true);
|
|
expect(validator({ name: 'John' }).valid).toBe(true);
|
|
expect(validator({ name: 123 }).valid).toBe(false);
|
|
});
|
|
|
|
it('validates with anyOf', () => {
|
|
const schema: JsonSchemaType = {
|
|
anyOf: [{ type: 'string' }, { type: 'number' }]
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
expect(validator('test').valid).toBe(true);
|
|
expect(validator(42).valid).toBe(true);
|
|
expect(validator(true).valid).toBe(false);
|
|
});
|
|
|
|
it('validates with oneOf', () => {
|
|
const schema: JsonSchemaType = {
|
|
oneOf: [
|
|
{ type: 'string', minLength: 5 },
|
|
{ type: 'string', maxLength: 3 }
|
|
]
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
expect(validator('ab').valid).toBe(true); // Matches second only
|
|
expect(validator('hello').valid).toBe(true); // Matches first only
|
|
expect(validator('abcd').valid).toBe(false); // Matches neither
|
|
});
|
|
|
|
it('validates with not', () => {
|
|
const schema: JsonSchemaType = {
|
|
not: { type: 'null' }
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
expect(validator('test').valid).toBe(true);
|
|
expect(validator(42).valid).toBe(true);
|
|
expect(validator(null).valid).toBe(false);
|
|
});
|
|
|
|
it('validates with const', () => {
|
|
const schema: JsonSchemaType = {
|
|
const: 'specific-value'
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
expect(validator('specific-value').valid).toBe(true);
|
|
expect(validator('other-value').valid).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Complex real-world schemas', () => {
|
|
it('validates user registration form', () => {
|
|
const schema: JsonSchemaType = {
|
|
type: 'object',
|
|
properties: {
|
|
username: {
|
|
type: 'string',
|
|
minLength: 3,
|
|
maxLength: 20,
|
|
pattern: '^[a-zA-Z0-9_]+$'
|
|
},
|
|
email: {
|
|
type: 'string',
|
|
format: 'email'
|
|
},
|
|
age: {
|
|
type: 'integer',
|
|
minimum: 18,
|
|
maximum: 120
|
|
},
|
|
newsletter: {
|
|
type: 'boolean',
|
|
default: false
|
|
}
|
|
},
|
|
required: ['username', 'email']
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
expect(
|
|
validator({
|
|
username: 'john_doe',
|
|
email: 'john@example.com',
|
|
age: 25,
|
|
newsletter: true
|
|
}).valid
|
|
).toBe(true);
|
|
|
|
expect(
|
|
validator({
|
|
username: 'john_doe',
|
|
email: 'john@example.com'
|
|
}).valid
|
|
).toBe(true);
|
|
|
|
expect(
|
|
validator({
|
|
username: 'ab', // Too short
|
|
email: 'john@example.com'
|
|
}).valid
|
|
).toBe(false);
|
|
|
|
expect(
|
|
validator({
|
|
username: 'john_doe',
|
|
email: 'invalid-email'
|
|
}).valid
|
|
).toBe(false);
|
|
});
|
|
|
|
it('validates API response with nested structure', () => {
|
|
const schema: JsonSchemaType = {
|
|
type: 'object',
|
|
properties: {
|
|
status: {
|
|
type: 'string',
|
|
enum: ['success', 'error', 'pending']
|
|
},
|
|
data: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string' },
|
|
items: {
|
|
type: 'array',
|
|
items: {
|
|
type: 'object',
|
|
properties: {
|
|
name: { type: 'string' },
|
|
quantity: { type: 'integer', minimum: 1 }
|
|
},
|
|
required: ['name', 'quantity']
|
|
}
|
|
}
|
|
},
|
|
required: ['id', 'items']
|
|
},
|
|
timestamp: {
|
|
type: 'string',
|
|
format: 'date-time'
|
|
}
|
|
},
|
|
required: ['status', 'data']
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
expect(
|
|
validator({
|
|
status: 'success',
|
|
data: {
|
|
id: '123',
|
|
items: [
|
|
{ name: 'Item 1', quantity: 5 },
|
|
{ name: 'Item 2', quantity: 3 }
|
|
]
|
|
},
|
|
timestamp: '2025-10-17T12:00:00Z'
|
|
}).valid
|
|
).toBe(true);
|
|
|
|
expect(
|
|
validator({
|
|
status: 'invalid-status',
|
|
data: { id: '123', items: [] }
|
|
}).valid
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Error messages', () => {
|
|
it('provides helpful error message on validation failure', () => {
|
|
const schema: JsonSchemaType = {
|
|
type: 'object',
|
|
properties: {
|
|
name: { type: 'string' }
|
|
},
|
|
required: ['name']
|
|
};
|
|
const validator = provider.getValidator(schema);
|
|
|
|
const result = validator({});
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errorMessage).toBeDefined();
|
|
expect(result.errorMessage).toBeTruthy();
|
|
expect(typeof result.errorMessage).toBe('string');
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Missing dependencies', () => {
|
|
describe('AJV not installed but CfWorker is', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.doUnmock('ajv');
|
|
vi.doUnmock('ajv-formats');
|
|
});
|
|
|
|
it('should throw error when trying to import ajv-provider without ajv', async () => {
|
|
// Mock ajv as not installed
|
|
vi.doMock('ajv', () => {
|
|
throw new Error("Cannot find module 'ajv'");
|
|
});
|
|
|
|
vi.doMock('ajv-formats', () => {
|
|
throw new Error("Cannot find module 'ajv-formats'");
|
|
});
|
|
|
|
// Attempting to import ajv-provider should fail
|
|
await expect(import('../../src/validation/ajv-provider.js')).rejects.toThrow();
|
|
});
|
|
|
|
it('should be able to import cfworker-provider when ajv is missing', async () => {
|
|
// Mock ajv as not installed
|
|
vi.doMock('ajv', () => {
|
|
throw new Error("Cannot find module 'ajv'");
|
|
});
|
|
|
|
vi.doMock('ajv-formats', () => {
|
|
throw new Error("Cannot find module 'ajv-formats'");
|
|
});
|
|
|
|
// But cfworker-provider should import successfully
|
|
const cfworkerModule = await import('../../src/validation/cfworker-provider.js');
|
|
expect(cfworkerModule.CfWorkerJsonSchemaValidator).toBeDefined();
|
|
|
|
// And should work correctly
|
|
const validator = new cfworkerModule.CfWorkerJsonSchemaValidator();
|
|
const schema: JsonSchemaType = { type: 'string' };
|
|
const validatorFn = validator.getValidator(schema);
|
|
expect(validatorFn('test').valid).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('CfWorker not installed but AJV is', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.doUnmock('@cfworker/json-schema');
|
|
});
|
|
|
|
it('should throw error when trying to import cfworker-provider without @cfworker/json-schema', async () => {
|
|
// Mock @cfworker/json-schema as not installed
|
|
vi.doMock('@cfworker/json-schema', () => {
|
|
throw new Error("Cannot find module '@cfworker/json-schema'");
|
|
});
|
|
|
|
// Attempting to import cfworker-provider should fail
|
|
await expect(import('../../src/validation/cfworker-provider.js')).rejects.toThrow();
|
|
});
|
|
|
|
it('should be able to import ajv-provider when @cfworker/json-schema is missing', async () => {
|
|
// Mock @cfworker/json-schema as not installed
|
|
vi.doMock('@cfworker/json-schema', () => {
|
|
throw new Error("Cannot find module '@cfworker/json-schema'");
|
|
});
|
|
|
|
// But ajv-provider should import successfully
|
|
const ajvModule = await import('../../src/validation/ajv-provider.js');
|
|
expect(ajvModule.AjvJsonSchemaValidator).toBeDefined();
|
|
|
|
// And should work correctly
|
|
const validator = new ajvModule.AjvJsonSchemaValidator();
|
|
const schema: JsonSchemaType = { type: 'string' };
|
|
const validatorFn = validator.getValidator(schema);
|
|
expect(validatorFn('test').valid).toBe(true);
|
|
});
|
|
|
|
it('should document that @cfworker/json-schema is required', () => {
|
|
const cfworkerProviderPath = join(__dirname, '../../src/validation/cfworker-provider.ts');
|
|
const content = readFileSync(cfworkerProviderPath, 'utf-8');
|
|
|
|
expect(content).toContain('@cfworker/json-schema');
|
|
});
|
|
});
|
|
});
|